Merge branch 'next' into feat/improve-network-mode-check

This commit is contained in:
Andras Bacsai
2025-09-22 12:31:36 +02:00
committed by GitHub
791 changed files with 32444 additions and 8477 deletions

11
.cursor/mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

View File

@@ -1,6 +1,6 @@
--- ---
description: description: Complete guide to Coolify Cursor rules and development patterns
globs: globs: .cursor/rules/*.mdc
alwaysApply: false alwaysApply: false
--- ---
# Coolify Cursor Rules - Complete Guide # Coolify Cursor Rules - Complete Guide
@@ -18,6 +18,7 @@ This comprehensive set of Cursor Rules provides deep insights into **Coolify**,
### 🎨 Frontend Development ### 🎨 Frontend Development
- **[frontend-patterns.mdc](mdc:.cursor/rules/frontend-patterns.mdc)** - Livewire + Alpine.js + Tailwind architecture - **[frontend-patterns.mdc](mdc:.cursor/rules/frontend-patterns.mdc)** - Livewire + Alpine.js + Tailwind architecture
- **[form-components.mdc](mdc:.cursor/rules/form-components.mdc)** - Enhanced form components with built-in authorization
### 🗄️ Data & Backend ### 🗄️ Data & Backend
- **[database-patterns.mdc](mdc:.cursor/rules/database-patterns.mdc)** - Database architecture, models, and data management - **[database-patterns.mdc](mdc:.cursor/rules/database-patterns.mdc)** - Database architecture, models, and data management

View File

@@ -1,6 +1,6 @@
--- ---
description: description: RESTful API design, routing patterns, webhooks, and HTTP communication
globs: globs: routes/*.php, app/Http/Controllers/**/*.php, app/Http/Resources/*.php, app/Http/Requests/*.php
alwaysApply: false alwaysApply: false
--- ---
# Coolify API & Routing Architecture # Coolify API & Routing Architecture

View File

@@ -1,6 +1,6 @@
--- ---
description: description: Laravel application structure, patterns, and architectural decisions
globs: globs: app/**/*.php, config/*.php, bootstrap/**/*.php
alwaysApply: false alwaysApply: false
--- ---
# Coolify Application Architecture # Coolify Application Architecture

View File

@@ -1,6 +1,6 @@
--- ---
description: description: Database architecture, models, migrations, relationships, and data management patterns
globs: globs: app/Models/*.php, database/migrations/*.php, database/seeders/*.php, app/Actions/Database/*.php
alwaysApply: false alwaysApply: false
--- ---
# Coolify Database Architecture & Patterns # Coolify Database Architecture & Patterns

View File

@@ -1,6 +1,6 @@
--- ---
description: description: Docker orchestration, deployment workflows, and containerization patterns
globs: globs: app/Jobs/*.php, app/Actions/Application/*.php, app/Actions/Server/*.php, docker/*.*, *.yml, *.yaml
alwaysApply: false alwaysApply: false
--- ---
# Coolify Deployment Architecture # Coolify Deployment Architecture

View File

@@ -1,6 +1,6 @@
--- ---
description: description: Development setup, coding standards, contribution guidelines, and best practices
globs: globs: **/*.php, composer.json, package.json, *.md, .env.example
alwaysApply: false alwaysApply: false
--- ---
# Coolify Development Workflow # Coolify Development Workflow

View File

@@ -0,0 +1,452 @@
---
description: Enhanced form components with built-in authorization system
globs: resources/views/**/*.blade.php, app/View/Components/Forms/*.php
alwaysApply: true
---
# Enhanced Form Components with Authorization
## Overview
Coolify's form components now feature **built-in authorization** that automatically handles permission-based UI control, dramatically reducing code duplication and improving security consistency.
## Enhanced Components
All form components now support the `canGate` authorization system:
- **[Input.php](mdc:app/View/Components/Forms/Input.php)** - Text, password, and other input fields
- **[Select.php](mdc:app/View/Components/Forms/Select.php)** - Dropdown selection components
- **[Textarea.php](mdc:app/View/Components/Forms/Textarea.php)** - Multi-line text areas
- **[Checkbox.php](mdc:app/View/Components/Forms/Checkbox.php)** - Boolean toggle components
- **[Button.php](mdc:app/View/Components/Forms/Button.php)** - Action buttons
## Authorization Parameters
### Core Parameters
```php
public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete'
public mixed $canResource = null; // Resource model instance to check against
public bool $autoDisable = true; // Automatically disable if no permission
```
### How It Works
```php
// Automatic authorization logic in each component
if ($this->canGate && $this->canResource && $this->autoDisable) {
$hasPermission = Gate::allows($this->canGate, $this->canResource);
if (! $hasPermission) {
$this->disabled = true;
// For Checkbox: also sets $this->instantSave = false;
}
}
```
## Usage Patterns
### ✅ Recommended: Single Line Pattern
**Before (Verbose, 6+ lines per element):**
```html
@can('update', $application)
<x-forms.input id="application.name" label="Name" />
<x-forms.checkbox instantSave id="application.settings.is_static" label="Static Site" />
<x-forms.button type="submit">Save</x-forms.button>
@else
<x-forms.input disabled id="application.name" label="Name" />
<x-forms.checkbox disabled id="application.settings.is_static" label="Static Site" />
@endcan
```
**After (Clean, 1 line per element):**
```html
<x-forms.input canGate="update" :canResource="$application" id="application.name" label="Name" />
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="application.settings.is_static" label="Static Site" />
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
```
**Result: 90% code reduction!**
### Component-Specific Examples
#### Input Fields
```html
<!-- Basic input with authorization -->
<x-forms.input
canGate="update"
:canResource="$application"
id="application.name"
label="Application Name" />
<!-- Password input with authorization -->
<x-forms.input
type="password"
canGate="update"
:canResource="$application"
id="application.database_password"
label="Database Password" />
<!-- Required input with authorization -->
<x-forms.input
required
canGate="update"
:canResource="$application"
id="application.fqdn"
label="Domain" />
```
#### Select Dropdowns
```html
<!-- Build pack selection -->
<x-forms.select
canGate="update"
:canResource="$application"
id="application.build_pack"
label="Build Pack"
required>
<option value="nixpacks">Nixpacks</option>
<option value="static">Static</option>
<option value="dockerfile">Dockerfile</option>
</x-forms.select>
<!-- Server selection -->
<x-forms.select
canGate="createAnyResource"
:canResource="auth()->user()->currentTeam"
id="server_id"
label="Target Server">
@foreach($servers as $server)
<option value="{{ $server->id }}">{{ $server->name }}</option>
@endforeach
</x-forms.select>
```
#### Checkboxes with InstantSave
```html
<!-- Static site toggle -->
<x-forms.checkbox
instantSave
canGate="update"
:canResource="$application"
id="application.settings.is_static"
label="Is it a static site?"
helper="Enable if your application serves static files" />
<!-- Debug mode toggle -->
<x-forms.checkbox
instantSave
canGate="update"
:canResource="$application"
id="application.settings.is_debug_enabled"
label="Debug Mode"
helper="Enable debug logging for troubleshooting" />
<!-- Build server toggle -->
<x-forms.checkbox
instantSave
canGate="update"
:canResource="$application"
id="application.settings.is_build_server_enabled"
label="Use Build Server"
helper="Use a dedicated build server for compilation" />
```
#### Textareas
```html
<!-- Configuration textarea -->
<x-forms.textarea
canGate="update"
:canResource="$application"
id="application.docker_compose_raw"
label="Docker Compose Configuration"
rows="10"
monacoEditorLanguage="yaml"
useMonacoEditor />
<!-- Custom commands -->
<x-forms.textarea
canGate="update"
:canResource="$application"
id="application.post_deployment_command"
label="Post-Deployment Commands"
placeholder="php artisan migrate"
helper="Commands to run after deployment" />
```
#### Buttons
```html
<!-- Save button -->
<x-forms.button
canGate="update"
:canResource="$application"
type="submit">
Save Configuration
</x-forms.button>
<!-- Deploy button -->
<x-forms.button
canGate="deploy"
:canResource="$application"
wire:click="deploy">
Deploy Application
</x-forms.button>
<!-- Delete button -->
<x-forms.button
canGate="delete"
:canResource="$application"
wire:click="confirmDelete"
class="button-danger">
Delete Application
</x-forms.button>
```
## Advanced Usage
### Custom Authorization Logic
```html
<!-- Disable auto-control for complex permissions -->
<x-forms.input
canGate="update"
:canResource="$application"
autoDisable="false"
:disabled="$application->is_deployed || !$application->canModifySettings()"
id="deployment.setting"
label="Advanced Setting" />
```
### Multiple Permission Checks
```html
<!-- Combine multiple authorization requirements -->
<x-forms.checkbox
canGate="deploy"
:canResource="$application"
autoDisable="false"
:disabled="!$application->hasDockerfile() || !Gate::allows('deploy', $application)"
id="docker.setting"
label="Docker-Specific Setting" />
```
### Conditional Resources
```html
<!-- Different resources based on context -->
<x-forms.button
:canGate="$isEditing ? 'update' : 'view'"
:canResource="$resource"
type="submit">
{{ $isEditing ? 'Save Changes' : 'View Details' }}
</x-forms.button>
```
## Supported Gates
### Resource-Level Gates
- `view` - Read access to resource details
- `update` - Modify resource configuration and settings
- `deploy` - Deploy, restart, or manage resource state
- `delete` - Remove or destroy resource
- `clone` - Duplicate resource to another location
### Global Gates
- `createAnyResource` - Create new resources of any type
- `manageTeam` - Team administration permissions
- `accessServer` - Server-level access permissions
## Supported Resources
### Primary Resources
- `$application` - Application instances and configurations
- `$service` - Docker Compose services and components
- `$database` - Database instances (PostgreSQL, MySQL, etc.)
- `$server` - Physical or virtual server instances
### Container Resources
- `$project` - Project containers and environments
- `$environment` - Environment-specific configurations
- `$team` - Team and organization contexts
### Infrastructure Resources
- `$privateKey` - SSH private keys and certificates
- `$source` - Git sources and repositories
- `$destination` - Deployment destinations and targets
## Component Behavior
### Input Components (Input, Select, Textarea)
When authorization fails:
- **disabled = true** - Field becomes non-editable
- **Visual styling** - Opacity reduction and disabled cursor
- **Form submission** - Values are ignored in forms
- **User feedback** - Clear visual indication of restricted access
### Checkbox Components
When authorization fails:
- **disabled = true** - Checkbox becomes non-clickable
- **instantSave = false** - Automatic saving is disabled
- **State preservation** - Current value is maintained but read-only
- **Visual styling** - Disabled appearance with reduced opacity
### Button Components
When authorization fails:
- **disabled = true** - Button becomes non-clickable
- **Event blocking** - Click handlers are ignored
- **Visual styling** - Disabled appearance and cursor
- **Loading states** - Loading indicators are disabled
## Migration Guide
### Converting Existing Forms
**Old Pattern:**
```html
<form wire:submit='submit'>
@can('update', $application)
<x-forms.input id="name" label="Name" />
<x-forms.select id="type" label="Type">...</x-forms.select>
<x-forms.checkbox instantSave id="enabled" label="Enabled" />
<x-forms.button type="submit">Save</x-forms.button>
@else
<x-forms.input disabled id="name" label="Name" />
<x-forms.select disabled id="type" label="Type">...</x-forms.select>
<x-forms.checkbox disabled id="enabled" label="Enabled" />
@endcan
</form>
```
**New Pattern:**
```html
<form wire:submit='submit'>
<x-forms.input canGate="update" :canResource="$application" id="name" label="Name" />
<x-forms.select canGate="update" :canResource="$application" id="type" label="Type">...</x-forms.select>
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="enabled" label="Enabled" />
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
</form>
```
### Gradual Migration Strategy
1. **Start with new forms** - Use the new pattern for all new components
2. **Convert high-traffic areas** - Migrate frequently used forms first
3. **Batch convert similar forms** - Group similar authorization patterns
4. **Test thoroughly** - Verify authorization behavior matches expectations
5. **Remove old patterns** - Clean up legacy @can/@else blocks
## Testing Patterns
### Component Authorization Tests
```php
// Test authorization integration in components
test('input component respects authorization', function () {
$user = User::factory()->member()->create();
$application = Application::factory()->create();
// Member should see disabled input
$component = Livewire::actingAs($user)
->test(TestComponent::class, [
'canGate' => 'update',
'canResource' => $application
]);
expect($component->get('disabled'))->toBeTrue();
});
test('checkbox disables instantSave for unauthorized users', function () {
$user = User::factory()->member()->create();
$application = Application::factory()->create();
$component = Livewire::actingAs($user)
->test(CheckboxComponent::class, [
'instantSave' => true,
'canGate' => 'update',
'canResource' => $application
]);
expect($component->get('disabled'))->toBeTrue();
expect($component->get('instantSave'))->toBeFalse();
});
```
### Integration Tests
```php
// Test full form authorization behavior
test('application form respects member permissions', function () {
$member = User::factory()->member()->create();
$application = Application::factory()->create();
$this->actingAs($member)
->get(route('application.edit', $application))
->assertSee('disabled')
->assertDontSee('Save Configuration');
});
```
## Best Practices
### Consistent Gate Usage
- Use `update` for configuration changes
- Use `deploy` for operational actions
- Use `view` for read-only access
- Use `delete` for destructive actions
### Resource Context
- Always pass the specific resource being acted upon
- Use team context for creation permissions
- Consider nested resource relationships
### Error Handling
- Provide clear feedback for disabled components
- Use helper text to explain permission requirements
- Consider tooltips for disabled buttons
### Performance
- Authorization checks are cached per request
- Use eager loading for resource relationships
- Consider query optimization for complex permissions
## Common Patterns
### Application Configuration Forms
```html
<!-- Application settings with consistent authorization -->
<x-forms.input canGate="update" :canResource="$application" id="application.name" label="Name" />
<x-forms.select canGate="update" :canResource="$application" id="application.build_pack" label="Build Pack">...</x-forms.select>
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="application.settings.is_static" label="Static Site" />
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
```
### Service Configuration Forms
```html
<!-- Service stack configuration with authorization -->
<x-forms.input canGate="update" :canResource="$service" id="service.name" label="Service Name" />
<x-forms.input canGate="update" :canResource="$service" id="service.description" label="Description" />
<x-forms.checkbox canGate="update" :canResource="$service" instantSave id="service.connect_to_docker_network" label="Connect To Predefined Network" />
<x-forms.button canGate="update" :canResource="$service" type="submit">Save</x-forms.button>
<!-- Service-specific fields -->
<x-forms.input canGate="update" :canResource="$service" type="{{ data_get($field, 'isPassword') ? 'password' : 'text' }}"
required="{{ str(data_get($field, 'rules'))?->contains('required') }}"
id="fields.{{ $serviceName }}.value"></x-forms.input>
<!-- Service restart modal - wrapped with @can -->
@can('update', $service)
<x-modal-confirmation title="Confirm Service Application Restart?"
buttonTitle="Restart"
submitAction="restartApplication({{ $application->id }})" />
@endcan
```
### Server Management Forms
```html
<!-- Server configuration with appropriate gates -->
<x-forms.input canGate="update" :canResource="$server" id="server.name" label="Server Name" />
<x-forms.select canGate="update" :canResource="$server" id="server.type" label="Server Type">...</x-forms.select>
<x-forms.button canGate="delete" :canResource="$server" wire:click="deleteServer">Delete Server</x-forms.button>
```
### Resource Creation Forms
```html
<!-- New resource creation -->
<x-forms.input canGate="createAnyResource" :canResource="auth()->user()->currentTeam" id="name" label="Name" />
<x-forms.select canGate="createAnyResource" :canResource="auth()->user()->currentTeam" id="server_id" label="Server">...</x-forms.select>
<x-forms.button canGate="createAnyResource" :canResource="auth()->user()->currentTeam" type="submit">Create Application</x-forms.button>
```

View File

@@ -1,6 +1,6 @@
--- ---
description: description: Livewire components, Alpine.js patterns, Tailwind CSS, and enhanced form components
globs: globs: app/Livewire/**/*.php, resources/views/**/*.blade.php, resources/js/**/*.js, resources/css/**/*.css
alwaysApply: false alwaysApply: false
--- ---
# Coolify Frontend Architecture & Patterns # Coolify Frontend Architecture & Patterns
@@ -230,6 +230,41 @@ class ServerList extends Component
- **Asset bundling** and compression - **Asset bundling** and compression
- **CDN integration** for static assets - **CDN integration** for static assets
## Enhanced Form Components
### Built-in Authorization System
Coolify features **enhanced form components** with automatic authorization handling:
```html
<!-- ✅ New Pattern: Single line with built-in authorization -->
<x-forms.input canGate="update" :canResource="$application" id="application.name" label="Name" />
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="application.settings.is_static" label="Static Site" />
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
<!-- ❌ Old Pattern: Verbose @can/@else blocks (deprecated) -->
@can('update', $application)
<x-forms.input id="application.name" label="Name" />
@else
<x-forms.input disabled id="application.name" label="Name" />
@endcan
```
### Authorization Parameters
```php
// Available on all form components (Input, Select, Textarea, Checkbox, Button)
public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete'
public mixed $canResource = null; // Resource model instance to check against
public bool $autoDisable = true; // Automatically disable if no permission (default: true)
```
### Benefits
- **90% code reduction** for authorization-protected forms
- **Consistent security** across all form components
- **Automatic disabling** for unauthorized users
- **Smart behavior** (disables instantSave on checkboxes for unauthorized users)
For complete documentation, see **[form-components.mdc](mdc:.cursor/rules/form-components.mdc)**
## Form Handling Patterns ## Form Handling Patterns
### Livewire Forms ### Livewire Forms

View File

@@ -0,0 +1,405 @@
---
alwaysApply: true
---
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.7
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v3
- laravel/dusk (DUSK) - v8
- laravel/pint (PINT) - v1
- laravel/telescope (TELESCOPE) - v5
- pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11
- rector/rector (RECTOR) - v2
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
- vue (VUE) - v3
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
=== php rules ===
## PHP
- Always use curly braces for control structures, even if it has one line.
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== laravel/core rules ===
## Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
## Laravel 12
- Use the `search-docs` tool to get version specific documentation.
- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that.
### Laravel 10 Structure
- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
- Middleware registration happens in `app/Http/Kernel.php`
- Exception handling is in `app/Exceptions/Handler.php`
- Console commands and schedule register in `app/Console/Kernel.php`
- Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules ===
## Livewire Core
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== livewire/v3 rules ===
## Livewire 3
### Key Changes From Livewire 2
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
### New Directives
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
### Alpine
- Alpine is now included with Livewire, don't manually include Alpine.js.
- Plugins included with Alpine: persist, intersect, collapse, and focus.
### Lifecycle Hooks
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
<code-snippet name="livewire:load example" lang="js">
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
alert('Your session expired');
}
});
Livewire.hook('message.failed', (message, component) => {
console.error(message);
});
});
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== pest/core rules ===
## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== tailwindcss/core rules ===
## Tailwind Core
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing, don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff"
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines>

View File

@@ -1,6 +1,6 @@
--- ---
description: description: High-level project mission, core concepts, and architectural overview
globs: globs: README.md, CONTRIBUTING.md, CHANGELOG.md, *.md
alwaysApply: false alwaysApply: false
--- ---
# Coolify Project Overview # Coolify Project Overview

View File

@@ -1,7 +1,7 @@
--- ---
description: description: Security architecture, authentication, authorization patterns, and enhanced form component security
globs: globs: app/Policies/*.php, app/View/Components/Forms/*.php, app/Http/Middleware/*.php, resources/views/**/*.blade.php
alwaysApply: false alwaysApply: true
--- ---
# Coolify Security Architecture & Patterns # Coolify Security Architecture & Patterns
@@ -63,6 +63,323 @@ class User extends Authenticatable
## Authorization & Access Control ## Authorization & Access Control
### Enhanced Form Component Authorization System
Coolify now features a **centralized authorization system** built into all form components (`Input`, `Select`, `Textarea`, `Checkbox`, `Button`) that automatically handles permission-based UI control.
#### Component Authorization Parameters
```php
// Available on all form components
public ?string $canGate = null; // Gate name (e.g., 'update', 'view', 'delete')
public mixed $canResource = null; // Resource to check against (model instance)
public bool $autoDisable = true; // Auto-disable if no permission (default: true)
```
#### Smart Authorization Logic
```php
// Automatic authorization handling in component constructor
if ($this->canGate && $this->canResource && $this->autoDisable) {
$hasPermission = Gate::allows($this->canGate, $this->canResource);
if (! $hasPermission) {
$this->disabled = true;
// For Checkbox: also disables instantSave
}
}
```
#### Usage Examples
**✅ Recommended Pattern (Single Line):**
```html
<!-- Input with automatic authorization -->
<x-forms.input
canGate="update"
:canResource="$application"
id="application.name"
label="Application Name" />
<!-- Select with automatic authorization -->
<x-forms.select
canGate="update"
:canResource="$application"
id="application.build_pack"
label="Build Pack">
<option value="nixpacks">Nixpacks</option>
<option value="static">Static</option>
</x-forms.select>
<!-- Checkbox with automatic instantSave control -->
<x-forms.checkbox
instantSave
canGate="update"
:canResource="$application"
id="application.settings.is_static"
label="Is Static Site?" />
<!-- Button with automatic disable -->
<x-forms.button
canGate="update"
:canResource="$application"
type="submit">
Save Configuration
</x-forms.button>
```
**❌ Old Pattern (Verbose, Deprecated):**
```html
<!-- DON'T use this repetitive pattern anymore -->
@can('update', $application)
<x-forms.input id="application.name" label="Application Name" />
<x-forms.button type="submit">Save</x-forms.button>
@else
<x-forms.input disabled id="application.name" label="Application Name" />
@endcan
```
#### Advanced Usage with Custom Control
**Custom Authorization Logic:**
```html
<!-- Disable auto-control, use custom logic -->
<x-forms.input
canGate="update"
:canResource="$application"
autoDisable="false"
:disabled="$application->is_deployed || !Gate::allows('update', $application)"
id="advanced.setting"
label="Advanced Setting" />
```
**Multiple Permission Checks:**
```html
<!-- Complex permission requirements -->
<x-forms.checkbox
canGate="deploy"
:canResource="$application"
autoDisable="false"
:disabled="!$application->canDeploy() || !auth()->user()->hasAdvancedPermissions()"
id="deployment.setting"
label="Advanced Deployment Setting" />
```
#### Supported Gates and Resources
**Common Gates:**
- `view` - Read access to resource
- `update` - Modify resource configuration
- `deploy` - Deploy/restart resource
- `delete` - Remove resource
- `createAnyResource` - Create new resources
**Resource Types:**
- `Application` - Application instances
- `Service` - Docker Compose services
- `Server` - Server instances
- `Project` - Project containers
- `Environment` - Environment contexts
- `Database` - Database instances
#### Benefits
**🔥 Massive Code Reduction:**
- **90% less code** for authorization-protected forms
- **Single line** instead of 6-12 lines per form element
- **No more @can/@else blocks** cluttering templates
**🛡️ Consistent Security:**
- **Unified authorization logic** across all form components
- **Automatic disabling** for unauthorized users
- **Smart behavior** (like disabling instantSave on checkboxes)
**🎨 Better UX:**
- **Consistent disabled styling** across all components
- **Proper visual feedback** for restricted access
- **Clean, professional interface**
#### Implementation Details
**Component Enhancement:**
```php
// Enhanced in all form components
use Illuminate\Support\Facades\Gate;
public function __construct(
// ... existing parameters
public ?string $canGate = null,
public mixed $canResource = null,
public bool $autoDisable = true,
) {
// Handle authorization-based disabling
if ($this->canGate && $this->canResource && $this->autoDisable) {
$hasPermission = Gate::allows($this->canGate, $this->canResource);
if (! $hasPermission) {
$this->disabled = true;
// For Checkbox: $this->instantSave = false;
}
}
}
```
**Backward Compatibility:**
- All existing form components continue to work unchanged
- New authorization parameters are optional
- Legacy @can/@else patterns still function but are discouraged
### Custom Component Authorization Patterns
When dealing with **custom Alpine.js components** or complex UI elements that don't use the standard `x-forms.*` components, manual authorization protection is required since the automatic `canGate` system only applies to enhanced form components.
#### Common Custom Components Requiring Manual Protection
**⚠️ Custom Components That Need Manual Authorization:**
- Custom dropdowns/selects with Alpine.js
- Complex form widgets with JavaScript interactions
- Multi-step wizards or dynamic forms
- Third-party component integrations
- Custom date/time pickers
- File upload components with drag-and-drop
#### Manual Authorization Pattern
**✅ Proper Manual Authorization:**
```html
<!-- Custom timezone dropdown example -->
<div class="w-full">
<div class="flex items-center mb-1">
<label for="customComponent">Component Label</label>
<x-helper helper="Component description" />
</div>
@can('update', $resource)
<!-- Full interactive component for authorized users -->
<div x-data="{
open: false,
value: '{{ $currentValue }}',
options: @js($options),
init() { /* Alpine.js initialization */ }
}">
<input x-model="value" @focus="open = true"
wire:model="propertyName" class="w-full input">
<div x-show="open">
<!-- Interactive dropdown content -->
<template x-for="option in options" :key="option">
<div @click="value = option; open = false; $wire.submit()"
x-text="option"></div>
</template>
</div>
</div>
@else
<!-- Read-only version for unauthorized users -->
<div class="relative">
<input readonly disabled autocomplete="off"
class="w-full input opacity-50 cursor-not-allowed"
value="{{ $currentValue ?: 'No value set' }}">
<svg class="absolute right-0 mr-2 w-4 h-4 opacity-50">
<!-- Disabled icon -->
</svg>
</div>
@endcan
</div>
```
#### Implementation Checklist
When implementing authorization for custom components:
**🔍 1. Identify Custom Components:**
- Look for Alpine.js `x-data` declarations
- Find components not using `x-forms.*` prefix
- Check for JavaScript-heavy interactions
- Review complex form widgets
**🛡️ 2. Wrap with Authorization:**
- Use `@can('gate', $resource)` / `@else` / `@endcan` structure
- Provide full functionality in the `@can` block
- Create disabled/readonly version in the `@else` block
**🎨 3. Design Disabled State:**
- Apply `readonly disabled` attributes to inputs
- Add `opacity-50 cursor-not-allowed` classes for visual feedback
- Remove interactive JavaScript behaviors
- Show current value or appropriate placeholder
**🔒 4. Backend Protection:**
- Ensure corresponding Livewire methods check authorization
- Add `$this->authorize('gate', $resource)` in relevant methods
- Validate permissions before processing any changes
#### Real-World Examples
**Custom Date Range Picker:**
```html
@can('update', $application)
<div x-data="dateRangePicker()" class="date-picker">
<!-- Interactive date picker with calendar -->
</div>
@else
<div class="flex gap-2">
<input readonly disabled value="{{ $startDate }}" class="input opacity-50">
<input readonly disabled value="{{ $endDate }}" class="input opacity-50">
</div>
@endcan
```
**Multi-Select Component:**
```html
@can('update', $server)
<div x-data="multiSelect({ options: @js($options) })">
<!-- Interactive multi-select with checkboxes -->
</div>
@else
<div class="space-y-2">
@foreach($selectedValues as $value)
<div class="px-3 py-1 bg-gray-100 rounded text-sm opacity-50">
{{ $value }}
</div>
@endforeach
</div>
@endcan
```
**File Upload Widget:**
```html
@can('update', $application)
<div x-data="fileUploader()" @drop.prevent="handleDrop">
<!-- Drag-and-drop file upload interface -->
</div>
@else
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center opacity-50">
<p class="text-gray-500">File upload restricted</p>
@if($currentFile)
<p class="text-sm">Current: {{ $currentFile }}</p>
@endif
</div>
@endcan
```
#### Key Principles
**🎯 Consistency:**
- Maintain similar visual styling between enabled/disabled states
- Use consistent disabled patterns across the application
- Apply the same opacity and cursor styling
**🔐 Security First:**
- Always implement backend authorization checks
- Never rely solely on frontend hiding/disabling
- Validate permissions on every server action
**💡 User Experience:**
- Show current values in disabled state when appropriate
- Provide clear visual feedback about restricted access
- Maintain layout stability between states
**🚀 Performance:**
- Minimize Alpine.js initialization for disabled components
- Avoid loading unnecessary JavaScript for unauthorized users
- Use simple HTML structures for read-only states
### Team-Based Multi-Tenancy ### Team-Based Multi-Tenancy
- **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure (8.9KB, 308 lines) - **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure (8.9KB, 308 lines)
- **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Secure team collaboration - **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Secure team collaboration

View File

@@ -31,19 +31,6 @@ alwaysApply: true
- Related rules have been updated - Related rules have been updated
- Implementation details have changed - Implementation details have changed
- **Example Pattern Recognition:**
```typescript
// If you see repeated patterns like:
const data = await prisma.user.findMany({
select: { id: true, email: true },
where: { status: 'ACTIVE' }
});
// Consider adding to [prisma.mdc](mdc:.cursor/rules/prisma.mdc):
// - Standard select fields
// - Common where conditions
// - Performance optimization patterns
```
- **Rule Quality Checks:** - **Rule Quality Checks:**
- Rules should be actionable and specific - Rules should be actionable and specific

View File

@@ -1,6 +1,6 @@
--- ---
description: description: Complete technology stack, dependencies, and infrastructure components
globs: globs: composer.json, package.json, docker-compose*.yml, config/*.php
alwaysApply: false alwaysApply: false
--- ---
# Coolify Technology Stack # Coolify Technology Stack

View File

@@ -1,6 +1,6 @@
--- ---
description: description: Testing strategies with Pest PHP, Laravel Dusk, and quality assurance patterns
globs: globs: tests/**/*.php, database/factories/*.php
alwaysApply: false alwaysApply: false
--- ---
# Coolify Testing Architecture & Patterns # Coolify Testing Architecture & Patterns
@@ -9,6 +9,8 @@ alwaysApply: false
Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions. Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions.
!Important: Always run tests inside `coolify` container.
## Testing Framework Stack ## Testing Framework Stack
### Core Testing Tools ### Core Testing Tools

View File

@@ -0,0 +1,79 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
if: false
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Direct prompt for automated review (no @claude mention needed)
direct_prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Be constructive and helpful in your feedback.
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
# use_sticky_comment: true
# Optional: Customize review based on file types
# direct_prompt: |
# Review this PR focusing on:
# - For TypeScript files: Type safety and proper interface usage
# - For API endpoints: Security, input validation, and error handling
# - For React components: Performance, accessibility, and best practices
# - For tests: Coverage, edge cases, and test quality
# Optional: Different prompts for different authors
# direct_prompt: |
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
# Optional: Add specific tools for running tests or linting
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
# Optional: Skip review for certain conditions
# if: |
# !contains(github.event.pull_request.title, '[skip-review]') &&
# !contains(github.event.pull_request.title, '[WIP]')

64
.github/workflows/claude.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test

11
.mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

4
.phpactor.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "/phpactor.schema.json",
"language_server_phpstan.enabled": true
}

View File

@@ -10,25 +10,508 @@ All notable changes to this project will be documented in this file.
### ⚙️ Miscellaneous Tasks ### ⚙️ Miscellaneous Tasks
- *(docker)* Add a blank line for improved readability in Dockerfile
## [4.0.0-beta.428] - 2025-09-15
### 🚀 Features
- *(deployment)* Enhance deployment status reporting with detailed information on active deployments and team members
### 🐛 Bug Fixes
- *(application)* Improve watch paths handling by trimming and filtering empty paths to prevent unnecessary triggers
### 🚜 Refactor
- *(deployment)* Streamline environment variable handling for dockercompose and improve sorting of runtime variables
- *(remoteProcess)* Remove command log comments for file transfers to simplify code
- *(remoteProcess)* Remove file transfer handling from remote_process and instant_remote_process functions to simplify code
- *(deployment)* Update environment file paths in docker compose commands to use working directory for improved consistency
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(constants)* Update realtime_version from 1.0.10 to 1.0.11
- *(versions)* Increment coolify version to 4.0.0-beta.428 and update realtime_version to 1.0.10
## [4.0.0-beta.427] - 2025-09-15
### 🚀 Features
- *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic
- *(ui)* Display current version in settings dropdown and update UI accordingly
- *(settings)* Add option to restrict PR deployments to repository members and contributors
- *(command)* Implement SSH command retry logic with exponential backoff and logging for better error handling
- *(ssh)* Add Sentry tracking for SSH retry events to enhance error monitoring
- *(exceptions)* Introduce NonReportableException to handle known errors and update Handler for selective reporting
- *(sudo-helper)* Add helper functions for command parsing and ownership management with sudo
- *(dev-command)* Dispatch CheckHelperImageJob during instance initialization to enhance setup process
- *(ssh-multiplexing)* Enhance multiplexed connection management with health checks and metadata caching
- *(ssh-multiplexing)* Add connection age metadata handling to improve multiplexed connection management
- *(database-backup)* Enhance error handling and output management in DatabaseBackupJob
- *(application)* Display parsing version in development mode and clean up domain conflict modal markup
- *(deployment)* Add SERVICE_NAME variables for service discovery
- *(storages)* Add method to retrieve the first storage ID for improved stability in storage display
- *(environment)* Add 'is_literal' attribute to environment variable for enhanced configuration options
- *(pre-commit)* Automate generation of service templates and OpenAPI documentation during pre-commit hook
- *(execute-container)* Enhance container command form with auto-connect feature for single container scenarios
- *(environment)* Introduce 'is_buildtime_only' attribute to environment variables for improved build-time configuration
- *(templates)* Add n8n service with PostgreSQL and worker support for enhanced workflow automation
- *(user-management)* Implement user deletion command with phased resource and subscription cancellation, including dry run option
- *(sentinel)* Add support for custom Docker images in StartSentinel and related methods
- *(sentinel)* Add slide-over for viewing Sentinel logs and custom Docker image input for development
- *(executions)* Add 'Load All' button to view all logs and implement loadAllLogs method for complete log retrieval
- *(auth)* Enhance user login flow to handle team invitations, attaching users to invited teams upon first login and maintaining personal team logic for regular logins
- *(laravel-boost)* Add Laravel Boost guidelines and MCP server configuration to enhance development experience
### 🐛 Bug Fixes
- *(ui)* Transactional email settings link on members page (#6491)
- *(api)* Add custom labels generation for applications with readonly container label setting enabled
- *(ui)* Add cursor pointer to upgrade button for better user interaction
- *(templates)* Update SECRET_KEY environment variable in getoutline.yaml to use SERVICE_HEX_32_OUTLINE
- *(command)* Enhance database deletion command to support multiple database types
- *(command)* Enhance cleanup process for stuck application previews by adding force delete for trashed records
- *(user)* Ensure email attributes are stored in lowercase for consistency and prevent case-related issues
- *(webhook)* Replace delete with forceDelete for application previews to ensure immediate removal
- *(ssh)* Introduce SshRetryHandler and SshRetryable trait for enhanced SSH command retry logic with exponential backoff and error handling
- Appwrite template - 500 errors, missing env vars etc.
- *(LocalFileVolume)* Add missing directory creation command for workdir in saveStorageOnServer method
- *(ScheduledTaskJob)* Replace generic Exception with NonReportableException for better error handling
- *(web-routes)* Enhance backup response messages to clarify local and S3 availability
- *(proxy)* Replace CheckConfiguration with GetProxyConfiguration and SaveConfiguration with SaveProxyConfiguration for improved clarity and consistency in proxy management
- *(private-key)* Implement transaction handling and error verification for private key storage operations
- *(deployment)* Add COOLIFY_* environment variables to Nixpacks build context for enhanced deployment configuration
- *(application)* Add functionality to stop and remove Docker containers on server
- *(templates)* Update 'compose' configuration for Appwrite service to enhance compatibility and streamline deployment
- *(security)* Update contact email for reporting vulnerabilities to enhance privacy
- *(feedback)* Update feedback email address to improve communication with users
- *(security)* Update contact email for vulnerability reports to improve security communication
- *(navbar)* Restrict subscription link visibility to admin users in cloud environment
- *(docker)* Enhance container status aggregation for multi-container applications, including exclusion handling based on docker-compose configuration
### 🚜 Refactor
- *(jobs)* Pull github changelogs from cdn instead of github
- *(command)* Streamline database deletion process to handle multiple database types and improve user experience
- *(command)* Improve database collection logic for deletion command by using unique identifiers and enhancing user experience
- *(command)* Remove InitChangelog command as it is no longer needed
- *(command)* Streamline Init command by removing unnecessary options and enhancing error handling for various operations
- *(webhook)* Replace direct forceDelete calls with DeleteResourceJob dispatch for application previews
- *(command)* Replace forceDelete calls with DeleteResourceJob dispatch for all stuck resources in cleanup process
- *(command)* Simplify SSH command retry logic by removing unnecessary logging and improving delay calculation
- *(ssh)* Enhance error handling in SSH command execution and improve connection validation logging
- *(backlog)* Remove outdated guidelines and project manager agent files to streamline task management documentation
- *(error-handling)* Remove ray debugging statements from CheckUpdates and shared helper functions to clean up error reporting
- *(file-transfer)* Replace base64 encoding with direct file transfer method across multiple database actions for improved clarity and efficiency
- *(remoteProcess)* Remove debugging statement from transfer_file_to_server function to clean up code
- *(dns-validation)* Rename DNS validation functions for consistency and clarity, and remove unused code
- *(file-transfer)* Replace base64 encoding with direct file transfer method in various components for improved clarity and efficiency
- *(private-key)* Remove debugging statement from storeInFileSystem method for cleaner code
- *(github-webhook)* Restructure application processing by grouping applications by server for improved deployment handling
- *(deployment)* Enhance queuing logic to support concurrent deployments by including pull request ID in checks
- *(remoteProcess)* Remove debugging statement from transfer_file_to_container function for cleaner code
- *(deployment)* Streamline next deployment queuing logic by repositioning queue_next_deployment call
- *(deployment)* Add validation for pull request existence in deployment process to enhance error handling
- *(database)* Remove volume_configuration_dir and streamline configuration directory usage in MongoDB and PostgreSQL handlers
- *(application-source)* Improve layout and accessibility of Git repository links in the application source view
- *(models)* Remove 'is_readonly' attribute from multiple database models for consistency
- *(webhook)* Remove Webhook model and related logic; add migrations to drop webhooks and kubernetes tables
- *(clone)* Consolidate application cloning logic into a dedicated function for improved maintainability and readability
- *(clone)* Integrate preview cloning logic directly into application cloning function for improved clarity and maintainability
- *(application)* Enhance environment variable retrieval in configuration change check for improved accuracy
- *(clone)* Enhance application cloning by separating production and preview environment variable handling
- *(deployment)* Add environment variable copying logic to Docker build commands for pull requests
- *(environment)* Standardize service name formatting by replacing '-' and '.' with '_' in environment variable keys
- *(deployment)* Update environment file handling in Docker commands to use '/artifacts/' path and streamline variable management
- *(openapi)* Remove 'is_build_time' attribute from environment variable definitions to streamline configuration
- *(environment)* Remove 'is_build_time' attribute from environment variable handling across the application to simplify configuration
- *(environment)* Streamline environment variable handling by replacing sorting methods with direct property access and enhancing query ordering for improved performance
- *(stripe-jobs)* Comment out internal notification calls and add subscription status verification before sending failure notifications
### 📚 Documentation
- Update changelog
- *(testing-patterns)* Add important note to always run tests inside the `coolify` container for clarity
### ⚙️ Miscellaneous Tasks
- Update coolify version to 4.0.0-beta.427 and nightly version to 4.0.0-beta.428
- Use main value then fallback to service_ values
- Remove webhooks table cleanup
- *(cleanup)* Remove deprecated ServerCheck and related job classes to streamline codebase
- *(versions)* Update sentinel version from 0.0.15 to 0.0.16 in versions.json files
## [4.0.0-beta.426] - 2025-08-28
### 🚜 Refactor
- *(policy)* Simplify ServiceDatabasePolicy methods to always return true and add manageBackups method
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- Update coolify version to 4.0.0-beta.426 and nightly version to 4.0.0-beta.427
## [4.0.0-beta.425] - 2025-08-28
### 🚀 Features
- *(domains)* Implement domain conflict detection and user confirmation modal across application components
- *(domains)* Add force_domain_override option and enhance domain conflict detection responses
### 🐛 Bug Fixes
- *(previews)* Simplify FQDN generation logic by removing unnecessary empty check
- *(templates)* Update Matrix service compose configuration for improved compatibility and clarity
### 🚜 Refactor
- *(urls)* Replace generateFqdn with generateUrl for consistent URL generation across applications
- *(domains)* Rename check_domain_usage to checkDomainUsage and update references across the application
- *(auth)* Simplify access control logic in CanAccessTerminal and ServerPolicy by allowing all users to perform actions
### 📚 Documentation
- Update changelog
- Update changelog
### ⚙️ Miscellaneous Tasks
- Update coolify version to 4.0.0-beta.425 and nightly version to 4.0.0-beta.426
## [4.0.0-beta.424] - 2025-08-27
### 🐛 Bug Fixes
- *(parsers)* Do not modify service names, only for getting fqdns and related envs
- *(compose)* Temporary allow to edit volumes in apps (compose based) and services
### 📚 Documentation
- Update changelog
- Update changelog
### ⚙️ Miscellaneous Tasks
- Update coolify version to 4.0.0-beta.424 and nightly version to 4.0.0-beta.425
## [4.0.0-beta.423] - 2025-08-27
### 🚜 Refactor
- *(parsers)* Remove unnecessary hyphen-to-underscore replacement for service names in serviceParser function
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- Update coolify version to 4.0.0-beta.423 and nightly version to 4.0.0-beta.424
## [4.0.0-beta.422] - 2025-08-27
### 🐛 Bug Fixes
- *(parsers)* Replace hyphens with underscores in service names for consistency. this allows to properly parse custom domains in docker compose based applications
- *(parsers)* Implement parseDockerVolumeString function to handle various Docker volume formats and modes, including environment variables and Windows paths. Add unit tests for comprehensive coverage.
- *(git)* Submodule update command uses an unsupported option (#6454)
- *(service)* Swap URL for FQDN on matrix template (#6466)
- *(parsers)* Enhance volume string handling by preserving mode in application and service parsers. Update related unit tests for validation.
- *(docker)* Update parser version in FQDN generation for service-specific URLs
### 🚜 Refactor
- *(git)* Improve submodule cloning
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- Update version
- Update development node version
## [4.0.0-beta.421] - 2025-08-26
### 🚀 Features
- *(policies)* Add EnvironmentVariablePolicy for managing environment variables ( it was missing )
### 🐛 Bug Fixes
- *(backups)* Rollback helper update for now
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(core)* Update version
- *(versions)* Update coolify version to 4.0.0-beta.421 and nightly version to 4.0.0-beta.422
## [4.0.0-beta.420.9] - 2025-08-26
### 🐛 Bug Fixes
- *(backups)* S3 backup upload is failing
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(core)* Update version
## [4.0.0-beta.420.8] - 2025-08-26
### 🚜 Refactor
- *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility
### 📚 Documentation
- Update changelog
## [4.0.0-beta.420.7] - 2025-08-26
### 🚀 Features
- *(service)* Add TriliumNext service (#5970)
- *(service)* Add Matrix service (#6029)
- *(service)* Add GitHub Action runner service (#6209)
- *(terminal)* Dispatch focus event for terminal after connection and enhance focus handling in JavaScript
- *(lang)* Add Polish language & improve forgot_password translation (#6306)
- *(service)* Update Authentik template (#6264)
- *(service)* Add sequin template (#6105)
- *(service)* Add pi-hole template (#6020)
- *(services)* Add Chroma service (#6201)
- *(service)* Add OpenPanel template (#5310)
- *(service)* Add librechat template (#5654)
- *(service)* Add Homebox service (#6116)
- *(service)* Add pterodactyl & wings services (#5537)
- *(service)* Add Bluesky PDS template (#6302)
- *(input)* Add autofocus attribute to input component for improved accessibility
- *(core)* Finally fqdn is fqdn and url is url. haha
- *(user)* Add changelog read tracking and unread count method
- *(templates)* Add new service templates and update existing compose files for various applications
- *(changelog)* Implement automated changelog fetching from GitHub and enhance changelog read tracking
- *(drizzle-gateway)* Add new drizzle-gateway service with configuration and logo
- *(drizzle-gateway)* Enhance service configuration by adding Master Password field and updating compose file path
- *(templates)* Add new service templates for Homebox, LibreChat, Pterodactyl, and Wings with corresponding configurations and logos
- *(templates)* Add Bluesky PDS service template and update compose file with new environment variable
- *(readme)* Add CubePath as a big sponsor and include new small sponsors with logos
- *(api)* Add create_environment endpoint to ProjectController for environment creation in projects
- *(api)* Add endpoints for managing environments in projects, including listing, creating, and deleting environments
- *(backup)* Add disable local backup option and related logic for S3 uploads
- *(dev patches)* Add functionality to send test email with patch data in development mode
- *(templates)* Added category per service
- *(email)* Implement email change request and verification process
- Generate category for services
- *(service)* Add elasticsearch template (#6300)
- *(sanitization)* Integrate DOMPurify for HTML sanitization across components
- *(cleanup)* Add command for sanitizing name fields across models
- *(sanitization)* Enhance HTML sanitization with improved DOMPurify configuration
- *(validation)* Centralize validation patterns for names and descriptions
- *(git-settings)* Add support for shallow cloning in application settings
- *(auth)* Implement authorization checks for server updates across multiple components
- *(auth)* Implement authorization for PrivateKey management
- *(auth)* Implement authorization for Docker and server management
- *(validation)* Add custom validation rules for Git repository URLs and branches
- *(security)* Add authorization checks for package updates in Livewire components
- *(auth)* Implement authorization checks for application management
- *(auth)* Enhance API error handling for authorization exceptions
- *(auth)* Add comprehensive authorization checks for all kind of resource creations
- *(auth)* Implement authorization checks for database management
- *(auth)* Refine authorization checks for S3 storage and service management
- *(auth)* Implement comprehensive authorization checks across API controllers
- *(auth)* Introduce resource creation authorization middleware and policies for enhanced access control
- *(auth)* Add middleware for resource creation authorization
- *(auth)* Enhance authorization checks in Livewire components for resource management
- *(validation)* Add ValidIpOrCidr rule for validating IP addresses and CIDR notations; update API access settings UI and add comprehensive tests
- *(docs)* Update architecture and development guidelines; enhance form components with built-in authorization system and improve routing documentation
- *(docs)* Expand authorization documentation for custom Alpine.js components; include manual protection patterns and implementation guidelines
- *(sentinel)* Implement SentinelRestarted event and update Livewire components to handle server restart notifications
- *(api)* Enhance IP access control in middleware and settings; support CIDR notation and special case for 0.0.0.0 to allow all IPs
- *(acl)* Change views/backend code to able to use proper ACL's later on. Currently it is not enabled.
- *(docs)* Add Backlog.md guidelines and project manager backlog agent; enhance CLAUDE.md with new links for task management
- *(docs)* Add tasks for implementing Docker build caching and optimizing staging builds; include detailed acceptance criteria and implementation plans
- *(docker)* Implement Docker cleanup processing in ScheduledJobManager; refactor server task scheduling to streamline cleanup job dispatching
- *(docs)* Expand Backlog.md guidelines with comprehensive usage instructions, CLI commands, and best practices for task management to enhance project organization and collaboration
### 🐛 Bug Fixes
- *(service)* Triliumnext platform and link
- *(application)* Update service environment variables when generating domain for Docker Compose
- *(application)* Add option to suppress toast notifications when loading compose file
- *(git)* Tracking issue due to case sensitivity
- *(git)* Tracking issue due to case sensitivity
- *(git)* Tracking issue due to case sensitivity
- *(ui)* Delete button width on small screens (#6308)
- *(service)* Matrix entrypoint
- *(ui)* Add flex-wrap to prevent overflow on small screens (#6307)
- *(docker)* Volumes get delete when stopping a service if `Delete Unused Volumes` is activated (#6317)
- *(docker)* Cleanup always running on deletion
- *(proxy)* Remove hardcoded port 80/443 checks (#6275)
- *(service)* Update healthcheck of penpot backend container (#6272)
- *(api)* Duplicated logs in application endpoint (#6292)
- *(service)* Documenso signees always pending (#6334)
- *(api)* Update service upsert to retain name and description values if not set
- *(database)* Custom postgres configs with SSL (#6352)
- *(policy)* Update delete method to check for admin status in S3StoragePolicy
- *(container)* Sort containers alphabetically by name in ExecuteContainerCommand and update filtering in Terminal Index
- *(application)* Streamline environment variable updates for Docker Compose services and enhance FQDN generation logic
- *(constants)* Update 'Change Log' to 'Changelog' in settings dropdown
- *(constants)* Update coolify version to 4.0.0-beta.420.7
- *(parsers)* Clarify comments and update variable checks for FQDN and URL handling
- *(terminal)* Update text color for terminal availability message and improve readability
- *(drizzle-gateway)* Remove healthcheck from drizzle-gateway compose file and update service template
- *(templates)* Should generate old SERVICE_FQDN service templates as well
- *(constants)* Update official service template URL to point to the v4.x branch for accuracy
- *(git)* Use exact refspec in ls-remote to avoid matching similarly named branches (e.g., changeset-release/main). Use refs/heads/<branch> or provider-specific PR refs.
- *(ApplicationPreview)* Change null check to empty check for fqdn in generate_preview_fqdn method
- *(email notifications)* Enhance EmailChannel to validate team membership for recipients and handle errors gracefully
- *(service api)* Separate create and update service functionalities
- *(templates)* Added a category tag for the docs service filter
- *(application)* Clear Docker Compose specific data when switching away from dockercompose
- *(database)* Conditionally set started_at only if the database is running
- *(ui)* Handle null values in postgres metrics (#6388)
- Disable env sorting by default
- *(proxy)* Filter host network from default proxy (#6383)
- *(modal)* Enhance confirmation text handling
- *(notification)* Update unread count display and improve HTML rendering
- *(select)* Remove unnecessary sanitization for logo rendering
- *(tags)* Update tag display to limit name length and adjust styling
- *(init)* Improve error handling for deployment and template pulling processes
- *(settings-dropdown)* Adjust unread count badge size and display logic for better consistency
- *(sanitization)* Enhance DOMPurify hook to remove Alpine.js directives for improved XSS protection
- *(servercheck)* Properly check server statuses with and without Sentinel
- *(errors)* Update error pages to provide navigation options
- *(github-deploy-key)* Update background color for selected private keys in deployment key selection UI
- *(auth)* Enhance authorization checks in application management
### 💼 Other
- *(settings-dropdown)* Add icons to buttons for improved UI in settings dropdown
- *(ui)* Introduce task for simplifying resource operations UI by replacing boxes with dropdown selections to enhance user experience and streamline interactions
### 🚜 Refactor
- *(jobs)* Remove logging for ScheduledJobManager and ServerResourceManager start and completion
- *(services)* Update validation rules to be optional
- *(service)* Improve langfuse
- *(service)* Improve openpanel template
- *(service)* Improve librechat
- *(public-git-repository)* Enhance form structure and add autofocus to repository URL input
- *(public-git-repository)* Remove commented-out code for cleaner template
- *(templates)* Update service template file handling to use dynamic file name from constants
- *(parsers)* Streamline domain handling in applicationParser and improve DNS validation logic
- *(templates)* Replace SERVICE_FQDN variables with SERVICE_URL in compose files for consistency
- *(links)* Replace inline SVGs with reusable external link component for consistency and improved maintainability
- *(previews)* Improve layout and add deployment/application logs links for previews
- *(docker compose)* Remove deprecated newParser function and associated test file to streamline codebase
- *(shared helpers)* Remove unused parseServiceVolumes function to clean up codebase
- *(parsers)* Update volume parsing logic to use beforeLast and afterLast for improved accuracy
- *(validation)* Implement centralized validation patterns across components
- *(jobs)* Rename job classes to indicate deprecation status
- Update check frequency logic for cloud and self-hosted environments; streamline server task scheduling and timezone handling
- *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility
### 📚 Documentation
- *(claude)* Clarify that artisan commands should only be run inside the "coolify" container during development
- Add AGENTS.md for project guidance and development instructions
- Update changelog
- Update changelog
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(service)* Improve matrix service
- *(service)* Format runner service
- *(service)* Improve sequin
- *(service)* Add `NOT_SECURED` env to Postiz (#6243)
- *(service)* Improve evolution-api environment variables (#6283)
- *(service)* Update Langfuse template to v3 (#6301)
- *(core)* Remove unused argument
- *(deletion)* Rename isDeleteOperation to deleteConnectedNetworks
- *(docker)* Remove unused arguments on StopService
- *(service)* Homebox formatting
- Clarify usage of custom redis configuration (#6321)
- *(changelogs)* Add .gitignore for changelogs directory and remove outdated changelog files for May, June, and July 2025
- *(service)* Change affine images (#6366)
- Elasticsearch URL, fromatting and add category
- Update service-templates json files
- *(docs)* Remove AGENTS.md file; enhance CLAUDE.md with detailed form authorization patterns and service configuration examples
- *(cleanup)* Remove unused GitLab view files for change, new, and show pages
- *(workflows)* Add backlog directory to build triggers for production and staging workflows
- *(config)* Disable auto_commit in backlog configuration to prevent automatic commits
- *(versions)* Update coolify version to 4.0.0-beta.420.8 and nightly version to 4.0.0-beta.420.9 in versions.json and constants.php
- *(docker)* Update soketi image version to 1.0.10 in production and Windows configurations
### ◀️ Revert
- *(parser)* Enhance FQDN generation logic for services and applications
## [4.0.0-beta.420.6] - 2025-07-18
### 🚀 Features
- *(service)* Enable password protection for the Wireguard Ul
- *(queues)* Improve Horizon config to reduce CPU and RAM usage (#6212)
- *(service)* Add Gowa service (#6164)
- *(container)* Add updatedSelectedContainer method to connect to non-default containers and update wire:model for improved reactivity
- *(application)* Implement environment variable updates for Docker Compose applications, including creation, updating, and deletion of SERVICE_FQDN and SERVICE_URL variables
### 🐛 Bug Fixes
- *(installer)* Public IPv4 link does not work
- *(composer)* Version constraint of prompts
- *(service)* Budibase secret keys (#6205)
- *(service)* Wg-easy host should be just the FQDN
- *(ui)* Search box overlaps the sidebar navigation (#6176)
- *(webhooks)* Exclude webhook routes from CSRF protection (#6200)
- *(services)* Update environment variable naming convention to use underscores instead of dashes for SERVICE_FQDN and SERVICE_URL
### 🚜 Refactor
- *(service)* Improve gowa
- *(previews)* Streamline preview domain generation logic in ApplicationDeploymentJob for improved clarity and maintainability
- *(services)* Simplify environment variable updates by using updateOrCreate and add cleanup for removed FQDNs
### 📚 Documentation
- Update changelog
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(service)* Update Nitropage template (#6181)
- *(versions)* Update all version
- *(bump)* Update composer deps - *(bump)* Update composer deps
- *(version)* Bump Coolify version to 4.0.0-beta.420.6 - *(version)* Bump Coolify version to 4.0.0-beta.420.6
## [4.0.0-beta.420.5] - 2025-07-08 ## [4.0.0-beta.420.4] - 2025-07-08
### 🚀 Features ### 🚀 Features
- *(scheduling)* Add command to manually run scheduled database backups and tasks with options for chunking, delays, and dry runs - *(scheduling)* Add command to manually run scheduled database backups and tasks with options for chunking, delays, and dry runs
- *(scheduling)* Add frequency filter option for manual execution of scheduled jobs
### 🐛 Bug Fixes - *(logging)* Implement scheduled logs command and enhance backup/task scheduling with cron checks
- *(logging)* Add frequency filters for scheduled logs command to support hourly, daily, weekly, and monthly job views
- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.5 and 4.0.0-beta.420.6 - *(scheduling)* Introduce ScheduledJobManager and ServerResourceManager for enhanced job scheduling and resource management
- *(database)* Ensure internal port defaults correctly for unsupported database types in StartDatabaseProxy - *(previews)* Implement soft delete and cleanup for ApplicationPreview, enhancing resource management in DeleteResourceJob
### 🚜 Refactor
- *(postgresql)* Improve layout and spacing in SSL and Proxy configuration sections for better UI consistency
## [4.0.0-beta.420.4] - 2025-07-08
### 🐛 Bug Fixes ### 🐛 Bug Fixes
@@ -41,11 +524,29 @@ All notable changes to this project will be documented in this file.
- *(deployment)* Refactor domain parsing and environment variable generation using Spatie URL library - *(deployment)* Refactor domain parsing and environment variable generation using Spatie URL library
- *(deployment)* Update COOLIFY_URL and COOLIFY_FQDN generation to use Spatie URL library for improved accuracy - *(deployment)* Update COOLIFY_URL and COOLIFY_FQDN generation to use Spatie URL library for improved accuracy
- *(scheduling)* Change redis cleanup command frequency from hourly to weekly for better resource management - *(scheduling)* Change redis cleanup command frequency from hourly to weekly for better resource management
- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.5 and 4.0.0-beta.420.6
- *(database)* Ensure internal port defaults correctly for unsupported database types in StartDatabaseProxy
- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.6 and 4.0.0-beta.420.7
- *(scheduling)* Remove unnecessary padding from scheduled task form layout for improved UI consistency
- *(horizon)* Update queue configuration to use environment variable for dynamic queue management
- *(horizon)* Add silenced jobs
- *(application)* Sanitize service names for HTML form binding and ensure original names are stored in docker compose domains
- *(previews)* Adjust padding for rate limit message in application previews
- *(previews)* Order application previews by pull request ID in descending order
- *(previews)* Add unique wire keys for preview containers and services based on pull request ID
- *(previews)* Enhance domain generation logic for application previews, ensuring unique domains are created when none are set
- *(previews)* Refine preview domain generation for Docker Compose applications, ensuring correct method usage based on build pack type
- *(ui)* Typo on proxy request handler tooltip (#6192)
- *(backups)* Large database backups are not working (#6217)
- *(backups)* Error message if there is no exception
### 🚜 Refactor ### 🚜 Refactor
- *(previews)* Streamline preview URL generation by utilizing application method - *(previews)* Streamline preview URL generation by utilizing application method
- *(application)* Adjust layout and spacing in general application view for improved UI - *(application)* Adjust layout and spacing in general application view for improved UI
- *(postgresql)* Improve layout and spacing in SSL and Proxy configuration sections for better UI consistency
- *(scheduling)* Replace deprecated job checks with ScheduledJobManager and ServerResourceManager for improved scheduling efficiency
- *(previews)* Move preview domain generation logic to ApplicationPreview model for better encapsulation and consistency across webhook handlers
### 📚 Documentation ### 📚 Documentation
@@ -7909,4 +8410,6 @@ All notable changes to this project will be documented in this file.
- Secrets join - Secrets join
- ENV variables set differently - ENV variables set differently
## [1.0.0] - 2021-03-24
<!-- generated by git-cliff --> <!-- generated by git-cliff -->

607
CLAUDE.md
View File

@@ -13,6 +13,7 @@ Coolify is an open-source, self-hostable platform for deploying applications and
- `npm run build` - Build frontend assets for production - `npm run build` - Build frontend assets for production
### Backend Development ### Backend Development
Only run artisan commands inside "coolify" container when in development.
- `php artisan serve` - Start Laravel development server - `php artisan serve` - Start Laravel development server
- `php artisan migrate` - Run database migrations - `php artisan migrate` - Run database migrations
- `php artisan queue:work` - Start queue worker for background jobs - `php artisan queue:work` - Start queue worker for background jobs
@@ -28,26 +29,27 @@ Coolify is an open-source, self-hostable platform for deploying applications and
### Technology Stack ### Technology Stack
- **Backend**: Laravel 12 (PHP 8.4) - **Backend**: Laravel 12 (PHP 8.4)
- **Frontend**: Livewire + Alpine.js + Tailwind CSS - **Frontend**: Livewire 3.5+ with Alpine.js and Tailwind CSS 4.1+
- **Database**: PostgreSQL 15 - **Database**: PostgreSQL 15 (primary), Redis 7 (cache/queues)
- **Cache/Queue**: Redis 7
- **Real-time**: Soketi (WebSocket server) - **Real-time**: Soketi (WebSocket server)
- **Containerization**: Docker & Docker Compose - **Containerization**: Docker & Docker Compose
- **Queue Management**: Laravel Horizon
### Key Components ### Key Components
#### Core Models #### Core Models
- `Application` - Deployed applications with Git integration - `Application` - Deployed applications with Git integration (74KB, highly complex)
- `Server` - Remote servers managed by Coolify - `Server` - Remote servers managed by Coolify (46KB, complex)
- `Service` - Docker Compose services - `Service` - Docker Compose services (58KB, complex)
- `Database` - Standalone database instances (PostgreSQL, MySQL, MongoDB, Redis, etc.) - `Database` - Standalone database instances (PostgreSQL, MySQL, MongoDB, Redis, etc.)
- `Team` - Multi-tenancy support - `Team` - Multi-tenancy support
- `Project` - Grouping of environments and resources - `Project` - Grouping of environments and resources
- `Environment` - Environment isolation (staging, production, etc.)
#### Job System #### Job System
- Uses Laravel Horizon for queue management - Uses Laravel Horizon for queue management
- Key jobs: `ApplicationDeploymentJob`, `ServerCheckJob`, `DatabaseBackupJob` - Key jobs: `ApplicationDeploymentJob`, `ServerCheckJob`, `DatabaseBackupJob`
- `ScheduledJobManager` and `ServerResourceManager` handle job scheduling - `ServerManagerJob` and `ServerConnectionCheckJob` handle job scheduling
#### Deployment Flow #### Deployment Flow
1. Git webhook triggers deployment 1. Git webhook triggers deployment
@@ -66,22 +68,591 @@ Coolify is an open-source, self-hostable platform for deploying applications and
- `app/Jobs/` - Background queue jobs - `app/Jobs/` - Background queue jobs
- `app/Livewire/` - Frontend components (full-stack with Livewire) - `app/Livewire/` - Frontend components (full-stack with Livewire)
- `app/Models/` - Eloquent models - `app/Models/` - Eloquent models
- `app/Rules/` - Custom validation rules
- `app/Http/Middleware/` - HTTP middleware
- `bootstrap/helpers/` - Helper functions for various domains - `bootstrap/helpers/` - Helper functions for various domains
- `database/migrations/` - Database schema evolution - `database/migrations/` - Database schema evolution
- `routes/` - Application routing (web.php, api.php, webhooks.php, channels.php)
- `resources/views/livewire/` - Livewire component views
- `tests/` - Pest tests (Feature and Unit)
## Development Guidelines ## Development Guidelines
### Code Organization ### Frontend Philosophy
- Use Actions pattern for complex business logic Coolify uses a **server-side first** approach with minimal JavaScript:
- Livewire components handle UI and user interactions - **Livewire** for server-side rendering with reactive components
- Jobs handle asynchronous operations - **Alpine.js** for lightweight client-side interactions
- Traits provide shared functionality (e.g., `ExecuteRemoteCommand`) - **Tailwind CSS** for utility-first styling with dark mode support
- **Enhanced Form Components** with built-in authorization system
- Real-time updates via WebSocket without page refreshes
### Form Authorization Pattern
**IMPORTANT**: When creating or editing forms, ALWAYS include authorization:
#### For Form Components (Input, Select, Textarea, Checkbox, Button):
Use `canGate` and `canResource` attributes for automatic authorization:
```html
<x-forms.input canGate="update" :canResource="$resource" id="name" label="Name" />
<x-forms.select canGate="update" :canResource="$resource" id="type" label="Type">...</x-forms.select>
<x-forms.checkbox instantSave canGate="update" :canResource="$resource" id="enabled" label="Enabled" />
<x-forms.button canGate="update" :canResource="$resource" type="submit">Save</x-forms.button>
```
#### For Modal Components:
Wrap with `@can` directives:
```html
@can('update', $resource)
<x-modal-confirmation title="Confirm Action?" buttonTitle="Confirm">...</x-modal-confirmation>
<x-modal-input buttonTitle="Edit" title="Edit Settings">...</x-modal-input>
@endcan
```
#### In Livewire Components:
Always add the `AuthorizesRequests` trait and check permissions:
```php
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
class MyComponent extends Component
{
use AuthorizesRequests;
public function mount()
{
$this->authorize('view', $this->resource);
}
public function update()
{
$this->authorize('update', $this->resource);
// ... update logic
}
}
```
### Livewire Component Structure
- Components located in `app/Livewire/`
- Views in `resources/views/livewire/`
- State management handled on the server
- Use wire:model for two-way data binding
- Dispatch events for component communication
### Code Organization Patterns
- **Actions Pattern**: Use Actions for complex business logic (`app/Actions/`)
- **Livewire Components**: Handle UI and user interactions
- **Jobs**: Handle asynchronous operations
- **Traits**: Provide shared functionality (e.g., `ExecuteRemoteCommand`)
- **Helper Functions**: Domain-specific helpers in `bootstrap/helpers/`
### Database Patterns
- Use Eloquent ORM for database interactions
- Implement relationships properly (HasMany, BelongsTo, etc.)
- Use database transactions for critical operations
- Leverage query scopes for reusable queries
- Apply indexes for performance-critical queries
### Security Best Practices
- **Authentication**: Multi-provider auth via Laravel Fortify & Sanctum
- **Authorization**: Team-based access control with policies and enhanced form components
- **Form Component Security**: Built-in `canGate` authorization system for UI components
- **API Security**: Token-based auth with IP allowlisting
- **Secrets Management**: Never log or expose sensitive data
- **Input Validation**: Always validate user input with Form Requests or Rules
- **SQL Injection Prevention**: Use Eloquent ORM or parameterized queries
### API Development
- RESTful endpoints in `routes/api.php`
- Use API Resources for response formatting
- Implement rate limiting for public endpoints
- Version APIs when making breaking changes
- Document endpoints with clear examples
### Testing Strategy
- **Framework**: Pest for expressive testing
- **Structure**: Feature tests for user flows, Unit tests for isolated logic
- **Coverage**: Test critical paths and edge cases
- **Mocking**: Use Laravel's built-in mocking for external services
- **Database**: Use RefreshDatabase trait for test isolation
### Routing Conventions
- Group routes by middleware and prefix
- Use route model binding for cleaner controllers
- Name routes consistently (resource.action)
- Implement proper HTTP verbs (GET, POST, PUT, DELETE)
### Error Handling
- Use `handleError()` helper for consistent error handling
- Log errors with appropriate context
- Return user-friendly error messages
- Implement proper HTTP status codes
### Performance Considerations
- Use eager loading to prevent N+1 queries
- Implement caching for frequently accessed data
- Queue heavy operations
- Optimize database queries with proper indexes
- Use chunking for large data operations
### Code Style
- Follow PSR-12 coding standards
- Use Laravel Pint for automatic formatting
- Write descriptive variable and method names
- Keep methods small and focused
- Document complex logic with clear comments
## Cloud Instance Considerations
We have a cloud instance of Coolify (hosted version) with:
- 2 Horizon worker servers
- Thousands of connected servers
- Thousands of active users
- High-availability requirements
When developing features:
- Consider scalability implications
- Test with large datasets
- Implement efficient queries
- Use queues for heavy operations
- Consider rate limiting and resource constraints
- Implement proper error recovery mechanisms
## Important Reminders
- Always run code formatting: `./vendor/bin/pint`
- Test your changes: `./vendor/bin/pest`
- Check for static analysis issues: `./vendor/bin/phpstan`
- Use existing patterns and helpers
- Follow the established directory structure
- Maintain backward compatibility
- Document breaking changes
- Consider performance impact on large-scale deployments
## Additional Documentation
For more detailed guidelines and patterns, refer to the `.cursor/rules/` directory:
### Architecture & Patterns
- [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure
- [Deployment Architecture](.cursor/rules/deployment-architecture.mdc) - Deployment patterns and flows
- [Database Patterns](.cursor/rules/database-patterns.mdc) - Database design and query patterns
- [Frontend Patterns](.cursor/rules/frontend-patterns.mdc) - Livewire and Alpine.js patterns
- [API & Routing](.cursor/rules/api-and-routing.mdc) - API design and routing conventions
### Development & Security
- [Development Workflow](.cursor/rules/development-workflow.mdc) - Development best practices
- [Security Patterns](.cursor/rules/security-patterns.mdc) - Security implementation details
- [Form Components](.cursor/rules/form-components.mdc) - Enhanced form components with authorization
- [Testing Patterns](.cursor/rules/testing-patterns.mdc) - Testing strategies and examples
### Project Information
- [Project Overview](.cursor/rules/project-overview.mdc) - High-level project structure
- [Technology Stack](.cursor/rules/technology-stack.mdc) - Detailed tech stack information
- [Cursor Rules Guide](.cursor/rules/cursor_rules.mdc) - How to maintain cursor rules
===
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.7
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v3
- laravel/dusk (DUSK) - v8
- laravel/pint (PINT) - v1
- laravel/telescope (TELESCOPE) - v5
- pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11
- rector/rector (RECTOR) - v2
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
- vue (VUE) - v3
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
=== php rules ===
## PHP
- Always use curly braces for control structures, even if it has one line.
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== laravel/core rules ===
## Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing ### Testing
- Uses Pest for testing framework - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Tests located in `tests/` directory - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Deployment and Docker ### Vite Error
- Applications are deployed using Docker containers - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
- Configuration generated dynamically based on application settings
- Supports multiple deployment targets and proxy configurations
=== laravel/v12 rules ===
## Laravel 12
- Use the `search-docs` tool to get version specific documentation.
- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that.
### Laravel 10 Structure
- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
- Middleware registration happens in `app/Http/Kernel.php`
- Exception handling is in `app/Exceptions/Handler.php`
- Console commands and schedule register in `app/Console/Kernel.php`
- Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules ===
## Livewire Core
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== livewire/v3 rules ===
## Livewire 3
### Key Changes From Livewire 2
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
### New Directives
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
### Alpine
- Alpine is now included with Livewire, don't manually include Alpine.js.
- Plugins included with Alpine: persist, intersect, collapse, and focus.
### Lifecycle Hooks
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
<code-snippet name="livewire:load example" lang="js">
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
alert('Your session expired');
}
});
Livewire.hook('message.failed', (message, component) => {
console.error(message);
});
});
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== pest/core rules ===
## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== tailwindcss/core rules ===
## Tailwind Core
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing, don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff"
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines>
Random other things you should remember:
- App\Models\Application::team must return a relationship instance., always use team()

View File

@@ -53,6 +53,7 @@ Thank you so much!
## Big Sponsors ## Big Sponsors
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management * [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management
* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform * [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform * [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
@@ -87,8 +88,11 @@ Thank you so much!
* [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure * [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity * [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
## Small Sponsors ## Small Sponsors
<a href="https://open-elements.com/?utm_source=coolify.io"><img width="60px" alt="OpenElements" src="https://github.com/OpenElements.png"/></a>
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="XamanApp" src="https://github.com/XamanApp.png"/></a>
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a> <a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
<a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a> <a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a>
<a href="https://github.com/iujlaki"><img width="60px" alt="Imre Ujlaki" src="https://github.com/iujlaki.png"/></a> <a href="https://github.com/iujlaki"><img width="60px" alt="Imre Ujlaki" src="https://github.com/iujlaki.png"/></a>

View File

@@ -18,7 +18,7 @@ We take security seriously. Security updates are released as soon as possible af
If you discover a security vulnerability, please follow these steps: If you discover a security vulnerability, please follow these steps:
1. **DO NOT** disclose the vulnerability publicly. 1. **DO NOT** disclose the vulnerability publicly.
2. Send a detailed report to: `hi@coollabs.io`. 2. Send a detailed report to: `security@coollabs.io`.
3. Include in your report: 3. Include in your report:
- A description of the vulnerability - A description of the vulnerability
- Steps to reproduce the issue - Steps to reproduce the issue

View File

@@ -49,7 +49,7 @@ class StopApplication
} }
if ($dockerCleanup) { if ($dockerCleanup) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, false, false);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
return $e->getMessage(); return $e->getMessage();

View File

@@ -185,6 +185,8 @@ class StartPostgresql
} }
} }
$command = ['postgres'];
if (filled($this->database->postgres_conf)) { if (filled($this->database->postgres_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'], $docker_compose['services'][$container_name]['volumes'],
@@ -195,29 +197,25 @@ class StartPostgresql
'read_only' => true, 'read_only' => true,
]] ]]
); );
$docker_compose['services'][$container_name]['command'] = [ $command = array_merge($command, ['-c', 'config_file=/etc/postgresql/postgresql.conf']);
'postgres',
'-c',
'config_file=/etc/postgresql/postgresql.conf',
];
} }
if ($this->database->enable_ssl) { if ($this->database->enable_ssl) {
$docker_compose['services'][$container_name]['command'] = [ $command = array_merge($command, [
'postgres', '-c', 'ssl=on',
'-c', '-c', 'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
'ssl=on', '-c', 'ssl_key_file=/var/lib/postgresql/certs/server.key',
'-c', ]);
'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
'-c',
'ssl_key_file=/var/lib/postgresql/certs/server.key',
];
} }
// Add custom docker run options // Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (count($command) > 1) {
$docker_compose['services'][$container_name]['command'] = $command;
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@@ -231,6 +229,8 @@ class StartPostgresql
} }
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";
ray($this->commands);
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
} }

View File

@@ -18,7 +18,7 @@ class StopDatabase
{ {
use AsAction; use AsAction;
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $isDeleteOperation = false, bool $dockerCleanup = true) public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true)
{ {
try { try {
$server = $database->destination->server; $server = $database->destination->server;
@@ -29,7 +29,7 @@ class StopDatabase
$this->stopContainer($database, $database->uuid, 30); $this->stopContainer($database, $database->uuid, 30);
if ($dockerCleanup) { if ($dockerCleanup) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, false, false);
} }
if ($database->is_public) { if ($database->is_public) {

View File

@@ -26,6 +26,8 @@ class GetContainersStatus
public $server; public $server;
protected ?Collection $applicationContainerStatuses;
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null) public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{ {
$this->containers = $containers; $this->containers = $containers;
@@ -94,7 +96,11 @@ class GetContainersStatus
} }
$containerStatus = data_get($container, 'State.Status'); $containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
$containerStatus = "$containerStatus ($containerHealth)"; if ($containerStatus === 'restarting') {
$containerStatus = "restarting ($containerHealth)";
} else {
$containerStatus = "$containerStatus ($containerHealth)";
}
$labels = Arr::undot(format_docker_labels_to_json($labels)); $labels = Arr::undot(format_docker_labels_to_json($labels));
$applicationId = data_get($labels, 'coolify.applicationId'); $applicationId = data_get($labels, 'coolify.applicationId');
if ($applicationId) { if ($applicationId) {
@@ -119,11 +125,16 @@ class GetContainersStatus
$application = $this->applications->where('id', $applicationId)->first(); $application = $this->applications->where('id', $applicationId)->first();
if ($application) { if ($application) {
$foundApplications[] = $application->id; $foundApplications[] = $application->id;
$statusFromDb = $application->status; // Store container status for aggregation
if ($statusFromDb !== $containerStatus) { if (! isset($this->applicationContainerStatuses)) {
$application->update(['status' => $containerStatus]); $this->applicationContainerStatuses = collect();
} else { }
$application->update(['last_online_at' => now()]); if (! $this->applicationContainerStatuses->has($applicationId)) {
$this->applicationContainerStatuses->put($applicationId, collect());
}
$containerName = data_get($labels, 'com.docker.compose.service');
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
} }
} else { } else {
// Notify user that this container should not be there. // Notify user that this container should not be there.
@@ -320,6 +331,97 @@ class GetContainersStatus
} }
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
} }
// Aggregate multi-container application statuses
if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) {
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
$application = $this->applications->where('id', $applicationId)->first();
if (! $application) {
continue;
}
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
if ($aggregatedStatus) {
$statusFromDb = $application->status;
if ($statusFromDb !== $aggregatedStatus) {
$application->update(['status' => $aggregatedStatus]);
} else {
$application->update(['last_online_at' => now()]);
}
}
}
}
ServiceChecked::dispatch($this->server->team->id); ServiceChecked::dispatch($this->server->team->id);
} }
private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
{
// Parse docker compose to check for excluded containers
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
$excludedContainers = collect();
if ($dockerComposeRaw) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $serviceConfig) {
// Check if container should be excluded
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
if ($excludeFromHc || $restartPolicy === 'no') {
$excludedContainers->push($serviceName);
}
}
} catch (\Exception $e) {
// If we can't parse, treat all containers as included
}
}
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, don't update status
if ($relevantStatuses->isEmpty()) {
return null;
}
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasExited = false;
foreach ($relevantStatuses as $status) {
if (str($status)->contains('restarting')) {
$hasRestarting = true;
} elseif (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
}
} elseif (str($status)->contains('exited')) {
$hasExited = true;
$hasUnhealthy = true;
}
}
if ($hasRestarting) {
return 'degraded (unhealthy)';
}
if ($hasRunning && $hasExited) {
return 'degraded (unhealthy)';
}
if ($hasRunning) {
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
}
// All containers are exited
return 'exited (unhealthy)';
}
} }

View File

@@ -40,7 +40,7 @@ class CreateNewUser implements CreatesNewUsers
$user = User::create([ $user = User::create([
'id' => 0, 'id' => 0,
'name' => $input['name'], 'name' => $input['name'],
'email' => strtolower($input['email']), 'email' => $input['email'],
'password' => Hash::make($input['password']), 'password' => Hash::make($input['password']),
]); ]);
$team = $user->teams()->first(); $team = $user->teams()->first();
@@ -52,7 +52,7 @@ class CreateNewUser implements CreatesNewUsers
} else { } else {
$user = User::create([ $user = User::create([
'name' => $input['name'], 'name' => $input['name'],
'email' => strtolower($input['email']), 'email' => $input['email'],
'password' => Hash::make($input['password']), 'password' => Hash::make($input['password']),
]); ]);
$team = $user->teams()->first(); $team = $user->teams()->first();

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Actions\Proxy;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Lorisleiva\Actions\Concerns\AsAction;
class CheckConfiguration
{
use AsAction;
public function handle(Server $server, bool $reset = false)
{
$proxyType = $server->proxyType();
if ($proxyType === 'NONE') {
return 'OK';
}
$proxy_path = $server->proxyPath();
$payload = [
"mkdir -p $proxy_path",
"cat $proxy_path/docker-compose.yml",
];
$proxy_configuration = instant_remote_process($payload, $server, false);
if ($reset || ! $proxy_configuration || is_null($proxy_configuration)) {
$proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value();
}
if (! $proxy_configuration || is_null($proxy_configuration)) {
throw new \Exception('Could not generate proxy configuration');
}
ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration);
return $proxy_configuration;
}
}

View File

@@ -66,11 +66,11 @@ class CheckProxy
if ($server->id === 0) { if ($server->id === 0) {
$ip = 'host.docker.internal'; $ip = 'host.docker.internal';
} }
$portsToCheck = ['80', '443']; $portsToCheck = [];
try { try {
if ($server->proxyType() !== ProxyTypes::NONE->value) { if ($server->proxyType() !== ProxyTypes::NONE->value) {
$proxyCompose = CheckConfiguration::run($server); $proxyCompose = GetProxyConfiguration::run($server);
if (isset($proxyCompose)) { if (isset($proxyCompose)) {
$yaml = Yaml::parse($proxyCompose); $yaml = Yaml::parse($proxyCompose);
$configPorts = []; $configPorts = [];

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Actions\Proxy;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Lorisleiva\Actions\Concerns\AsAction;
class GetProxyConfiguration
{
use AsAction;
public function handle(Server $server, bool $forceRegenerate = false): string
{
$proxyType = $server->proxyType();
if ($proxyType === 'NONE') {
return 'OK';
}
$proxy_path = $server->proxyPath();
$proxy_configuration = null;
// If not forcing regeneration, try to read existing configuration
if (! $forceRegenerate) {
$payload = [
"mkdir -p $proxy_path",
"cat $proxy_path/docker-compose.yml 2>/dev/null",
];
$proxy_configuration = instant_remote_process($payload, $server, false);
}
// Generate default configuration if:
// 1. Force regenerate is requested
// 2. Configuration file doesn't exist or is empty
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
$proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value();
}
if (empty($proxy_configuration)) {
throw new \Exception('Could not get or generate proxy configuration');
}
ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration);
return $proxy_configuration;
}
}

View File

@@ -5,22 +5,21 @@ namespace App\Actions\Proxy;
use App\Models\Server; use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
class SaveConfiguration class SaveProxyConfiguration
{ {
use AsAction; use AsAction;
public function handle(Server $server, ?string $proxy_settings = null) public function handle(Server $server, string $configuration): void
{ {
if (is_null($proxy_settings)) {
$proxy_settings = CheckConfiguration::run($server, true);
}
$proxy_path = $server->proxyPath(); $proxy_path = $server->proxyPath();
$docker_compose_yml_base64 = base64_encode($proxy_settings); $docker_compose_yml_base64 = base64_encode($configuration);
// Update the saved settings hash
$server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value; $server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value;
$server->save(); $server->save();
return instant_remote_process([ // Transfer the configuration file to the server
instant_remote_process([
"mkdir -p $proxy_path", "mkdir -p $proxy_path",
"echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null", "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
], $server); ], $server);

View File

@@ -21,11 +21,11 @@ class StartProxy
} }
$commands = collect([]); $commands = collect([]);
$proxy_path = $server->proxyPath(); $proxy_path = $server->proxyPath();
$configuration = CheckConfiguration::run($server); $configuration = GetProxyConfiguration::run($server);
if (! $configuration) { if (! $configuration) {
throw new \Exception('Configuration is not synced'); throw new \Exception('Configuration is not synced');
} }
SaveConfiguration::run($server, $configuration); SaveProxyConfiguration::run($server, $configuration);
$docker_compose_yml_base64 = base64_encode($configuration); $docker_compose_yml_base64 = base64_encode($configuration);
$server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); $server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
$server->save(); $server->save();

View File

@@ -102,7 +102,6 @@ class CheckUpdates
]; ];
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
ray('Error:', $e->getMessage());
return [ return [
'osId' => $osId, 'osId' => $osId,

View File

@@ -11,7 +11,7 @@ class CleanupDocker
public string $jobQueue = 'high'; public string $jobQueue = 'high';
public function handle(Server $server) public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false)
{ {
$settings = instanceSettings(); $settings = instanceSettings();
$realtimeImage = config('constants.coolify.realtime_image'); $realtimeImage = config('constants.coolify.realtime_image');
@@ -36,11 +36,11 @@ class CleanupDocker
"docker images --filter before=$realtimeImageWithoutPrefixVersion --filter reference=$realtimeImageWithoutPrefix | grep $realtimeImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithoutPrefixVersion --filter reference=$realtimeImageWithoutPrefix | grep $realtimeImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
]; ];
if ($server->settings->delete_unused_volumes) { if ($deleteUnusedVolumes) {
$commands[] = 'docker volume prune -af'; $commands[] = 'docker volume prune -af';
} }
if ($server->settings->delete_unused_networks) { if ($deleteUnusedNetworks) {
$commands[] = 'docker network prune -f'; $commands[] = 'docker network prune -f';
} }

View File

@@ -1,268 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Jobs\CheckAndStartSentinelJob;
use App\Jobs\ServerStorageCheckJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Notifications\Container\ContainerRestarted;
use Illuminate\Support\Arr;
use Lorisleiva\Actions\Concerns\AsAction;
class ServerCheck
{
use AsAction;
public Server $server;
public bool $isSentinel = false;
public $containers;
public $databases;
public function handle(Server $server, $data = null)
{
$this->server = $server;
try {
if ($this->server->isFunctional() === false) {
return 'Server is not functional.';
}
if (! $this->server->isSwarmWorker() && ! $this->server->isBuildServer()) {
if (isset($data)) {
$data = collect($data);
$this->server->sentinelHeartbeat();
$this->containers = collect(data_get($data, 'containers'));
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
$containerReplicates = null;
$this->isSentinel = true;
} else {
['containers' => $this->containers, 'containerReplicates' => $containerReplicates] = $this->server->getContainers();
// ServerStorageCheckJob::dispatch($this->server);
}
if (is_null($this->containers)) {
return 'No containers found.';
}
if (isset($containerReplicates)) {
foreach ($containerReplicates as $containerReplica) {
$name = data_get($containerReplica, 'Name');
$this->containers = $this->containers->map(function ($container) use ($name, $containerReplica) {
if (data_get($container, 'Spec.Name') === $name) {
$replicas = data_get($containerReplica, 'Replicas');
$running = str($replicas)->explode('/')[0];
$total = str($replicas)->explode('/')[1];
if ($running === $total) {
data_set($container, 'State.Status', 'running');
data_set($container, 'State.Health.Status', 'healthy');
} else {
data_set($container, 'State.Status', 'starting');
data_set($container, 'State.Health.Status', 'unhealthy');
}
}
return $container;
});
}
}
$this->checkContainers();
if ($this->server->isSentinelEnabled() && $this->isSentinel === false) {
CheckAndStartSentinelJob::dispatch($this->server);
}
if ($this->server->isLogDrainEnabled()) {
$this->checkLogDrainContainer();
}
if ($this->server->proxySet() && ! $this->server->proxy->force_stop) {
$foundProxyContainer = $this->containers->filter(function ($value, $key) {
if ($this->server->isSwarm()) {
return data_get($value, 'Spec.Name') === 'coolify-proxy_traefik';
} else {
return data_get($value, 'Name') === '/coolify-proxy';
}
})->first();
$proxyStatus = data_get($foundProxyContainer, 'State.Status', 'exited');
if (! $foundProxyContainer || $proxyStatus !== 'running') {
try {
$shouldStart = CheckProxy::run($this->server);
if ($shouldStart) {
StartProxy::run($this->server, async: false);
$this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server));
}
} catch (\Throwable $e) {
}
} else {
$this->server->proxy->status = data_get($foundProxyContainer, 'State.Status');
$this->server->save();
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
}
}
}
} catch (\Throwable $e) {
return handleError($e);
}
}
private function checkLogDrainContainer()
{
$foundLogDrainContainer = $this->containers->filter(function ($value, $key) {
return data_get($value, 'Name') === '/coolify-log-drain';
})->first();
if ($foundLogDrainContainer) {
$status = data_get($foundLogDrainContainer, 'State.Status');
if ($status !== 'running') {
StartLogDrain::dispatch($this->server);
}
} else {
StartLogDrain::dispatch($this->server);
}
}
private function checkContainers()
{
foreach ($this->containers as $container) {
if ($this->isSentinel) {
$labels = Arr::undot(data_get($container, 'labels'));
} else {
if ($this->server->isSwarm()) {
$labels = Arr::undot(data_get($container, 'Spec.Labels'));
} else {
$labels = Arr::undot(data_get($container, 'Config.Labels'));
}
}
$managed = data_get($labels, 'coolify.managed');
if (! $managed) {
continue;
}
$uuid = data_get($labels, 'coolify.name');
if (! $uuid) {
$uuid = data_get($labels, 'com.docker.compose.service');
}
if ($this->isSentinel) {
$containerStatus = data_get($container, 'state');
$containerHealth = data_get($container, 'health_status');
} else {
$containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
}
$containerStatus = "$containerStatus ($containerHealth)";
$applicationId = data_get($labels, 'coolify.applicationId');
$serviceId = data_get($labels, 'coolify.serviceId');
$databaseId = data_get($labels, 'coolify.databaseId');
$pullRequestId = data_get($labels, 'coolify.pullRequestId');
if ($applicationId) {
// Application
if ($pullRequestId != 0) {
if (str($applicationId)->contains('-')) {
$applicationId = str($applicationId)->before('-');
}
$preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first();
if ($preview) {
$preview->update(['status' => $containerStatus]);
}
} else {
$application = Application::where('id', $applicationId)->first();
if ($application) {
$application->update([
'status' => $containerStatus,
'last_online_at' => now(),
]);
}
}
} elseif (isset($serviceId)) {
// Service
$subType = data_get($labels, 'coolify.service.subType');
$subId = data_get($labels, 'coolify.service.subId');
$service = Service::where('id', $serviceId)->first();
if (! $service) {
continue;
}
if ($subType === 'application') {
$service = ServiceApplication::where('id', $subId)->first();
} else {
$service = ServiceDatabase::where('id', $subId)->first();
}
if ($service) {
$service->update([
'status' => $containerStatus,
'last_online_at' => now(),
]);
if ($subType === 'database') {
$isPublic = data_get($service, 'is_public');
if ($isPublic) {
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
if ($this->isSentinel) {
return data_get($value, 'name') === $uuid.'-proxy';
} else {
if ($this->server->isSwarm()) {
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
} else {
return data_get($value, 'Name') === "/$uuid-proxy";
}
}
})->first();
if (! $foundTcpProxy) {
StartDatabaseProxy::run($service);
}
}
}
}
} else {
// Database
if (is_null($this->databases)) {
$this->databases = $this->server->databases();
}
$database = $this->databases->where('uuid', $uuid)->first();
if ($database) {
$database->update([
'status' => $containerStatus,
'last_online_at' => now(),
]);
$isPublic = data_get($database, 'is_public');
if ($isPublic) {
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
if ($this->isSentinel) {
return data_get($value, 'name') === $uuid.'-proxy';
} else {
if ($this->server->isSwarm()) {
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
} else {
return data_get($value, 'Name') === "/$uuid-proxy";
}
}
})->first();
if (! $foundTcpProxy) {
StartDatabaseProxy::run($database);
// $this->server->team?->notify(new ContainerRestarted("TCP Proxy for database", $this->server));
}
}
}
}
}
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Actions\Server; namespace App\Actions\Server;
use App\Events\SentinelRestarted;
use App\Models\Server; use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
@@ -9,7 +10,7 @@ class StartSentinel
{ {
use AsAction; use AsAction;
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null) public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null)
{ {
if ($server->isSwarm() || $server->isBuildServer()) { if ($server->isSwarm() || $server->isBuildServer()) {
return; return;
@@ -43,7 +44,9 @@ class StartSentinel
]; ];
if (isDev()) { if (isDev()) {
// data_set($environments, 'DEBUG', 'true'); // data_set($environments, 'DEBUG', 'true');
// $image = 'sentinel'; if ($customImage && ! empty($customImage)) {
$image = $customImage;
}
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel'; $mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
} }
$dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"'; $dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"';
@@ -61,5 +64,8 @@ class StartSentinel
$server->settings->is_sentinel_enabled = true; $server->settings->is_sentinel_enabled = true;
$server->settings->save(); $server->settings->save();
$server->sentinelHeartbeat(); $server->sentinelHeartbeat();
// Dispatch event to notify UI components
SentinelRestarted::dispatch($server, $version);
} }
} }

View File

@@ -29,7 +29,7 @@ class UpdateCoolify
if (! $this->server) { if (! $this->server) {
return; return;
} }
CleanupDocker::dispatch($this->server); CleanupDocker::dispatch($this->server, false, false);
$this->latestVersion = get_latest_version_of_coolify(); $this->latestVersion = get_latest_version_of_coolify();
$this->currentVersion = config('constants.coolify.version'); $this->currentVersion = config('constants.coolify.version');
if (! $manual_update) { if (! $manual_update) {

View File

@@ -11,7 +11,7 @@ class DeleteService
{ {
use AsAction; use AsAction;
public function handle(Service $service, bool $deleteConfigurations, bool $deleteVolumes, bool $dockerCleanup, bool $deleteConnectedNetworks) public function handle(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations, bool $dockerCleanup)
{ {
try { try {
$server = data_get($service, 'server'); $server = data_get($service, 'server');
@@ -71,7 +71,7 @@ class DeleteService
$service->forceDelete(); $service->forceDelete();
if ($dockerCleanup) { if ($dockerCleanup) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, false, false);
} }
} }
} }

View File

@@ -14,7 +14,7 @@ class StopService
public string $jobQueue = 'high'; public string $jobQueue = 'high';
public function handle(Service $service, bool $isDeleteOperation = false, bool $dockerCleanup = true) public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true)
{ {
try { try {
$server = $service->destination->server; $server = $service->destination->server;
@@ -36,11 +36,11 @@ class StopService
$this->stopContainersInParallel($containersToStop, $server); $this->stopContainersInParallel($containersToStop, $server);
} }
if ($isDeleteOperation) { if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks(); $service->deleteConnectedNetworks();
} }
if ($dockerCleanup) { if ($dockerCleanup) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, false, false);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
return $e->getMessage(); return $e->getMessage();

View File

@@ -26,22 +26,22 @@ class ComplexStatusCheck
continue; continue;
} }
} }
$container = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false); $containers = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
$container = format_docker_command_output_to_json($container); $containers = format_docker_command_output_to_json($containers);
if ($container->count() === 1) {
$container = $container->first(); if ($containers->count() > 0) {
$containerStatus = data_get($container, 'State.Status'); $statusToSet = $this->aggregateContainerStatuses($application, $containers);
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
if ($is_main_server) { if ($is_main_server) {
$statusFromDb = $application->status; $statusFromDb = $application->status;
if ($statusFromDb !== $containerStatus) { if ($statusFromDb !== $statusToSet) {
$application->update(['status' => "$containerStatus:$containerHealth"]); $application->update(['status' => $statusToSet]);
} }
} else { } else {
$additional_server = $application->additional_servers()->wherePivot('server_id', $server->id); $additional_server = $application->additional_servers()->wherePivot('server_id', $server->id);
$statusFromDb = $additional_server->first()->pivot->status; $statusFromDb = $additional_server->first()->pivot->status;
if ($statusFromDb !== $containerStatus) { if ($statusFromDb !== $statusToSet) {
$additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]); $additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]);
} }
} }
} else { } else {
@@ -57,4 +57,78 @@ class ComplexStatusCheck
} }
} }
} }
private function aggregateContainerStatuses($application, $containers)
{
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
$excludedContainers = collect();
if ($dockerComposeRaw) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $serviceConfig) {
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
if ($excludeFromHc || $restartPolicy === 'no') {
$excludedContainers->push($serviceName);
}
}
} catch (\Exception $e) {
// If we can't parse, treat all containers as included
}
}
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasExited = false;
$relevantContainerCount = 0;
foreach ($containers as $container) {
$labels = data_get($container, 'Config.Labels', []);
$serviceName = data_get($labels, 'com.docker.compose.service');
if ($serviceName && $excludedContainers->contains($serviceName)) {
continue;
}
$relevantContainerCount++;
$containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
if ($containerStatus === 'restarting') {
$hasRestarting = true;
$hasUnhealthy = true;
} elseif ($containerStatus === 'running') {
$hasRunning = true;
if ($containerHealth === 'unhealthy') {
$hasUnhealthy = true;
}
} elseif ($containerStatus === 'exited') {
$hasExited = true;
$hasUnhealthy = true;
}
}
if ($relevantContainerCount === 0) {
return 'running:healthy';
}
if ($hasRestarting) {
return 'degraded:unhealthy';
}
if ($hasRunning && $hasExited) {
return 'degraded:unhealthy';
}
if ($hasRunning) {
return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy';
}
return 'exited:unhealthy';
}
} }

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Actions\Stripe;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Support\Collection;
use Stripe\StripeClient;
class CancelSubscription
{
private User $user;
private bool $isDryRun;
private ?StripeClient $stripe = null;
public function __construct(User $user, bool $isDryRun = false)
{
$this->user = $user;
$this->isDryRun = $isDryRun;
if (! $isDryRun && isCloud()) {
$this->stripe = new StripeClient(config('subscription.stripe_api_key'));
}
}
public function getSubscriptionsPreview(): Collection
{
$subscriptions = collect();
// Get all teams the user belongs to
$teams = $this->user->teams;
foreach ($teams as $team) {
// Only include subscriptions from teams where user is owner
$userRole = $team->pivot->role;
if ($userRole === 'owner' && $team->subscription) {
$subscription = $team->subscription;
// Only include active subscriptions
if ($subscription->stripe_subscription_id &&
$subscription->stripe_invoice_paid) {
$subscriptions->push($subscription);
}
}
}
return $subscriptions;
}
public function execute(): array
{
if ($this->isDryRun) {
return [
'cancelled' => 0,
'failed' => 0,
'errors' => [],
];
}
$cancelledCount = 0;
$failedCount = 0;
$errors = [];
$subscriptions = $this->getSubscriptionsPreview();
foreach ($subscriptions as $subscription) {
try {
$this->cancelSingleSubscription($subscription);
$cancelledCount++;
} catch (\Exception $e) {
$failedCount++;
$errorMessage = "Failed to cancel subscription {$subscription->stripe_subscription_id}: ".$e->getMessage();
$errors[] = $errorMessage;
\Log::error($errorMessage);
}
}
return [
'cancelled' => $cancelledCount,
'failed' => $failedCount,
'errors' => $errors,
];
}
private function cancelSingleSubscription(Subscription $subscription): void
{
if (! $this->stripe) {
throw new \Exception('Stripe client not initialized');
}
$subscriptionId = $subscription->stripe_subscription_id;
// Cancel the subscription immediately (not at period end)
$this->stripe->subscriptions->cancel($subscriptionId, []);
// Update local database
$subscription->update([
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_past_due' => false,
'stripe_feedback' => 'User account deleted',
'stripe_comment' => 'Subscription cancelled due to user account deletion at '.now()->toDateTimeString(),
]);
// Call the team's subscription ended method to handle cleanup
if ($subscription->team) {
$subscription->team->subscriptionEnded();
}
\Log::info("Cancelled Stripe subscription: {$subscriptionId} for team: {$subscription->team->name}");
}
/**
* Cancel a single subscription by ID (helper method for external use)
*/
public static function cancelById(string $subscriptionId): bool
{
try {
if (! isCloud()) {
return false;
}
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe->subscriptions->cancel($subscriptionId, []);
// Update local record if exists
$subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->first();
if ($subscription) {
$subscription->update([
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_past_due' => false,
]);
if ($subscription->team) {
$subscription->team->subscriptionEnded();
}
}
return true;
} catch (\Exception $e) {
\Log::error("Failed to cancel subscription {$subscriptionId}: ".$e->getMessage());
return false;
}
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Actions\User;
use App\Models\User;
use Illuminate\Support\Collection;
class DeleteUserResources
{
private User $user;
private bool $isDryRun;
public function __construct(User $user, bool $isDryRun = false)
{
$this->user = $user;
$this->isDryRun = $isDryRun;
}
public function getResourcesPreview(): array
{
$applications = collect();
$databases = collect();
$services = collect();
// Get all teams the user belongs to
$teams = $this->user->teams;
foreach ($teams as $team) {
// Get all servers for this team
$servers = $team->servers;
foreach ($servers as $server) {
// Get applications
$serverApplications = $server->applications;
$applications = $applications->merge($serverApplications);
// Get databases
$serverDatabases = $this->getAllDatabasesForServer($server);
$databases = $databases->merge($serverDatabases);
// Get services
$serverServices = $server->services;
$services = $services->merge($serverServices);
}
}
return [
'applications' => $applications->unique('id'),
'databases' => $databases->unique('id'),
'services' => $services->unique('id'),
];
}
public function execute(): array
{
if ($this->isDryRun) {
return [
'applications' => 0,
'databases' => 0,
'services' => 0,
];
}
$deletedCounts = [
'applications' => 0,
'databases' => 0,
'services' => 0,
];
$resources = $this->getResourcesPreview();
// Delete applications
foreach ($resources['applications'] as $application) {
try {
$application->forceDelete();
$deletedCounts['applications']++;
} catch (\Exception $e) {
\Log::error("Failed to delete application {$application->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
// Delete databases
foreach ($resources['databases'] as $database) {
try {
$database->forceDelete();
$deletedCounts['databases']++;
} catch (\Exception $e) {
\Log::error("Failed to delete database {$database->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
// Delete services
foreach ($resources['services'] as $service) {
try {
$service->forceDelete();
$deletedCounts['services']++;
} catch (\Exception $e) {
\Log::error("Failed to delete service {$service->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
return $deletedCounts;
}
private function getAllDatabasesForServer($server): Collection
{
$databases = collect();
// Get all standalone database types
$databases = $databases->merge($server->postgresqls);
$databases = $databases->merge($server->mysqls);
$databases = $databases->merge($server->mariadbs);
$databases = $databases->merge($server->mongodbs);
$databases = $databases->merge($server->redis);
$databases = $databases->merge($server->keydbs);
$databases = $databases->merge($server->dragonflies);
$databases = $databases->merge($server->clickhouses);
return $databases;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Actions\User;
use App\Models\Server;
use App\Models\User;
use Illuminate\Support\Collection;
class DeleteUserServers
{
private User $user;
private bool $isDryRun;
public function __construct(User $user, bool $isDryRun = false)
{
$this->user = $user;
$this->isDryRun = $isDryRun;
}
public function getServersPreview(): Collection
{
$servers = collect();
// Get all teams the user belongs to
$teams = $this->user->teams;
foreach ($teams as $team) {
// Only include servers from teams where user is owner or admin
$userRole = $team->pivot->role;
if ($userRole === 'owner' || $userRole === 'admin') {
$teamServers = $team->servers;
$servers = $servers->merge($teamServers);
}
}
// Return unique servers (in case same server is in multiple teams)
return $servers->unique('id');
}
public function execute(): array
{
if ($this->isDryRun) {
return [
'servers' => 0,
];
}
$deletedCount = 0;
$servers = $this->getServersPreview();
foreach ($servers as $server) {
try {
// Skip the default server (ID 0) which is the Coolify host
if ($server->id === 0) {
\Log::info('Skipping deletion of Coolify host server (ID: 0)');
continue;
}
// The Server model's forceDeleting event will handle cleanup of:
// - destinations
// - settings
$server->forceDelete();
$deletedCount++;
} catch (\Exception $e) {
\Log::error("Failed to delete server {$server->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
return [
'servers' => $deletedCount,
];
}
}

View File

@@ -0,0 +1,202 @@
<?php
namespace App\Actions\User;
use App\Models\Team;
use App\Models\User;
class DeleteUserTeams
{
private User $user;
private bool $isDryRun;
public function __construct(User $user, bool $isDryRun = false)
{
$this->user = $user;
$this->isDryRun = $isDryRun;
}
public function getTeamsPreview(): array
{
$teamsToDelete = collect();
$teamsToTransfer = collect();
$teamsToLeave = collect();
$edgeCases = collect();
$teams = $this->user->teams;
foreach ($teams as $team) {
// Skip root team (ID 0)
if ($team->id === 0) {
continue;
}
$userRole = $team->pivot->role;
$memberCount = $team->members->count();
if ($memberCount === 1) {
// User is alone in the team - delete it
$teamsToDelete->push($team);
} elseif ($userRole === 'owner') {
// Check if there are other owners
$otherOwners = $team->members
->where('id', '!=', $this->user->id)
->filter(function ($member) {
return $member->pivot->role === 'owner';
});
if ($otherOwners->isNotEmpty()) {
// There are other owners, but check if this user is paying for the subscription
if ($this->isUserPayingForTeamSubscription($team)) {
// User is paying for the subscription - this is an edge case
$edgeCases->push([
'team' => $team,
'reason' => 'User is paying for the team\'s Stripe subscription but there are other owners. The subscription needs to be cancelled or transferred to another owner\'s payment method.',
]);
} else {
// There are other owners and user is not paying, just remove this user
$teamsToLeave->push($team);
}
} else {
// User is the only owner, check for replacement
$newOwner = $this->findNewOwner($team);
if ($newOwner) {
$teamsToTransfer->push([
'team' => $team,
'new_owner' => $newOwner,
]);
} else {
// No suitable replacement found - this is an edge case
$edgeCases->push([
'team' => $team,
'reason' => 'No suitable owner replacement found. Team has only regular members without admin privileges.',
]);
}
}
} else {
// User is just a member - remove them from the team
$teamsToLeave->push($team);
}
}
return [
'to_delete' => $teamsToDelete,
'to_transfer' => $teamsToTransfer,
'to_leave' => $teamsToLeave,
'edge_cases' => $edgeCases,
];
}
public function execute(): array
{
if ($this->isDryRun) {
return [
'deleted' => 0,
'transferred' => 0,
'left' => 0,
];
}
$counts = [
'deleted' => 0,
'transferred' => 0,
'left' => 0,
];
$preview = $this->getTeamsPreview();
// Check for edge cases - should not happen here as we check earlier, but be safe
if ($preview['edge_cases']->isNotEmpty()) {
throw new \Exception('Edge cases detected during execution. This should not happen.');
}
// Delete teams where user is alone
foreach ($preview['to_delete'] as $team) {
try {
// The Team model's deleting event will handle cleanup of:
// - private keys
// - sources
// - tags
// - environment variables
// - s3 storages
// - notification settings
$team->delete();
$counts['deleted']++;
} catch (\Exception $e) {
\Log::error("Failed to delete team {$team->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
// Transfer ownership for teams where user is owner but not alone
foreach ($preview['to_transfer'] as $item) {
try {
$team = $item['team'];
$newOwner = $item['new_owner'];
// Update the new owner's role to owner
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
// Remove the current user from the team
$team->members()->detach($this->user->id);
$counts['transferred']++;
} catch (\Exception $e) {
\Log::error("Failed to transfer ownership of team {$item['team']->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
// Remove user from teams where they're just a member
foreach ($preview['to_leave'] as $team) {
try {
$team->members()->detach($this->user->id);
$counts['left']++;
} catch (\Exception $e) {
\Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
return $counts;
}
private function findNewOwner(Team $team): ?User
{
// Only look for admins as potential new owners
// We don't promote regular members automatically
$otherAdmin = $team->members
->where('id', '!=', $this->user->id)
->filter(function ($member) {
return $member->pivot->role === 'admin';
})
->first();
return $otherAdmin;
}
private function isUserPayingForTeamSubscription(Team $team): bool
{
if (! $team->subscription || ! $team->subscription->stripe_customer_id) {
return false;
}
// In Stripe, we need to check if the customer email matches the user's email
// This would require a Stripe API call to get customer details
// For now, we'll check if the subscription was created by this user
// Alternative approach: Check if user is the one who initiated the subscription
// We could store this information when the subscription is created
// For safety, we'll assume if there's an active subscription and multiple owners,
// we should treat it as an edge case that needs manual review
if ($team->subscription->stripe_subscription_id &&
$team->subscription->stripe_invoice_paid) {
// Active subscription exists - we should be cautious
return true;
}
return false;
}
}

View File

@@ -64,13 +64,5 @@ class CleanupDatabase extends Command
if ($this->option('yes')) { if ($this->option('yes')) {
$scheduled_task_executions->delete(); $scheduled_task_executions->delete();
} }
// Cleanup webhooks table
$webhooks = DB::table('webhooks')->where('created_at', '<', now()->subDays($keep_days));
$count = $webhooks->count();
echo "Delete $count entries from webhooks.\n";
if ($this->option('yes')) {
$webhooks->delete();
}
} }
} }

View File

@@ -0,0 +1,248 @@
<?php
namespace App\Console\Commands;
use App\Models\Application;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Tag;
use App\Models\Team;
use App\Support\ValidationPatterns;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class CleanupNames extends Command
{
protected $signature = 'cleanup:names
{--dry-run : Preview changes without applying them}
{--model= : Clean specific model (e.g., Project, Server)}
{--backup : Create database backup before changes}
{--force : Skip confirmation prompt}';
protected $description = 'Sanitize name fields by removing invalid characters (keeping only letters, numbers, spaces, dashes, underscores, dots, slashes, colons, parentheses)';
protected array $modelsToClean = [
'Project' => Project::class,
'Environment' => Environment::class,
'Application' => Application::class,
'Service' => Service::class,
'Server' => Server::class,
'Team' => Team::class,
'StandalonePostgresql' => StandalonePostgresql::class,
'StandaloneMysql' => StandaloneMysql::class,
'StandaloneRedis' => StandaloneRedis::class,
'StandaloneMongodb' => StandaloneMongodb::class,
'StandaloneMariadb' => StandaloneMariadb::class,
'StandaloneKeydb' => StandaloneKeydb::class,
'StandaloneDragonfly' => StandaloneDragonfly::class,
'StandaloneClickhouse' => StandaloneClickhouse::class,
'S3Storage' => S3Storage::class,
'Tag' => Tag::class,
'PrivateKey' => PrivateKey::class,
'ScheduledTask' => ScheduledTask::class,
];
protected array $changes = [];
protected int $totalProcessed = 0;
protected int $totalCleaned = 0;
public function handle(): int
{
$this->info('🔍 Scanning for invalid characters in name fields...');
if ($this->option('backup') && ! $this->option('dry-run')) {
$this->createBackup();
}
$modelFilter = $this->option('model');
$modelsToProcess = $modelFilter
? [$modelFilter => $this->modelsToClean[$modelFilter] ?? null]
: $this->modelsToClean;
if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
$this->error("❌ Unknown model: {$modelFilter}");
$this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
return self::FAILURE;
}
foreach ($modelsToProcess as $modelName => $modelClass) {
if (! $modelClass) {
continue;
}
$this->processModel($modelName, $modelClass);
}
$this->displaySummary();
if (! $this->option('dry-run') && $this->totalCleaned > 0) {
$this->logChanges();
}
return self::SUCCESS;
}
protected function processModel(string $modelName, string $modelClass): void
{
$this->info("\n📋 Processing {$modelName}...");
try {
$records = $modelClass::all(['id', 'name']);
$cleaned = 0;
foreach ($records as $record) {
$this->totalProcessed++;
$originalName = $record->name;
$sanitizedName = $this->sanitizeName($originalName);
if ($sanitizedName !== $originalName) {
$this->changes[] = [
'model' => $modelName,
'id' => $record->id,
'original' => $originalName,
'sanitized' => $sanitizedName,
'timestamp' => now(),
];
if (! $this->option('dry-run')) {
// Update without triggering events/mutators to avoid conflicts
$modelClass::where('id', $record->id)->update(['name' => $sanitizedName]);
}
$cleaned++;
$this->totalCleaned++;
$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());
}
}
protected function sanitizeName(string $name): string
{
// Remove all characters that don't match the allowed pattern
// Use the shared ValidationPatterns to ensure consistency
$allowedPattern = str_replace(['/', '^', '$'], '', ValidationPatterns::NAME_PATTERN);
$sanitized = preg_replace('/[^'.$allowedPattern.']+/', '', $name);
// Clean up excessive whitespace but preserve other allowed characters
$sanitized = preg_replace('/\s+/', ' ', $sanitized);
$sanitized = trim($sanitized);
// If result is empty, provide a default name
if (empty($sanitized)) {
$sanitized = 'sanitized-item';
}
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');
$logData = [
'timestamp' => now()->toISOString(),
'total_processed' => $this->totalProcessed,
'total_cleaned' => $this->totalCleaned,
'changes' => $this->changes,
];
file_put_contents($logFile, json_encode($logData, JSON_PRETTY_PRINT)."\n", FILE_APPEND);
Log::info('Name Sanitization completed', [
'total_processed' => $this->totalProcessed,
'total_sanitized' => $this->totalCleaned,
'changes_count' => count($this->changes),
]);
}
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');
// Ensure backup directory exists
if (! file_exists(dirname($backupFile))) {
mkdir(dirname($backupFile), 0755, true);
}
$dbConfig = config('database.connections.'.config('database.default'));
$command = sprintf(
'pg_dump -h %s -p %s -U %s -d %s > %s',
$dbConfig['host'],
$dbConfig['port'],
$dbConfig['username'],
$dbConfig['database'],
$backupFile
);
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...');
}
}
protected function truncate(string $text, int $length): string
{
return strlen($text) > $length ? substr($text, 0, $length).'...' : $text;
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Jobs\CleanupHelperContainersJob; use App\Jobs\CleanupHelperContainersJob;
use App\Jobs\DeleteResourceJob;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
@@ -72,7 +73,7 @@ class CleanupStuckedResources extends Command
$applications = Application::withTrashed()->whereNotNull('deleted_at')->get(); $applications = Application::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($applications as $application) { foreach ($applications as $application) {
echo "Deleting stuck application: {$application->name}\n"; echo "Deleting stuck application: {$application->name}\n";
$application->forceDelete(); DeleteResourceJob::dispatch($application);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Error in cleaning stuck application: {$e->getMessage()}\n"; echo "Error in cleaning stuck application: {$e->getMessage()}\n";
@@ -82,26 +83,35 @@ class CleanupStuckedResources extends Command
foreach ($applicationsPreviews as $applicationPreview) { foreach ($applicationsPreviews as $applicationPreview) {
if (! data_get($applicationPreview, 'application')) { if (! data_get($applicationPreview, 'application')) {
echo "Deleting stuck application preview: {$applicationPreview->uuid}\n"; echo "Deleting stuck application preview: {$applicationPreview->uuid}\n";
$applicationPreview->delete(); DeleteResourceJob::dispatch($applicationPreview);
} }
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Error in cleaning stuck application: {$e->getMessage()}\n"; echo "Error in cleaning stuck application: {$e->getMessage()}\n";
} }
try {
$applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($applicationsPreviews as $applicationPreview) {
echo "Deleting stuck application preview: {$applicationPreview->fqdn}\n";
DeleteResourceJob::dispatch($applicationPreview);
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck application: {$e->getMessage()}\n";
}
try { try {
$postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get(); $postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($postgresqls as $postgresql) { foreach ($postgresqls as $postgresql) {
echo "Deleting stuck postgresql: {$postgresql->name}\n"; echo "Deleting stuck postgresql: {$postgresql->name}\n";
$postgresql->forceDelete(); DeleteResourceJob::dispatch($postgresql);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n"; echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n";
} }
try { try {
$redis = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get(); $rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($redis as $redis) { foreach ($rediss as $redis) {
echo "Deleting stuck redis: {$redis->name}\n"; echo "Deleting stuck redis: {$redis->name}\n";
$redis->forceDelete(); DeleteResourceJob::dispatch($redis);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Error in cleaning stuck redis: {$e->getMessage()}\n"; echo "Error in cleaning stuck redis: {$e->getMessage()}\n";
@@ -110,7 +120,7 @@ class CleanupStuckedResources extends Command
$keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->get(); $keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($keydbs as $keydb) { foreach ($keydbs as $keydb) {
echo "Deleting stuck keydb: {$keydb->name}\n"; echo "Deleting stuck keydb: {$keydb->name}\n";
$keydb->forceDelete(); DeleteResourceJob::dispatch($keydb);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Error in cleaning stuck keydb: {$e->getMessage()}\n"; echo "Error in cleaning stuck keydb: {$e->getMessage()}\n";
@@ -119,7 +129,7 @@ class CleanupStuckedResources extends Command
$dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->get(); $dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($dragonflies as $dragonfly) { foreach ($dragonflies as $dragonfly) {
echo "Deleting stuck dragonfly: {$dragonfly->name}\n"; echo "Deleting stuck dragonfly: {$dragonfly->name}\n";
$dragonfly->forceDelete(); DeleteResourceJob::dispatch($dragonfly);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Error in cleaning stuck dragonfly: {$e->getMessage()}\n"; echo "Error in cleaning stuck dragonfly: {$e->getMessage()}\n";
@@ -128,7 +138,7 @@ class CleanupStuckedResources extends Command
$clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->get(); $clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($clickhouses as $clickhouse) { foreach ($clickhouses as $clickhouse) {
echo "Deleting stuck clickhouse: {$clickhouse->name}\n"; echo "Deleting stuck clickhouse: {$clickhouse->name}\n";
$clickhouse->forceDelete(); DeleteResourceJob::dispatch($clickhouse);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Error in cleaning stuck clickhouse: {$e->getMessage()}\n"; echo "Error in cleaning stuck clickhouse: {$e->getMessage()}\n";
@@ -137,7 +147,7 @@ class CleanupStuckedResources extends Command
$mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get(); $mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($mongodbs as $mongodb) { foreach ($mongodbs as $mongodb) {
echo "Deleting stuck mongodb: {$mongodb->name}\n"; echo "Deleting stuck mongodb: {$mongodb->name}\n";
$mongodb->forceDelete(); DeleteResourceJob::dispatch($mongodb);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n"; echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n";
@@ -146,7 +156,7 @@ class CleanupStuckedResources extends Command
$mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get(); $mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($mysqls as $mysql) { foreach ($mysqls as $mysql) {
echo "Deleting stuck mysql: {$mysql->name}\n"; echo "Deleting stuck mysql: {$mysql->name}\n";
$mysql->forceDelete(); DeleteResourceJob::dispatch($mysql);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Error in cleaning stuck mysql: {$e->getMessage()}\n"; echo "Error in cleaning stuck mysql: {$e->getMessage()}\n";
@@ -155,7 +165,7 @@ class CleanupStuckedResources extends Command
$mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get(); $mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($mariadbs as $mariadb) { foreach ($mariadbs as $mariadb) {
echo "Deleting stuck mariadb: {$mariadb->name}\n"; echo "Deleting stuck mariadb: {$mariadb->name}\n";
$mariadb->forceDelete(); DeleteResourceJob::dispatch($mariadb);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n"; echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n";
@@ -164,7 +174,7 @@ class CleanupStuckedResources extends Command
$services = Service::withTrashed()->whereNotNull('deleted_at')->get(); $services = Service::withTrashed()->whereNotNull('deleted_at')->get();
foreach ($services as $service) { foreach ($services as $service) {
echo "Deleting stuck service: {$service->name}\n"; echo "Deleting stuck service: {$service->name}\n";
$service->forceDelete(); DeleteResourceJob::dispatch($service);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Error in cleaning stuck service: {$e->getMessage()}\n"; echo "Error in cleaning stuck service: {$e->getMessage()}\n";
@@ -217,19 +227,19 @@ class CleanupStuckedResources extends Command
foreach ($applications as $application) { foreach ($applications as $application) {
if (! data_get($application, 'environment')) { if (! data_get($application, 'environment')) {
echo 'Application without environment: '.$application->name.'\n'; echo 'Application without environment: '.$application->name.'\n';
$application->forceDelete(); DeleteResourceJob::dispatch($application);
continue; continue;
} }
if (! $application->destination()) { if (! $application->destination()) {
echo 'Application without destination: '.$application->name.'\n'; echo 'Application without destination: '.$application->name.'\n';
$application->forceDelete(); DeleteResourceJob::dispatch($application);
continue; continue;
} }
if (! data_get($application, 'destination.server')) { if (! data_get($application, 'destination.server')) {
echo 'Application without server: '.$application->name.'\n'; echo 'Application without server: '.$application->name.'\n';
$application->forceDelete(); DeleteResourceJob::dispatch($application);
continue; continue;
} }
@@ -242,19 +252,19 @@ class CleanupStuckedResources extends Command
foreach ($postgresqls as $postgresql) { foreach ($postgresqls as $postgresql) {
if (! data_get($postgresql, 'environment')) { if (! data_get($postgresql, 'environment')) {
echo 'Postgresql without environment: '.$postgresql->name.'\n'; echo 'Postgresql without environment: '.$postgresql->name.'\n';
$postgresql->forceDelete(); DeleteResourceJob::dispatch($postgresql);
continue; continue;
} }
if (! $postgresql->destination()) { if (! $postgresql->destination()) {
echo 'Postgresql without destination: '.$postgresql->name.'\n'; echo 'Postgresql without destination: '.$postgresql->name.'\n';
$postgresql->forceDelete(); DeleteResourceJob::dispatch($postgresql);
continue; continue;
} }
if (! data_get($postgresql, 'destination.server')) { if (! data_get($postgresql, 'destination.server')) {
echo 'Postgresql without server: '.$postgresql->name.'\n'; echo 'Postgresql without server: '.$postgresql->name.'\n';
$postgresql->forceDelete(); DeleteResourceJob::dispatch($postgresql);
continue; continue;
} }
@@ -267,19 +277,19 @@ class CleanupStuckedResources extends Command
foreach ($redis as $redis) { foreach ($redis as $redis) {
if (! data_get($redis, 'environment')) { if (! data_get($redis, 'environment')) {
echo 'Redis without environment: '.$redis->name.'\n'; echo 'Redis without environment: '.$redis->name.'\n';
$redis->forceDelete(); DeleteResourceJob::dispatch($redis);
continue; continue;
} }
if (! $redis->destination()) { if (! $redis->destination()) {
echo 'Redis without destination: '.$redis->name.'\n'; echo 'Redis without destination: '.$redis->name.'\n';
$redis->forceDelete(); DeleteResourceJob::dispatch($redis);
continue; continue;
} }
if (! data_get($redis, 'destination.server')) { if (! data_get($redis, 'destination.server')) {
echo 'Redis without server: '.$redis->name.'\n'; echo 'Redis without server: '.$redis->name.'\n';
$redis->forceDelete(); DeleteResourceJob::dispatch($redis);
continue; continue;
} }
@@ -293,19 +303,19 @@ class CleanupStuckedResources extends Command
foreach ($mongodbs as $mongodb) { foreach ($mongodbs as $mongodb) {
if (! data_get($mongodb, 'environment')) { if (! data_get($mongodb, 'environment')) {
echo 'Mongodb without environment: '.$mongodb->name.'\n'; echo 'Mongodb without environment: '.$mongodb->name.'\n';
$mongodb->forceDelete(); DeleteResourceJob::dispatch($mongodb);
continue; continue;
} }
if (! $mongodb->destination()) { if (! $mongodb->destination()) {
echo 'Mongodb without destination: '.$mongodb->name.'\n'; echo 'Mongodb without destination: '.$mongodb->name.'\n';
$mongodb->forceDelete(); DeleteResourceJob::dispatch($mongodb);
continue; continue;
} }
if (! data_get($mongodb, 'destination.server')) { if (! data_get($mongodb, 'destination.server')) {
echo 'Mongodb without server: '.$mongodb->name.'\n'; echo 'Mongodb without server: '.$mongodb->name.'\n';
$mongodb->forceDelete(); DeleteResourceJob::dispatch($mongodb);
continue; continue;
} }
@@ -319,19 +329,19 @@ class CleanupStuckedResources extends Command
foreach ($mysqls as $mysql) { foreach ($mysqls as $mysql) {
if (! data_get($mysql, 'environment')) { if (! data_get($mysql, 'environment')) {
echo 'Mysql without environment: '.$mysql->name.'\n'; echo 'Mysql without environment: '.$mysql->name.'\n';
$mysql->forceDelete(); DeleteResourceJob::dispatch($mysql);
continue; continue;
} }
if (! $mysql->destination()) { if (! $mysql->destination()) {
echo 'Mysql without destination: '.$mysql->name.'\n'; echo 'Mysql without destination: '.$mysql->name.'\n';
$mysql->forceDelete(); DeleteResourceJob::dispatch($mysql);
continue; continue;
} }
if (! data_get($mysql, 'destination.server')) { if (! data_get($mysql, 'destination.server')) {
echo 'Mysql without server: '.$mysql->name.'\n'; echo 'Mysql without server: '.$mysql->name.'\n';
$mysql->forceDelete(); DeleteResourceJob::dispatch($mysql);
continue; continue;
} }
@@ -345,19 +355,19 @@ class CleanupStuckedResources extends Command
foreach ($mariadbs as $mariadb) { foreach ($mariadbs as $mariadb) {
if (! data_get($mariadb, 'environment')) { if (! data_get($mariadb, 'environment')) {
echo 'Mariadb without environment: '.$mariadb->name.'\n'; echo 'Mariadb without environment: '.$mariadb->name.'\n';
$mariadb->forceDelete(); DeleteResourceJob::dispatch($mariadb);
continue; continue;
} }
if (! $mariadb->destination()) { if (! $mariadb->destination()) {
echo 'Mariadb without destination: '.$mariadb->name.'\n'; echo 'Mariadb without destination: '.$mariadb->name.'\n';
$mariadb->forceDelete(); DeleteResourceJob::dispatch($mariadb);
continue; continue;
} }
if (! data_get($mariadb, 'destination.server')) { if (! data_get($mariadb, 'destination.server')) {
echo 'Mariadb without server: '.$mariadb->name.'\n'; echo 'Mariadb without server: '.$mariadb->name.'\n';
$mariadb->forceDelete(); DeleteResourceJob::dispatch($mariadb);
continue; continue;
} }
@@ -371,19 +381,19 @@ class CleanupStuckedResources extends Command
foreach ($services as $service) { foreach ($services as $service) {
if (! data_get($service, 'environment')) { if (! data_get($service, 'environment')) {
echo 'Service without environment: '.$service->name.'\n'; echo 'Service without environment: '.$service->name.'\n';
$service->forceDelete(); DeleteResourceJob::dispatch($service);
continue; continue;
} }
if (! $service->destination()) { if (! $service->destination()) {
echo 'Service without destination: '.$service->name.'\n'; echo 'Service without destination: '.$service->name.'\n';
$service->forceDelete(); DeleteResourceJob::dispatch($service);
continue; continue;
} }
if (! data_get($service, 'server')) { if (! data_get($service, 'server')) {
echo 'Service without server: '.$service->name.'\n'; echo 'Service without server: '.$service->name.'\n';
$service->forceDelete(); DeleteResourceJob::dispatch($service);
continue; continue;
} }
@@ -396,7 +406,7 @@ class CleanupStuckedResources extends Command
foreach ($serviceApplications as $service) { foreach ($serviceApplications as $service) {
if (! data_get($service, 'service')) { if (! data_get($service, 'service')) {
echo 'ServiceApplication without service: '.$service->name.'\n'; echo 'ServiceApplication without service: '.$service->name.'\n';
$service->forceDelete(); DeleteResourceJob::dispatch($service);
continue; continue;
} }
@@ -409,7 +419,7 @@ class CleanupStuckedResources extends Command
foreach ($serviceDatabases as $service) { foreach ($serviceDatabases as $service) {
if (! data_get($service, 'service')) { if (! data_get($service, 'service')) {
echo 'ServiceDatabase without service: '.$service->name.'\n'; echo 'ServiceDatabase without service: '.$service->name.'\n';
$service->forceDelete(); DeleteResourceJob::dispatch($service);
continue; continue;
} }

View File

@@ -0,0 +1,722 @@
<?php
namespace App\Console\Commands;
use App\Actions\Stripe\CancelSubscription;
use App\Actions\User\DeleteUserResources;
use App\Actions\User\DeleteUserServers;
use App\Actions\User\DeleteUserTeams;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class CloudDeleteUser extends Command
{
protected $signature = 'cloud:delete-user {email}
{--dry-run : Preview what will be deleted without actually deleting}
{--skip-stripe : Skip Stripe subscription cancellation}
{--skip-resources : Skip resource deletion}';
protected $description = 'Delete a user from the cloud instance with phase-by-phase confirmation';
private bool $isDryRun = false;
private bool $skipStripe = false;
private bool $skipResources = false;
private User $user;
public function handle()
{
if (! isCloud()) {
$this->error('This command is only available on cloud instances.');
return 1;
}
$email = $this->argument('email');
$this->isDryRun = $this->option('dry-run');
$this->skipStripe = $this->option('skip-stripe');
$this->skipResources = $this->option('skip-resources');
if ($this->isDryRun) {
$this->info('🔍 DRY RUN MODE - No data will be deleted');
$this->newLine();
}
try {
$this->user = User::whereEmail($email)->firstOrFail();
} catch (\Exception $e) {
$this->error("User with email '{$email}' not found.");
return 1;
}
$this->logAction("Starting user deletion process for: {$email}");
// Phase 1: Show User Overview (outside transaction)
if (! $this->showUserOverview()) {
$this->info('User deletion cancelled.');
return 0;
}
// If not dry run, wrap everything in a transaction
if (! $this->isDryRun) {
try {
DB::beginTransaction();
// Phase 2: Delete Resources
if (! $this->skipResources) {
if (! $this->deleteResources()) {
DB::rollBack();
$this->error('User deletion failed at resource deletion phase. All changes rolled back.');
return 1;
}
}
// Phase 3: Delete Servers
if (! $this->deleteServers()) {
DB::rollBack();
$this->error('User deletion failed at server deletion phase. All changes rolled back.');
return 1;
}
// Phase 4: Handle Teams
if (! $this->handleTeams()) {
DB::rollBack();
$this->error('User deletion failed at team handling phase. All changes rolled back.');
return 1;
}
// Phase 5: Cancel Stripe Subscriptions
if (! $this->skipStripe && isCloud()) {
if (! $this->cancelStripeSubscriptions()) {
DB::rollBack();
$this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.');
return 1;
}
}
// Phase 6: Delete User Profile
if (! $this->deleteUserProfile()) {
DB::rollBack();
$this->error('User deletion failed at final phase. All changes rolled back.');
return 1;
}
// Commit the transaction
DB::commit();
$this->newLine();
$this->info('✅ User deletion completed successfully!');
$this->logAction("User deletion completed for: {$email}");
} catch (\Exception $e) {
DB::rollBack();
$this->error('An error occurred during user deletion: '.$e->getMessage());
$this->logAction("User deletion failed for {$email}: ".$e->getMessage());
return 1;
}
} else {
// Dry run mode - just run through the phases without transaction
// Phase 2: Delete Resources
if (! $this->skipResources) {
if (! $this->deleteResources()) {
$this->info('User deletion would be cancelled at resource deletion phase.');
return 0;
}
}
// Phase 3: Delete Servers
if (! $this->deleteServers()) {
$this->info('User deletion would be cancelled at server deletion phase.');
return 0;
}
// Phase 4: Handle Teams
if (! $this->handleTeams()) {
$this->info('User deletion would be cancelled at team handling phase.');
return 0;
}
// Phase 5: Cancel Stripe Subscriptions
if (! $this->skipStripe && isCloud()) {
if (! $this->cancelStripeSubscriptions()) {
$this->info('User deletion would be cancelled at Stripe cancellation phase.');
return 0;
}
}
// Phase 6: Delete User Profile
if (! $this->deleteUserProfile()) {
$this->info('User deletion would be cancelled at final phase.');
return 0;
}
$this->newLine();
$this->info('✅ DRY RUN completed successfully! No data was deleted.');
}
return 0;
}
private function showUserOverview(): bool
{
$this->info('═══════════════════════════════════════');
$this->info('PHASE 1: USER OVERVIEW');
$this->info('═══════════════════════════════════════');
$this->newLine();
$teams = $this->user->teams;
$ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner');
$memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner');
// Collect all servers from all teams
$allServers = collect();
$allApplications = collect();
$allDatabases = collect();
$allServices = collect();
$activeSubscriptions = collect();
foreach ($teams as $team) {
$servers = $team->servers;
$allServers = $allServers->merge($servers);
foreach ($servers as $server) {
$resources = $server->definedResources();
foreach ($resources as $resource) {
if ($resource instanceof \App\Models\Application) {
$allApplications->push($resource);
} elseif ($resource instanceof \App\Models\Service) {
$allServices->push($resource);
} else {
$allDatabases->push($resource);
}
}
}
if ($team->subscription && $team->subscription->stripe_subscription_id) {
$activeSubscriptions->push($team->subscription);
}
}
$this->table(
['Property', 'Value'],
[
['User', $this->user->email],
['User ID', $this->user->id],
['Created', $this->user->created_at->format('Y-m-d H:i:s')],
['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')],
['Teams (Total)', $teams->count()],
['Teams (Owner)', $ownedTeams->count()],
['Teams (Member)', $memberTeams->count()],
['Servers', $allServers->unique('id')->count()],
['Applications', $allApplications->count()],
['Databases', $allDatabases->count()],
['Services', $allServices->count()],
['Active Stripe Subscriptions', $activeSubscriptions->count()],
]
);
$this->newLine();
$this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!');
$this->newLine();
if (! $this->confirm('Do you want to continue with the deletion process?', false)) {
return false;
}
return true;
}
private function deleteResources(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 2: DELETE RESOURCES');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new DeleteUserResources($this->user, $this->isDryRun);
$resources = $action->getResourcesPreview();
if ($resources['applications']->isEmpty() &&
$resources['databases']->isEmpty() &&
$resources['services']->isEmpty()) {
$this->info('No resources to delete.');
return true;
}
$this->info('Resources to be deleted:');
$this->newLine();
if ($resources['applications']->isNotEmpty()) {
$this->warn("Applications to be deleted ({$resources['applications']->count()}):");
$this->table(
['Name', 'UUID', 'Server', 'Status'],
$resources['applications']->map(function ($app) {
return [
$app->name,
$app->uuid,
$app->destination->server->name,
$app->status ?? 'unknown',
];
})->toArray()
);
$this->newLine();
}
if ($resources['databases']->isNotEmpty()) {
$this->warn("Databases to be deleted ({$resources['databases']->count()}):");
$this->table(
['Name', 'Type', 'UUID', 'Server'],
$resources['databases']->map(function ($db) {
return [
$db->name,
class_basename($db),
$db->uuid,
$db->destination->server->name,
];
})->toArray()
);
$this->newLine();
}
if ($resources['services']->isNotEmpty()) {
$this->warn("Services to be deleted ({$resources['services']->count()}):");
$this->table(
['Name', 'UUID', 'Server'],
$resources['services']->map(function ($service) {
return [
$service->name,
$service->uuid,
$service->server->name,
];
})->toArray()
);
$this->newLine();
}
$this->error('⚠️ THIS ACTION CANNOT BE UNDONE!');
if (! $this->confirm('Are you sure you want to delete all these resources?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Deleting resources...');
$result = $action->execute();
$this->info("Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services");
$this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services");
}
return true;
}
private function deleteServers(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 3: DELETE SERVERS');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new DeleteUserServers($this->user, $this->isDryRun);
$servers = $action->getServersPreview();
if ($servers->isEmpty()) {
$this->info('No servers to delete.');
return true;
}
$this->warn("Servers to be deleted ({$servers->count()}):");
$this->table(
['ID', 'Name', 'IP', 'Description', 'Resources Count'],
$servers->map(function ($server) {
$resourceCount = $server->definedResources()->count();
return [
$server->id,
$server->name,
$server->ip,
$server->description ?? '-',
$resourceCount,
];
})->toArray()
);
$this->newLine();
$this->error('⚠️ WARNING: Deleting servers will remove all server configurations!');
if (! $this->confirm('Are you sure you want to delete all these servers?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Deleting servers...');
$result = $action->execute();
$this->info("Deleted {$result['servers']} servers");
$this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}");
}
return true;
}
private function handleTeams(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 4: HANDLE TEAMS');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new DeleteUserTeams($this->user, $this->isDryRun);
$preview = $action->getTeamsPreview();
// Check for edge cases first - EXIT IMMEDIATELY if found
if ($preview['edge_cases']->isNotEmpty()) {
$this->error('═══════════════════════════════════════');
$this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED');
$this->error('═══════════════════════════════════════');
$this->newLine();
foreach ($preview['edge_cases'] as $edgeCase) {
$team = $edgeCase['team'];
$reason = $edgeCase['reason'];
$this->error("Team: {$team->name} (ID: {$team->id})");
$this->error("Issue: {$reason}");
// Show team members for context
$this->info('Current members:');
foreach ($team->members as $member) {
$role = $member->pivot->role;
$this->line(" - {$member->name} ({$member->email}) - Role: {$role}");
}
// Check for active resources
$resourceCount = 0;
foreach ($team->servers as $server) {
$resources = $server->definedResources();
$resourceCount += $resources->count();
}
if ($resourceCount > 0) {
$this->warn(" ⚠️ This team has {$resourceCount} active resources!");
}
// Show subscription details if relevant
if ($team->subscription && $team->subscription->stripe_subscription_id) {
$this->warn(' ⚠️ Active Stripe subscription details:');
$this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}");
$this->warn(" Customer ID: {$team->subscription->stripe_customer_id}");
// Show other owners who could potentially take over
$otherOwners = $team->members
->where('id', '!=', $this->user->id)
->filter(function ($member) {
return $member->pivot->role === 'owner';
});
if ($otherOwners->isNotEmpty()) {
$this->info(' Other owners who could take over billing:');
foreach ($otherOwners as $owner) {
$this->line(" - {$owner->name} ({$owner->email})");
}
}
}
$this->newLine();
}
$this->error('Please resolve these issues manually before retrying:');
// Check if any edge case involves subscription payment issues
$hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) {
return str_contains($edgeCase['reason'], 'Stripe subscription');
});
if ($hasSubscriptionIssue) {
$this->info('For teams with subscription payment issues:');
$this->info('1. Cancel the subscription through Stripe dashboard, OR');
$this->info('2. Transfer the subscription to another owner\'s payment method, OR');
$this->info('3. Have the other owner create a new subscription after cancelling this one');
$this->newLine();
}
$hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) {
return str_contains($edgeCase['reason'], 'No suitable owner replacement');
});
if ($hasNoOwnerReplacement) {
$this->info('For teams with no suitable owner replacement:');
$this->info('1. Assign an admin role to a trusted member, OR');
$this->info('2. Transfer team resources to another team, OR');
$this->info('3. Delete the team manually if no longer needed');
$this->newLine();
}
$this->error('USER DELETION ABORTED DUE TO EDGE CASES');
$this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling");
// Exit immediately - don't proceed with deletion
if (! $this->isDryRun) {
DB::rollBack();
}
exit(1);
}
if ($preview['to_delete']->isEmpty() &&
$preview['to_transfer']->isEmpty() &&
$preview['to_leave']->isEmpty()) {
$this->info('No team changes needed.');
return true;
}
if ($preview['to_delete']->isNotEmpty()) {
$this->warn('Teams to be DELETED (user is the only member):');
$this->table(
['ID', 'Name', 'Resources', 'Subscription'],
$preview['to_delete']->map(function ($team) {
$resourceCount = 0;
foreach ($team->servers as $server) {
$resourceCount += $server->definedResources()->count();
}
$hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id
? '⚠️ YES - '.$team->subscription->stripe_subscription_id
: 'No';
return [
$team->id,
$team->name,
$resourceCount,
$hasSubscription,
];
})->toArray()
);
$this->newLine();
}
if ($preview['to_transfer']->isNotEmpty()) {
$this->warn('Teams where ownership will be TRANSFERRED:');
$this->table(
['Team ID', 'Team Name', 'New Owner', 'New Owner Email'],
$preview['to_transfer']->map(function ($item) {
return [
$item['team']->id,
$item['team']->name,
$item['new_owner']->name,
$item['new_owner']->email,
];
})->toArray()
);
$this->newLine();
}
if ($preview['to_leave']->isNotEmpty()) {
$this->warn('Teams where user will be REMOVED (other owners/admins exist):');
$userId = $this->user->id;
$this->table(
['ID', 'Name', 'User Role', 'Other Members'],
$preview['to_leave']->map(function ($team) use ($userId) {
$userRole = $team->members->where('id', $userId)->first()->pivot->role;
$otherMembers = $team->members->count() - 1;
return [
$team->id,
$team->name,
$userRole,
$otherMembers,
];
})->toArray()
);
$this->newLine();
}
$this->error('⚠️ WARNING: Team changes affect access control and ownership!');
if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Processing team changes...');
$result = $action->execute();
$this->info("Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}");
$this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}");
}
return true;
}
private function cancelStripeSubscriptions(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 5: CANCEL STRIPE SUBSCRIPTIONS');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new CancelSubscription($this->user, $this->isDryRun);
$subscriptions = $action->getSubscriptionsPreview();
if ($subscriptions->isEmpty()) {
$this->info('No Stripe subscriptions to cancel.');
return true;
}
$this->info('Stripe subscriptions to cancel:');
$this->newLine();
$totalMonthlyValue = 0;
foreach ($subscriptions as $subscription) {
$team = $subscription->team;
$planId = $subscription->stripe_plan_id;
// Try to get the price from config
$monthlyValue = $this->getSubscriptionMonthlyValue($planId);
$totalMonthlyValue += $monthlyValue;
$this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})");
if ($monthlyValue > 0) {
$this->line(" Monthly value: \${$monthlyValue}");
}
if ($subscription->stripe_cancel_at_period_end) {
$this->line(' ⚠️ Already set to cancel at period end');
}
}
if ($totalMonthlyValue > 0) {
$this->newLine();
$this->warn("Total monthly value: \${$totalMonthlyValue}");
}
$this->newLine();
$this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!');
if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Cancelling subscriptions...');
$result = $action->execute();
$this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed");
if ($result['failed'] > 0 && ! empty($result['errors'])) {
$this->error('Failed subscriptions:');
foreach ($result['errors'] as $error) {
$this->error(" - {$error}");
}
}
$this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}");
}
return true;
}
private function deleteUserProfile(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 6: DELETE USER PROFILE');
$this->info('═══════════════════════════════════════');
$this->newLine();
$this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!');
$this->newLine();
$this->info('User profile to be deleted:');
$this->table(
['Property', 'Value'],
[
['Email', $this->user->email],
['Name', $this->user->name],
['User ID', $this->user->id],
['Created', $this->user->created_at->format('Y-m-d H:i:s')],
['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'],
['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'],
]
);
$this->newLine();
$this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:");
$confirmation = $this->ask('Confirmation');
if ($confirmation !== "DELETE {$this->user->email}") {
$this->error('Confirmation text does not match. Deletion cancelled.');
return false;
}
if (! $this->isDryRun) {
$this->info('Deleting user profile...');
try {
$this->user->delete();
$this->info('User profile deleted successfully.');
$this->logAction("User profile deleted: {$this->user->email}");
} catch (\Exception $e) {
$this->error('Failed to delete user profile: '.$e->getMessage());
$this->logAction("Failed to delete user profile {$this->user->email}: ".$e->getMessage());
return false;
}
}
return true;
}
private function getSubscriptionMonthlyValue(string $planId): int
{
// Map plan IDs to monthly values based on config
$subscriptionConfigs = config('subscription');
foreach ($subscriptionConfigs as $key => $value) {
if ($value === $planId && str_contains($key, 'stripe_price_id_')) {
// Extract price from key pattern: stripe_price_id_basic_monthly -> basic
$planType = str($key)->after('stripe_price_id_')->before('_')->toString();
// Map to known prices (you may need to adjust these based on your actual pricing)
return match ($planType) {
'basic' => 29,
'pro' => 49,
'ultimate' => 99,
default => 0
};
}
}
return 0;
}
private function logAction(string $message): void
{
$logMessage = "[CloudDeleteUser] {$message}";
if ($this->isDryRun) {
$logMessage = "[DRY RUN] {$logMessage}";
}
Log::channel('single')->info($logMessage);
// Also log to a dedicated user deletion log file
$logFile = storage_path('logs/user-deletions.log');
$timestamp = now()->format('Y-m-d H:i:s');
file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Jobs\CheckHelperImageJob;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
@@ -44,5 +45,6 @@ class Dev extends Command
} else { } else {
echo "Instance already initialized.\n"; echo "Instance already initialized.\n";
} }
CheckHelperImageJob::dispatch();
} }
} }

View File

@@ -16,7 +16,7 @@ class Services extends Command
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $description = 'Generate service-templates.yaml based on /templates/compose directory'; protected $description = 'Generates service-templates json file based on /templates/compose directory';
public function handle(): int public function handle(): int
{ {
@@ -33,7 +33,10 @@ class Services extends Command
]; ];
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); })->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesJson.PHP_EOL); file_put_contents(base_path('templates/'.config('constants.services.file_name')), $serviceTemplatesJson.PHP_EOL);
// Generate service-templates.json with SERVICE_URL changed to SERVICE_FQDN
$this->generateServiceTemplatesWithFqdn();
return self::SUCCESS; return self::SUCCESS;
} }
@@ -71,6 +74,7 @@ class Services extends Command
'slogan' => $data->get('slogan', str($file)->headline()), 'slogan' => $data->get('slogan', str($file)->headline()),
'compose' => $compose, 'compose' => $compose,
'tags' => $tags, 'tags' => $tags,
'category' => $data->get('category'),
'logo' => $data->get('logo', 'svgs/default.webp'), 'logo' => $data->get('logo', 'svgs/default.webp'),
'minversion' => $data->get('minversion', '0.0.0'), 'minversion' => $data->get('minversion', '0.0.0'),
]; ];
@@ -86,4 +90,145 @@ class Services extends Command
return $payload; return $payload;
} }
private function generateServiceTemplatesWithFqdn(): void
{
$serviceTemplatesWithFqdn = collect(array_merge(
glob(base_path('templates/compose/*.yaml')),
glob(base_path('templates/compose/*.yml'))
))
->mapWithKeys(function ($file): array {
$file = basename($file);
$parsed = $this->processFileWithFqdn($file);
return $parsed === false ? [] : [
Arr::pull($parsed, 'name') => $parsed,
];
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesWithFqdn.PHP_EOL);
// Generate service-templates-raw.json with non-base64 encoded compose content
// $this->generateServiceTemplatesRaw();
}
private function processFileWithFqdn(string $file): false|array
{
$content = file_get_contents(base_path("templates/compose/$file"));
$data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array {
preg_match('/^#(?<key>.*):(?<value>.*)$/U', $line, $m);
return $m ? [trim($m['key']) => trim($m['value'])] : [];
});
if (str($data->get('ignore'))->toBoolean()) {
return false;
}
$documentation = $data->get('documentation');
$documentation = $documentation ? $documentation.'?utm_source=coolify.io' : 'https://coolify.io/docs';
// Replace SERVICE_URL with SERVICE_FQDN in the content
$modifiedContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $content);
$json = Yaml::parse($modifiedContent);
$compose = base64_encode(Yaml::dump($json, 10, 2));
$tags = str($data->get('tags'))->lower()->explode(',')->map(fn ($tag) => trim($tag))->filter();
$tags = $tags->isEmpty() ? null : $tags->all();
$payload = [
'name' => pathinfo($file, PATHINFO_FILENAME),
'documentation' => $documentation,
'slogan' => $data->get('slogan', str($file)->headline()),
'compose' => $compose,
'tags' => $tags,
'category' => $data->get('category'),
'logo' => $data->get('logo', 'svgs/default.webp'),
'minversion' => $data->get('minversion', '0.0.0'),
];
if ($port = $data->get('port')) {
$payload['port'] = $port;
}
if ($envFile = $data->get('env_file')) {
$envFileContent = file_get_contents(base_path("templates/compose/$envFile"));
// Also replace SERVICE_URL with SERVICE_FQDN in env file content
$modifiedEnvContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $envFileContent);
$payload['envs'] = base64_encode($modifiedEnvContent);
}
return $payload;
}
private function generateServiceTemplatesRaw(): void
{
$serviceTemplatesRaw = collect(array_merge(
glob(base_path('templates/compose/*.yaml')),
glob(base_path('templates/compose/*.yml'))
))
->mapWithKeys(function ($file): array {
$file = basename($file);
$parsed = $this->processFileWithFqdnRaw($file);
return $parsed === false ? [] : [
Arr::pull($parsed, 'name') => $parsed,
];
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
file_put_contents(base_path('templates/service-templates-raw.json'), $serviceTemplatesRaw.PHP_EOL);
}
private function processFileWithFqdnRaw(string $file): false|array
{
$content = file_get_contents(base_path("templates/compose/$file"));
$data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array {
preg_match('/^#(?<key>.*):(?<value>.*)$/U', $line, $m);
return $m ? [trim($m['key']) => trim($m['value'])] : [];
});
if (str($data->get('ignore'))->toBoolean()) {
return false;
}
$documentation = $data->get('documentation');
$documentation = $documentation ? $documentation.'?utm_source=coolify.io' : 'https://coolify.io/docs';
// Replace SERVICE_URL with SERVICE_FQDN in the content
$modifiedContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $content);
$json = Yaml::parse($modifiedContent);
$compose = Yaml::dump($json, 10, 2); // Not base64 encoded
$tags = str($data->get('tags'))->lower()->explode(',')->map(fn ($tag) => trim($tag))->filter();
$tags = $tags->isEmpty() ? null : $tags->all();
$payload = [
'name' => pathinfo($file, PATHINFO_FILENAME),
'documentation' => $documentation,
'slogan' => $data->get('slogan', str($file)->headline()),
'compose' => $compose,
'tags' => $tags,
'category' => $data->get('category'),
'logo' => $data->get('logo', 'svgs/default.webp'),
'minversion' => $data->get('minversion', '0.0.0'),
];
if ($port = $data->get('port')) {
$payload['port'] = $port;
}
if ($envFile = $data->get('env_file')) {
$envFileContent = file_get_contents(base_path("templates/compose/$envFile"));
// Also replace SERVICE_URL with SERVICE_FQDN in env file content (not base64 encoded)
$modifiedEnvContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $envFileContent);
$payload['envs'] = $modifiedEnvContent;
}
return $payload;
}
} }

View File

@@ -5,8 +5,10 @@ namespace App\Console\Commands;
use App\Enums\ActivityTypes; use App\Enums\ActivityTypes;
use App\Enums\ApplicationDeploymentStatus; use App\Enums\ApplicationDeploymentStatus;
use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckHelperImageJob;
use App\Jobs\PullChangelog;
use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment; use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use App\Models\Server; use App\Models\Server;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
@@ -18,27 +20,49 @@ use Illuminate\Support\Facades\Http;
class Init extends Command class Init extends Command
{ {
protected $signature = 'app:init {--force-cloud}'; protected $signature = 'app:init';
protected $description = 'Cleanup instance related stuffs'; protected $description = 'Cleanup instance related stuffs';
public $servers = null; public $servers = null;
public InstanceSettings $settings;
public function handle() public function handle()
{ {
$this->optimize(); Artisan::call('optimize:clear');
Artisan::call('optimize');
if (isCloud() && ! $this->option('force-cloud')) { try {
echo "Skipping init as we are on cloud and --force-cloud option is not set\n"; $this->pullTemplatesFromCDN();
} catch (\Throwable $e) {
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
}
try {
$this->pullChangelogFromGitHub();
} catch (\Throwable $e) {
echo "Could not changelogs from github: {$e->getMessage()}\n";
}
try {
$this->pullHelperImage();
} catch (\Throwable $e) {
echo "Error in pullHelperImage command: {$e->getMessage()}\n";
}
if (isCloud()) {
return; return;
} }
$this->settings = instanceSettings();
$this->servers = Server::all(); $this->servers = Server::all();
if (! isCloud()) {
$do_not_track = data_get($this->settings, 'do_not_track', true);
if ($do_not_track == false) {
$this->sendAliveSignal(); $this->sendAliveSignal();
get_public_ips();
} }
get_public_ips();
// Backward compatibility // Backward compatibility
$this->replaceSlashInEnvironmentName(); $this->replaceSlashInEnvironmentName();
@@ -46,51 +70,54 @@ class Init extends Command
$this->updateUserEmails(); $this->updateUserEmails();
// //
$this->updateTraefikLabels(); $this->updateTraefikLabels();
if (! isCloud() || $this->option('force-cloud')) { $this->cleanupUnusedNetworkFromCoolifyProxy();
$this->cleanupUnusedNetworkFromCoolifyProxy();
}
$this->call('cleanup:redis');
$this->call('cleanup:stucked-resources');
try { try {
$this->pullHelperImage(); $this->call('cleanup:redis');
} catch (\Throwable $e) { } catch (\Throwable $e) {
// echo "Error in cleanup:redis command: {$e->getMessage()}\n";
} }
try {
$this->call('cleanup:names');
} catch (\Throwable $e) {
echo "Error in cleanup:names command: {$e->getMessage()}\n";
}
try {
$this->call('cleanup:stucked-resources');
} catch (\Throwable $e) {
echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n";
}
try {
$updatedCount = ApplicationDeploymentQueue::whereIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
])->update([
'status' => ApplicationDeploymentStatus::FAILED->value,
]);
if (isCloud()) { if ($updatedCount > 0) {
try { echo "Marked {$updatedCount} stuck deployments as failed\n";
$this->cleanupUnnecessaryDynamicProxyConfiguration();
$this->pullTemplatesFromCDN();
} catch (\Throwable $e) {
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
} }
return;
}
try {
$this->cleanupInProgressApplicationDeployments();
$this->pullTemplatesFromCDN();
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Could not pull templates from CDN: {$e->getMessage()}\n"; echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
} }
try { try {
$localhost = $this->servers->where('id', 0)->first(); $localhost = $this->servers->where('id', 0)->first();
$localhost->setupDynamicProxyConfiguration(); if ($localhost) {
$localhost->setupDynamicProxyConfiguration();
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Could not setup dynamic configuration: {$e->getMessage()}\n"; echo "Could not setup dynamic configuration: {$e->getMessage()}\n";
} }
$settings = instanceSettings();
if (! is_null(config('constants.coolify.autoupdate', null))) { if (! is_null(config('constants.coolify.autoupdate', null))) {
if (config('constants.coolify.autoupdate') == true) { if (config('constants.coolify.autoupdate') == true) {
echo "Enabling auto-update\n"; echo "Enabling auto-update\n";
$settings->update(['is_auto_update_enabled' => true]); $this->settings->update(['is_auto_update_enabled' => true]);
} else { } else {
echo "Disabling auto-update\n"; echo "Disabling auto-update\n";
$settings->update(['is_auto_update_enabled' => false]); $this->settings->update(['is_auto_update_enabled' => false]);
} }
} }
} }
@@ -105,21 +132,25 @@ class Init extends Command
$response = Http::retry(3, 1000)->get(config('constants.services.official')); $response = Http::retry(3, 1000)->get(config('constants.services.official'));
if ($response->successful()) { if ($response->successful()) {
$services = $response->json(); $services = $response->json();
File::put(base_path('templates/service-templates.json'), json_encode($services)); File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
} }
} }
private function optimize() private function pullChangelogFromGitHub()
{ {
Artisan::call('optimize:clear'); try {
Artisan::call('optimize'); PullChangelog::dispatch();
echo "Changelog fetch initiated\n";
} catch (\Throwable $e) {
echo "Could not fetch changelog from GitHub: {$e->getMessage()}\n";
}
} }
private function updateUserEmails() private function updateUserEmails()
{ {
try { try {
User::whereRaw('email ~ \'[A-Z]\'')->get()->each(function (User $user) { User::whereRaw('email ~ \'[A-Z]\'')->get()->each(function (User $user) {
$user->update(['email' => strtolower($user->email)]); $user->update(['email' => $user->email]);
}); });
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Error in updating user emails: {$e->getMessage()}\n"; echo "Error in updating user emails: {$e->getMessage()}\n";
@@ -135,27 +166,6 @@ class Init extends Command
} }
} }
private function cleanupUnnecessaryDynamicProxyConfiguration()
{
foreach ($this->servers as $server) {
try {
if (! $server->isFunctional()) {
continue;
}
if ($server->id === 0) {
continue;
}
$file = $server->proxyPath().'/dynamic/coolify.yaml';
return instant_remote_process([
"rm -f $file",
], $server, false);
} catch (\Throwable $e) {
echo "Error in cleaning up unnecessary dynamic proxy configuration: {$e->getMessage()}\n";
}
}
}
private function cleanupUnusedNetworkFromCoolifyProxy() private function cleanupUnusedNetworkFromCoolifyProxy()
{ {
foreach ($this->servers as $server) { foreach ($this->servers as $server) {
@@ -225,13 +235,6 @@ class Init extends Command
{ {
$id = config('app.id'); $id = config('app.id');
$version = config('constants.coolify.version'); $version = config('constants.coolify.version');
$settings = instanceSettings();
$do_not_track = data_get($settings, 'do_not_track');
if ($do_not_track == true) {
echo "Do_not_track is enabled\n";
return;
}
try { try {
Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version"); Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version");
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -239,23 +242,6 @@ class Init extends Command
} }
} }
private function cleanupInProgressApplicationDeployments()
{
// Cleanup any failed deployments
try {
if (isCloud()) {
return;
}
$queued_inprogress_deployments = ApplicationDeploymentQueue::whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value])->get();
foreach ($queued_inprogress_deployments as $deployment) {
$deployment->status = ApplicationDeploymentStatus::FAILED->value;
$deployment->save();
}
} catch (\Throwable $e) {
echo "Error: {$e->getMessage()}\n";
}
}
private function replaceSlashInEnvironmentName() private function replaceSlashInEnvironmentName()
{ {
if (version_compare('4.0.0-beta.298', config('constants.coolify.version'), '<=')) { if (version_compare('4.0.0-beta.298', config('constants.coolify.version'), '<=')) {

View File

@@ -6,7 +6,14 @@ use App\Jobs\DeleteResourceJob;
use App\Models\Application; use App\Models\Application;
use App\Models\Server; use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use function Laravel\Prompts\confirm; use function Laravel\Prompts\confirm;
@@ -103,19 +110,79 @@ class ServicesDelete extends Command
private function deleteDatabase() private function deleteDatabase()
{ {
$databases = StandalonePostgresql::all(); // Collect all databases from all types with unique identifiers
if ($databases->count() === 0) { $allDatabases = collect();
$databaseOptions = collect();
// Add PostgreSQL databases
foreach (StandalonePostgresql::all() as $db) {
$key = "postgresql_{$db->id}";
$allDatabases->put($key, $db);
$databaseOptions->put($key, "{$db->name} (PostgreSQL)");
}
// Add MySQL databases
foreach (StandaloneMysql::all() as $db) {
$key = "mysql_{$db->id}";
$allDatabases->put($key, $db);
$databaseOptions->put($key, "{$db->name} (MySQL)");
}
// Add MariaDB databases
foreach (StandaloneMariadb::all() as $db) {
$key = "mariadb_{$db->id}";
$allDatabases->put($key, $db);
$databaseOptions->put($key, "{$db->name} (MariaDB)");
}
// Add MongoDB databases
foreach (StandaloneMongodb::all() as $db) {
$key = "mongodb_{$db->id}";
$allDatabases->put($key, $db);
$databaseOptions->put($key, "{$db->name} (MongoDB)");
}
// Add Redis databases
foreach (StandaloneRedis::all() as $db) {
$key = "redis_{$db->id}";
$allDatabases->put($key, $db);
$databaseOptions->put($key, "{$db->name} (Redis)");
}
// Add KeyDB databases
foreach (StandaloneKeydb::all() as $db) {
$key = "keydb_{$db->id}";
$allDatabases->put($key, $db);
$databaseOptions->put($key, "{$db->name} (KeyDB)");
}
// Add Dragonfly databases
foreach (StandaloneDragonfly::all() as $db) {
$key = "dragonfly_{$db->id}";
$allDatabases->put($key, $db);
$databaseOptions->put($key, "{$db->name} (Dragonfly)");
}
// Add ClickHouse databases
foreach (StandaloneClickhouse::all() as $db) {
$key = "clickhouse_{$db->id}";
$allDatabases->put($key, $db);
$databaseOptions->put($key, "{$db->name} (ClickHouse)");
}
if ($allDatabases->count() === 0) {
$this->error('There are no databases to delete.'); $this->error('There are no databases to delete.');
return; return;
} }
$databasesToDelete = multiselect( $databasesToDelete = multiselect(
'What database do you want to delete?', 'What database do you want to delete?',
$databases->pluck('name', 'id')->sortKeys(), $databaseOptions->sortKeys(),
); );
foreach ($databasesToDelete as $database) { foreach ($databasesToDelete as $databaseKey) {
$toDelete = $databases->where('id', $database)->first(); $toDelete = $allDatabases->get($databaseKey);
if ($toDelete) { if ($toDelete) {
$this->info($toDelete); $this->info($toDelete);
$confirmed = confirm('Are you sure you want to delete all selected resources?'); $confirmed = confirm('Are you sure you want to delete all selected resources?');

View File

@@ -16,7 +16,7 @@ class SyncBunny extends Command
* *
* @var string * @var string
*/ */
protected $signature = 'sync:bunny {--templates} {--release} {--nightly}'; protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}';
/** /**
* The console command description. * The console command description.
@@ -25,6 +25,50 @@ class SyncBunny extends Command
*/ */
protected $description = 'Sync files to BunnyCDN'; protected $description = 'Sync files to BunnyCDN';
/**
* Fetch GitHub releases and sync to CDN
*/
private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn)
{
$this->info('Fetching releases from GitHub...');
try {
$response = Http::timeout(30)
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
'per_page' => 30, // Fetch more releases for better changelog
]);
if ($response->successful()) {
$releases = $response->json();
// Save releases to a temporary file
$releases_file = "$parent_dir/releases.json";
file_put_contents($releases_file, json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
// Upload to CDN
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $releases_file)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/releases.json"),
$pool->purge("$bunny_cdn/coolify/releases.json"),
]);
// Clean up temporary file
unlink($releases_file);
$this->info('releases.json uploaded & purged...');
$this->info('Total releases synced: '.count($releases));
return true;
} else {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
} catch (\Throwable $e) {
$this->error('Error fetching releases: '.$e->getMessage());
return false;
}
}
/** /**
* Execute the console command. * Execute the console command.
*/ */
@@ -33,6 +77,7 @@ class SyncBunny extends Command
$that = $this; $that = $this;
$only_template = $this->option('templates'); $only_template = $this->option('templates');
$only_version = $this->option('release'); $only_version = $this->option('release');
$only_github_releases = $this->option('github-releases');
$nightly = $this->option('nightly'); $nightly = $this->option('nightly');
$bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn = 'https://cdn.coollabs.io';
$bunny_cdn_path = 'coolify'; $bunny_cdn_path = 'coolify';
@@ -45,7 +90,7 @@ class SyncBunny extends Command
$install_script = 'install.sh'; $install_script = 'install.sh';
$upgrade_script = 'upgrade.sh'; $upgrade_script = 'upgrade.sh';
$production_env = '.env.production'; $production_env = '.env.production';
$service_template = 'service-templates.json'; $service_template = config('constants.services.file_name');
$versions = 'versions.json'; $versions = 'versions.json';
$compose_file_location = "$parent_dir/$compose_file"; $compose_file_location = "$parent_dir/$compose_file";
@@ -90,7 +135,7 @@ class SyncBunny extends Command
$install_script_location = "$parent_dir/other/nightly/$install_script"; $install_script_location = "$parent_dir/other/nightly/$install_script";
$versions_location = "$parent_dir/other/nightly/$versions"; $versions_location = "$parent_dir/other/nightly/$versions";
} }
if (! $only_template && ! $only_version) { if (! $only_template && ! $only_version && ! $only_github_releases) {
if ($nightly) { if ($nightly) {
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.'); $this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
} else { } else {
@@ -102,7 +147,7 @@ class SyncBunny extends Command
} }
} }
if ($only_template) { if ($only_template) {
$this->info('About to sync service-templates.json to BunnyCDN.'); $this->info('About to sync '.config('constants.services.file_name').' to BunnyCDN.');
$confirmed = confirm('Are you sure you want to sync?'); $confirmed = confirm('Are you sure you want to sync?');
if (! $confirmed) { if (! $confirmed) {
return; return;
@@ -128,12 +173,29 @@ class SyncBunny extends Command
if (! $confirmed) { if (! $confirmed) {
return; return;
} }
// First sync GitHub releases
$this->info('Syncing GitHub releases first...');
$this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn);
// Then sync versions.json
Http::pool(fn (Pool $pool) => [ Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
]); ]);
$this->info('versions.json uploaded & purged...'); $this->info('versions.json uploaded & purged...');
return;
} elseif ($only_github_releases) {
$this->info('About to sync GitHub releases to BunnyCDN.');
$confirmed = confirm('Are you sure you want to sync GitHub releases?');
if (! $confirmed) {
return;
}
// Use the reusable function
$this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn);
return; return;
} }

View File

@@ -6,10 +6,11 @@ use App\Jobs\CheckAndStartSentinelJob;
use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckHelperImageJob;
use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\PullChangelog;
use App\Jobs\PullTemplatesFromCDN; use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob; use App\Jobs\RegenerateSslCertJob;
use App\Jobs\ScheduledJobManager; use App\Jobs\ScheduledJobManager;
use App\Jobs\ServerResourceManager; use App\Jobs\ServerManagerJob;
use App\Jobs\UpdateCoolifyJob; use App\Jobs\UpdateCoolifyJob;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Server; use App\Models\Server;
@@ -54,7 +55,7 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->job(new CheckHelperImageJob)->everyTenMinutes()->onOneServer(); $this->scheduleInstance->job(new CheckHelperImageJob)->everyTenMinutes()->onOneServer();
// Server Jobs // Server Jobs
$this->scheduleInstance->job(new ServerResourceManager)->everyMinute()->onOneServer(); $this->scheduleInstance->job(new ServerManagerJob)->everyMinute()->onOneServer();
// Scheduled Jobs (Backups & Tasks) // Scheduled Jobs (Backups & Tasks)
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer(); $this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
@@ -67,12 +68,13 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->onOneServer(); $this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->onOneServer();
$this->scheduleInstance->job(new PullTemplatesFromCDN)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer(); $this->scheduleInstance->job(new PullTemplatesFromCDN)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
$this->scheduleInstance->job(new PullChangelog)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
$this->scheduleInstance->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer(); $this->scheduleInstance->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer();
$this->scheduleUpdates(); $this->scheduleUpdates();
// Server Jobs // Server Jobs
$this->scheduleInstance->job(new ServerResourceManager)->everyMinute()->onOneServer(); $this->scheduleInstance->job(new ServerManagerJob)->everyMinute()->onOneServer();
$this->pullImages(); $this->pullImages();

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ApplicationConfigurationChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public ?int $teamId = null;
public function __construct($teamId = null)
{
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
$teamId = auth()->user()->currentTeam()->id;
}
$this->teamId = $teamId;
}
public function broadcastOn(): array
{
if (is_null($this->teamId)) {
return [];
}
return [
new PrivateChannel("team.{$this->teamId}"),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Events;
use App\Models\Server;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SentinelRestarted implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public ?int $teamId = null;
public ?string $version = null;
public string $serverUuid;
public function __construct(Server $server, ?string $version = null)
{
$this->teamId = $server->team_id;
$this->serverUuid = $server->uuid;
$this->version = $version;
}
public function broadcastOn(): array
{
if (is_null($this->teamId)) {
return [];
}
return [
new PrivateChannel("team.{$this->teamId}"),
];
}
}

View File

@@ -29,6 +29,7 @@ class Handler extends ExceptionHandler
*/ */
protected $dontReport = [ protected $dontReport = [
ProcessException::class, ProcessException::class,
NonReportableException::class,
]; ];
/** /**
@@ -53,6 +54,35 @@ class Handler extends ExceptionHandler
return redirect()->guest($exception->redirectTo($request) ?? route('login')); return redirect()->guest($exception->redirectTo($request) ?? route('login'));
} }
/**
* Render an exception into an HTTP response.
*/
public function render($request, Throwable $e)
{
// Handle authorization exceptions for API routes
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
if ($request->is('api/*') || $request->expectsJson()) {
// Get the custom message from the policy if available
$message = $e->getMessage();
// Clean up the message for API responses (remove HTML tags if present)
$message = strip_tags(str_replace('<br/>', ' ', $message));
// If no custom message, use a default one
if (empty($message) || $message === 'This action is unauthorized.') {
$message = 'You are not authorized to perform this action.';
}
return response()->json([
'message' => $message,
'error' => 'Unauthorized',
], 403);
}
}
return parent::render($request, $e);
}
/** /**
* Register the exception handling callbacks for the application. * Register the exception handling callbacks for the application.
*/ */
@@ -81,9 +111,14 @@ class Handler extends ExceptionHandler
); );
} }
); );
// Check for errors that should not be reported to Sentry
if (str($e->getMessage())->contains('No space left on device')) { if (str($e->getMessage())->contains('No space left on device')) {
// Log locally but don't send to Sentry
logger()->warning('Disk space error: '.$e->getMessage());
return; return;
} }
Integration::captureUnhandledException($e); Integration::captureUnhandledException($e);
}); });
} }

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* Exception that should not be reported to Sentry or other error tracking services.
* Use this for known, expected errors that don't require external tracking.
*/
class NonReportableException extends Exception
{
/**
* Create a new non-reportable exception instance.
*
* @param string $message
* @param int $code
*/
public function __construct($message = '', $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
/**
* Create from another exception, preserving its message and stack trace.
*/
public static function fromException(\Throwable $exception): static
{
return new static($exception->getMessage(), $exception->getCode(), $exception);
}
}

View File

@@ -4,7 +4,9 @@ namespace App\Helpers;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Server; use App\Models\Server;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
class SshMultiplexingHelper class SshMultiplexingHelper
@@ -30,6 +32,7 @@ class SshMultiplexingHelper
$sshConfig = self::serverSshConfiguration($server); $sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename']; $muxSocket = $sshConfig['muxFilename'];
// Check if connection exists
$checkCommand = "ssh -O check -o ControlPath=$muxSocket "; $checkCommand = "ssh -O check -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) { if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
@@ -41,6 +44,24 @@ class SshMultiplexingHelper
return self::establishNewMultiplexedConnection($server); return self::establishNewMultiplexedConnection($server);
} }
// Connection exists, ensure we have metadata for age tracking
if (self::getConnectionAge($server) === null) {
// Existing connection but no metadata, store current time as fallback
self::storeConnectionMetadata($server);
}
// Connection exists, check if it needs refresh due to age
if (self::isConnectionExpired($server)) {
return self::refreshMultiplexedConnection($server);
}
// Perform health check if enabled
if (config('constants.ssh.mux_health_check_enabled')) {
if (! self::isConnectionHealthy($server)) {
return self::refreshMultiplexedConnection($server);
}
}
return true; return true;
} }
@@ -65,6 +86,9 @@ class SshMultiplexingHelper
return false; return false;
} }
// Store connection metadata for tracking
self::storeConnectionMetadata($server);
return true; return true;
} }
@@ -79,6 +103,9 @@ class SshMultiplexingHelper
} }
$closeCommand .= "{$server->user}@{$server->ip}"; $closeCommand .= "{$server->user}@{$server->ip}";
Process::run($closeCommand); Process::run($closeCommand);
// Clear connection metadata from cache
self::clearConnectionMetadata($server);
} }
public static function generateScpCommand(Server $server, string $source, string $dest) public static function generateScpCommand(Server $server, string $source, string $dest)
@@ -94,8 +121,18 @@ class SshMultiplexingHelper
if ($server->isIpv6()) { if ($server->isIpv6()) {
$scp_command .= '-6 '; $scp_command .= '-6 ';
} }
if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) { if (self::isMultiplexingEnabled()) {
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; try {
if (self::ensureMultiplexedConnection($server)) {
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
}
} catch (\Exception $e) {
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
'server' => $server->name ?? $server->ip,
'error' => $e->getMessage(),
]);
// Continue without multiplexing
}
} }
if (data_get($server, 'settings.is_cloudflare_tunnel')) { if (data_get($server, 'settings.is_cloudflare_tunnel')) {
@@ -130,8 +167,16 @@ class SshMultiplexingHelper
$ssh_command = "timeout $timeout ssh "; $ssh_command = "timeout $timeout ssh ";
if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) { $multiplexingSuccessful = false;
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; if (self::isMultiplexingEnabled()) {
try {
$multiplexingSuccessful = self::ensureMultiplexedConnection($server);
if ($multiplexingSuccessful) {
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
}
} catch (\Exception $e) {
// Continue without multiplexing
}
} }
if (data_get($server, 'settings.is_cloudflare_tunnel')) { if (data_get($server, 'settings.is_cloudflare_tunnel')) {
@@ -186,4 +231,81 @@ class SshMultiplexingHelper
return $options; return $options;
} }
/**
* Check if the multiplexed connection is healthy by running a test command
*/
public static function isConnectionHealthy(Server $server): bool
{
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$healthCommand .= "{$server->user}@{$server->ip} 'echo \"health_check_ok\"'";
$process = Process::run($healthCommand);
$isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
return $isHealthy;
}
/**
* Check if the connection has exceeded its maximum age
*/
public static function isConnectionExpired(Server $server): bool
{
$connectionAge = self::getConnectionAge($server);
$maxAge = config('constants.ssh.mux_max_age');
return $connectionAge !== null && $connectionAge > $maxAge;
}
/**
* Get the age of the current connection in seconds
*/
public static function getConnectionAge(Server $server): ?int
{
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
$connectionTime = Cache::get($cacheKey);
if ($connectionTime === null) {
return null;
}
return time() - $connectionTime;
}
/**
* Refresh a multiplexed connection by closing and re-establishing it
*/
public static function refreshMultiplexedConnection(Server $server): bool
{
// Close existing connection
self::removeMuxFile($server);
// Establish new connection
return self::establishNewMultiplexedConnection($server);
}
/**
* Store connection metadata when a new connection is established
*/
private static function storeConnectionMetadata(Server $server): void
{
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time
}
/**
* Clear connection metadata from cache
*/
private static function clearConnectionMetadata(Server $server): void
{
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
Cache::forget($cacheKey);
}
} }

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Helpers;
use App\Traits\SshRetryable;
/**
* Helper class to use SshRetryable trait in non-class contexts
*/
class SshRetryHandler
{
use SshRetryable;
/**
* Static method to get a singleton instance
*/
public static function instance(): self
{
static $instance = null;
if ($instance === null) {
$instance = new self;
}
return $instance;
}
/**
* Convenience static method for retry execution
*/
public static function retry(callable $callback, array $context = [], bool $throwError = true)
{
return self::instance()->executeWithSshRetry($callback, $context, $throwError);
}
}

View File

@@ -15,6 +15,8 @@ use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
@@ -188,6 +190,7 @@ class ApplicationsController extends Controller
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
], ],
) )
), ),
@@ -215,6 +218,35 @@ class ApplicationsController extends Controller
response: 400, response: 400,
ref: '#/components/responses/400', ref: '#/components/responses/400',
), ),
new OA\Response(
response: 409,
description: 'Domain conflicts detected.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
'conflicts' => [
'type' => 'array',
'items' => new OA\Schema(
type: 'object',
properties: [
'domain' => ['type' => 'string', 'example' => 'example.com'],
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
]
),
],
]
)
),
]
),
] ]
)] )]
public function create_public_application(Request $request) public function create_public_application(Request $request)
@@ -308,6 +340,7 @@ class ApplicationsController extends Controller
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
], ],
) )
), ),
@@ -335,6 +368,35 @@ class ApplicationsController extends Controller
response: 400, response: 400,
ref: '#/components/responses/400', ref: '#/components/responses/400',
), ),
new OA\Response(
response: 409,
description: 'Domain conflicts detected.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
'conflicts' => [
'type' => 'array',
'items' => new OA\Schema(
type: 'object',
properties: [
'domain' => ['type' => 'string', 'example' => 'example.com'],
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
]
),
],
]
)
),
]
),
] ]
)] )]
public function create_private_gh_app_application(Request $request) public function create_private_gh_app_application(Request $request)
@@ -428,6 +490,7 @@ class ApplicationsController extends Controller
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
], ],
) )
), ),
@@ -455,6 +518,35 @@ class ApplicationsController extends Controller
response: 400, response: 400,
ref: '#/components/responses/400', ref: '#/components/responses/400',
), ),
new OA\Response(
response: 409,
description: 'Domain conflicts detected.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
'conflicts' => [
'type' => 'array',
'items' => new OA\Schema(
type: 'object',
properties: [
'domain' => ['type' => 'string', 'example' => 'example.com'],
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
]
),
],
]
)
),
]
),
] ]
)] )]
public function create_private_deploy_key_application(Request $request) public function create_private_deploy_key_application(Request $request)
@@ -532,6 +624,7 @@ class ApplicationsController extends Controller
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
], ],
) )
), ),
@@ -559,6 +652,35 @@ class ApplicationsController extends Controller
response: 400, response: 400,
ref: '#/components/responses/400', ref: '#/components/responses/400',
), ),
new OA\Response(
response: 409,
description: 'Domain conflicts detected.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
'conflicts' => [
'type' => 'array',
'items' => new OA\Schema(
type: 'object',
properties: [
'domain' => ['type' => 'string', 'example' => 'example.com'],
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
]
),
],
]
)
),
]
),
] ]
)] )]
public function create_dockerfile_application(Request $request) public function create_dockerfile_application(Request $request)
@@ -633,6 +755,7 @@ class ApplicationsController extends Controller
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
], ],
) )
), ),
@@ -660,6 +783,35 @@ class ApplicationsController extends Controller
response: 400, response: 400,
ref: '#/components/responses/400', ref: '#/components/responses/400',
), ),
new OA\Response(
response: 409,
description: 'Domain conflicts detected.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
'conflicts' => [
'type' => 'array',
'items' => new OA\Schema(
type: 'object',
properties: [
'domain' => ['type' => 'string', 'example' => 'example.com'],
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
]
),
],
]
)
),
]
),
] ]
)] )]
public function create_dockerimage_application(Request $request) public function create_dockerimage_application(Request $request)
@@ -697,6 +849,7 @@ class ApplicationsController extends Controller
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
], ],
) )
), ),
@@ -724,6 +877,35 @@ class ApplicationsController extends Controller
response: 400, response: 400,
ref: '#/components/responses/400', ref: '#/components/responses/400',
), ),
new OA\Response(
response: 409,
description: 'Domain conflicts detected.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
'conflicts' => [
'type' => 'array',
'items' => new OA\Schema(
type: 'object',
properties: [
'domain' => ['type' => 'string', 'example' => 'example.com'],
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
]
),
],
]
)
),
]
),
] ]
)] )]
public function create_dockercompose_application(Request $request) public function create_dockercompose_application(Request $request)
@@ -738,11 +920,13 @@ class ApplicationsController extends Controller
return invalidTokenResponse(); return invalidTokenResponse();
} }
$this->authorize('create', Application::class);
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return; return $return;
} }
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network']; $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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'name' => 'string|max:255', 'name' => 'string|max:255',
@@ -831,8 +1015,8 @@ class ApplicationsController extends Controller
$destination = $destinations->first(); $destination = $destinations->first();
if ($type === 'public') { if ($type === 'public') {
$validationRules = [ $validationRules = [
'git_repository' => 'string|required', 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => 'string|required', 'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'docker_compose_location' => 'string', 'docker_compose_location' => 'string',
@@ -883,7 +1067,7 @@ class ApplicationsController extends Controller
$application->source_type = GithubApp::class; $application->source_type = GithubApp::class;
$application->source_id = GithubApp::find(0)->id; $application->source_id = GithubApp::find(0)->id;
} }
$application->git_repository = $repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2); $application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString();
$application->fqdn = $fqdn; $application->fqdn = $fqdn;
$application->destination_id = $destination->id; $application->destination_id = $destination->id;
$application->destination_type = $destination->getMorphClass(); $application->destination_type = $destination->getMorphClass();
@@ -935,7 +1119,7 @@ class ApplicationsController extends Controller
} elseif ($type === 'private-gh-app') { } elseif ($type === 'private-gh-app') {
$validationRules = [ $validationRules = [
'git_repository' => 'string|required', 'git_repository' => 'string|required',
'git_branch' => 'string|required', 'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'github_app_uuid' => 'string|required', 'github_app_uuid' => 'string|required',
@@ -1043,7 +1227,7 @@ class ApplicationsController extends Controller
$application->docker_compose_domains = $dockerComposeDomainsJson; $application->docker_compose_domains = $dockerComposeDomainsJson;
} }
$application->fqdn = $fqdn; $application->fqdn = $fqdn;
$application->git_repository = $gitRepository; $application->git_repository = str($gitRepository)->trim()->toString();
$application->destination_id = $destination->id; $application->destination_id = $destination->id;
$application->destination_type = $destination->getMorphClass(); $application->destination_type = $destination->getMorphClass();
$application->environment_id = $environment->id; $application->environment_id = $environment->id;
@@ -1090,8 +1274,8 @@ class ApplicationsController extends Controller
} elseif ($type === 'private-deploy-key') { } elseif ($type === 'private-deploy-key') {
$validationRules = [ $validationRules = [
'git_repository' => 'string|required', 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => 'string|required', 'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'private_key_uuid' => 'string|required', 'private_key_uuid' => 'string|required',
@@ -1376,7 +1560,7 @@ class ApplicationsController extends Controller
'domains' => data_get($application, 'domains'), 'domains' => data_get($application, 'domains'),
]))->setStatusCode(201); ]))->setStatusCode(201);
} elseif ($type === 'dockercompose') { } elseif ($type === 'dockercompose') {
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw']; $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || ! empty($extraFields)) {
@@ -1519,6 +1703,8 @@ class ApplicationsController extends Controller
return response()->json(['message' => 'Application not found.'], 404); return response()->json(['message' => 'Application not found.'], 404);
} }
$this->authorize('view', $application);
return response()->json($this->removeSensitiveData($application)); return response()->json($this->removeSensitiveData($application));
} }
@@ -1697,12 +1883,14 @@ class ApplicationsController extends Controller
], 404); ], 404);
} }
$this->authorize('delete', $application);
DeleteResourceJob::dispatch( DeleteResourceJob::dispatch(
resource: $application, resource: $application,
deleteConfigurations: $request->query->get('delete_configurations', true),
deleteVolumes: $request->query->get('delete_volumes', true), deleteVolumes: $request->query->get('delete_volumes', true),
dockerCleanup: $request->query->get('docker_cleanup', true), deleteConnectedNetworks: $request->query->get('delete_connected_networks', true),
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) deleteConfigurations: $request->query->get('delete_configurations', true),
dockerCleanup: $request->query->get('docker_cleanup', true)
); );
return response()->json([ return response()->json([
@@ -1802,6 +1990,7 @@ class ApplicationsController extends Controller
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
], ],
) )
), ),
@@ -1835,6 +2024,35 @@ class ApplicationsController extends Controller
response: 404, response: 404,
ref: '#/components/responses/404', ref: '#/components/responses/404',
), ),
new OA\Response(
response: 409,
description: 'Domain conflicts detected.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
'conflicts' => [
'type' => 'array',
'items' => new OA\Schema(
type: 'object',
properties: [
'domain' => ['type' => 'string', 'example' => 'example.com'],
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
]
),
],
]
)
),
]
),
] ]
)] )]
public function update_by_uuid(Request $request) public function update_by_uuid(Request $request)
@@ -1854,8 +2072,11 @@ class ApplicationsController extends Controller
'message' => 'Application not found', 'message' => 'Application not found',
], 404); ], 404);
} }
$this->authorize('update', $application);
$server = $application->destination->server; $server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', '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', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network']; $allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', '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', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override'];
$validationRules = [ $validationRules = [
'name' => 'string|max:255', 'name' => 'string|max:255',
@@ -1971,14 +2192,23 @@ class ApplicationsController extends Controller
'errors' => $errors, 'errors' => $errors,
], 422); ], 422);
} }
if (checkIfDomainIsAlreadyUsed($fqdn, $teamId, $uuid)) { // Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($fqdn, $teamId, $uuid);
if (isset($result['error'])) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
'errors' => [ 'errors' => ['domains' => $result['error']],
'domains' => 'One of the domain is already used.',
],
], 422); ], 422);
} }
// If there are conflicts and force is not enabled, return warning
if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $result['conflicts'],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
} }
$dockerComposeDomainsJson = collect(); $dockerComposeDomainsJson = collect();
@@ -2054,6 +2284,9 @@ class ApplicationsController extends Controller
data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson)); data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson));
} }
$application->fill($data); $application->fill($data);
if ($application->settings->is_container_label_readonly_enabled && $requestHasDomains && $server->isProxyShouldRun()) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
}
$application->save(); $application->save();
if ($instantDeploy) { if ($instantDeploy) {
@@ -2138,6 +2371,9 @@ class ApplicationsController extends Controller
'message' => 'Application not found', 'message' => 'Application not found',
], 404); ], 404);
} }
$this->authorize('view', $application);
$envs = $application->environment_variables->sortBy('id')->merge($application->environment_variables_preview->sortBy('id')); $envs = $application->environment_variables->sortBy('id')->merge($application->environment_variables_preview->sortBy('id'));
$envs = $envs->map(function ($env) { $envs = $envs->map(function ($env) {
@@ -2193,7 +2429,6 @@ class ApplicationsController extends Controller
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@@ -2234,7 +2469,7 @@ class ApplicationsController extends Controller
)] )]
public function update_env_by_uuid(Request $request) public function update_env_by_uuid(Request $request)
{ {
$allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal']; $allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@@ -2252,11 +2487,13 @@ class ApplicationsController extends Controller
'message' => 'Application not found', 'message' => 'Application not found',
], 404); ], 404);
} }
$this->authorize('manageEnvironment', $application);
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'key' => 'string|required', 'key' => 'string|required',
'value' => 'string|nullable', 'value' => 'string|nullable',
'is_preview' => 'boolean', 'is_preview' => 'boolean',
'is_build_time' => 'boolean',
'is_literal' => 'boolean', 'is_literal' => 'boolean',
'is_multiline' => 'boolean', 'is_multiline' => 'boolean',
'is_shown_once' => 'boolean', 'is_shown_once' => 'boolean',
@@ -2277,16 +2514,12 @@ class ApplicationsController extends Controller
], 422); ], 422);
} }
$is_preview = $request->is_preview ?? false; $is_preview = $request->is_preview ?? false;
$is_build_time = $request->is_build_time ?? false;
$is_literal = $request->is_literal ?? false; $is_literal = $request->is_literal ?? false;
$key = str($request->key)->trim()->replace(' ', '_')->value; $key = str($request->key)->trim()->replace(' ', '_')->value;
if ($is_preview) { if ($is_preview) {
$env = $application->environment_variables_preview->where('key', $key)->first(); $env = $application->environment_variables_preview->where('key', $key)->first();
if ($env) { if ($env) {
$env->value = $request->value; $env->value = $request->value;
if ($env->is_build_time != $is_build_time) {
$env->is_build_time = $is_build_time;
}
if ($env->is_literal != $is_literal) { if ($env->is_literal != $is_literal) {
$env->is_literal = $is_literal; $env->is_literal = $is_literal;
} }
@@ -2299,6 +2532,12 @@ class ApplicationsController extends Controller
if ($env->is_shown_once != $request->is_shown_once) { if ($env->is_shown_once != $request->is_shown_once) {
$env->is_shown_once = $request->is_shown_once; $env->is_shown_once = $request->is_shown_once;
} }
if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) {
$env->is_runtime = $request->is_runtime;
}
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
$env->is_buildtime = $request->is_buildtime;
}
$env->save(); $env->save();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201); return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@@ -2311,9 +2550,6 @@ class ApplicationsController extends Controller
$env = $application->environment_variables->where('key', $key)->first(); $env = $application->environment_variables->where('key', $key)->first();
if ($env) { if ($env) {
$env->value = $request->value; $env->value = $request->value;
if ($env->is_build_time != $is_build_time) {
$env->is_build_time = $is_build_time;
}
if ($env->is_literal != $is_literal) { if ($env->is_literal != $is_literal) {
$env->is_literal = $is_literal; $env->is_literal = $is_literal;
} }
@@ -2326,6 +2562,12 @@ class ApplicationsController extends Controller
if ($env->is_shown_once != $request->is_shown_once) { if ($env->is_shown_once != $request->is_shown_once) {
$env->is_shown_once = $request->is_shown_once; $env->is_shown_once = $request->is_shown_once;
} }
if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) {
$env->is_runtime = $request->is_runtime;
}
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
$env->is_buildtime = $request->is_buildtime;
}
$env->save(); $env->save();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201); return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@@ -2380,7 +2622,6 @@ class ApplicationsController extends Controller
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@@ -2442,6 +2683,8 @@ class ApplicationsController extends Controller
], 404); ], 404);
} }
$this->authorize('manageEnvironment', $application);
$bulk_data = $request->get('data'); $bulk_data = $request->get('data');
if (! $bulk_data) { if (! $bulk_data) {
return response()->json([ return response()->json([
@@ -2449,7 +2692,7 @@ class ApplicationsController extends Controller
], 400); ], 400);
} }
$bulk_data = collect($bulk_data)->map(function ($item) { $bulk_data = collect($bulk_data)->map(function ($item) {
return collect($item)->only(['key', 'value', 'is_preview', 'is_build_time', 'is_literal']); return collect($item)->only(['key', 'value', 'is_preview', 'is_literal']);
}); });
$returnedEnvs = collect(); $returnedEnvs = collect();
foreach ($bulk_data as $item) { foreach ($bulk_data as $item) {
@@ -2457,7 +2700,6 @@ class ApplicationsController extends Controller
'key' => 'string|required', 'key' => 'string|required',
'value' => 'string|nullable', 'value' => 'string|nullable',
'is_preview' => 'boolean', 'is_preview' => 'boolean',
'is_build_time' => 'boolean',
'is_literal' => 'boolean', 'is_literal' => 'boolean',
'is_multiline' => 'boolean', 'is_multiline' => 'boolean',
'is_shown_once' => 'boolean', 'is_shown_once' => 'boolean',
@@ -2469,7 +2711,6 @@ class ApplicationsController extends Controller
], 422); ], 422);
} }
$is_preview = $item->get('is_preview') ?? false; $is_preview = $item->get('is_preview') ?? false;
$is_build_time = $item->get('is_build_time') ?? false;
$is_literal = $item->get('is_literal') ?? false; $is_literal = $item->get('is_literal') ?? false;
$is_multi_line = $item->get('is_multiline') ?? false; $is_multi_line = $item->get('is_multiline') ?? false;
$is_shown_once = $item->get('is_shown_once') ?? false; $is_shown_once = $item->get('is_shown_once') ?? false;
@@ -2478,9 +2719,7 @@ class ApplicationsController extends Controller
$env = $application->environment_variables_preview->where('key', $key)->first(); $env = $application->environment_variables_preview->where('key', $key)->first();
if ($env) { if ($env) {
$env->value = $item->get('value'); $env->value = $item->get('value');
if ($env->is_build_time != $is_build_time) {
$env->is_build_time = $is_build_time;
}
if ($env->is_literal != $is_literal) { if ($env->is_literal != $is_literal) {
$env->is_literal = $is_literal; $env->is_literal = $is_literal;
} }
@@ -2490,16 +2729,23 @@ class ApplicationsController extends Controller
if ($env->is_shown_once != $item->get('is_shown_once')) { if ($env->is_shown_once != $item->get('is_shown_once')) {
$env->is_shown_once = $item->get('is_shown_once'); $env->is_shown_once = $item->get('is_shown_once');
} }
if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) {
$env->is_runtime = $item->get('is_runtime');
}
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
$env->is_buildtime = $item->get('is_buildtime');
}
$env->save(); $env->save();
} else { } else {
$env = $application->environment_variables()->create([ $env = $application->environment_variables()->create([
'key' => $item->get('key'), 'key' => $item->get('key'),
'value' => $item->get('value'), 'value' => $item->get('value'),
'is_preview' => $is_preview, 'is_preview' => $is_preview,
'is_build_time' => $is_build_time,
'is_literal' => $is_literal, 'is_literal' => $is_literal,
'is_multiline' => $is_multi_line, 'is_multiline' => $is_multi_line,
'is_shown_once' => $is_shown_once, 'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'resourceable_type' => get_class($application), 'resourceable_type' => get_class($application),
'resourceable_id' => $application->id, 'resourceable_id' => $application->id,
]); ]);
@@ -2508,9 +2754,6 @@ class ApplicationsController extends Controller
$env = $application->environment_variables->where('key', $key)->first(); $env = $application->environment_variables->where('key', $key)->first();
if ($env) { if ($env) {
$env->value = $item->get('value'); $env->value = $item->get('value');
if ($env->is_build_time != $is_build_time) {
$env->is_build_time = $is_build_time;
}
if ($env->is_literal != $is_literal) { if ($env->is_literal != $is_literal) {
$env->is_literal = $is_literal; $env->is_literal = $is_literal;
} }
@@ -2520,16 +2763,23 @@ class ApplicationsController extends Controller
if ($env->is_shown_once != $item->get('is_shown_once')) { if ($env->is_shown_once != $item->get('is_shown_once')) {
$env->is_shown_once = $item->get('is_shown_once'); $env->is_shown_once = $item->get('is_shown_once');
} }
if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) {
$env->is_runtime = $item->get('is_runtime');
}
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
$env->is_buildtime = $item->get('is_buildtime');
}
$env->save(); $env->save();
} else { } else {
$env = $application->environment_variables()->create([ $env = $application->environment_variables()->create([
'key' => $item->get('key'), 'key' => $item->get('key'),
'value' => $item->get('value'), 'value' => $item->get('value'),
'is_preview' => $is_preview, 'is_preview' => $is_preview,
'is_build_time' => $is_build_time,
'is_literal' => $is_literal, 'is_literal' => $is_literal,
'is_multiline' => $is_multi_line, 'is_multiline' => $is_multi_line,
'is_shown_once' => $is_shown_once, 'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'resourceable_type' => get_class($application), 'resourceable_type' => get_class($application),
'resourceable_id' => $application->id, 'resourceable_id' => $application->id,
]); ]);
@@ -2573,7 +2823,6 @@ class ApplicationsController extends Controller
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@@ -2613,7 +2862,7 @@ class ApplicationsController extends Controller
)] )]
public function create_env(Request $request) public function create_env(Request $request)
{ {
$allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal']; $allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@@ -2626,11 +2875,13 @@ class ApplicationsController extends Controller
'message' => 'Application not found', 'message' => 'Application not found',
], 404); ], 404);
} }
$this->authorize('manageEnvironment', $application);
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'key' => 'string|required', 'key' => 'string|required',
'value' => 'string|nullable', 'value' => 'string|nullable',
'is_preview' => 'boolean', 'is_preview' => 'boolean',
'is_build_time' => 'boolean',
'is_literal' => 'boolean', 'is_literal' => 'boolean',
'is_multiline' => 'boolean', 'is_multiline' => 'boolean',
'is_shown_once' => 'boolean', 'is_shown_once' => 'boolean',
@@ -2664,10 +2915,11 @@ class ApplicationsController extends Controller
'key' => $request->key, 'key' => $request->key,
'value' => $request->value, 'value' => $request->value,
'is_preview' => $request->is_preview ?? false, 'is_preview' => $request->is_preview ?? false,
'is_build_time' => $request->is_build_time ?? false,
'is_literal' => $request->is_literal ?? false, 'is_literal' => $request->is_literal ?? false,
'is_multiline' => $request->is_multiline ?? false, 'is_multiline' => $request->is_multiline ?? false,
'is_shown_once' => $request->is_shown_once ?? false, 'is_shown_once' => $request->is_shown_once ?? false,
'is_runtime' => $request->is_runtime ?? true,
'is_buildtime' => $request->is_buildtime ?? true,
'resourceable_type' => get_class($application), 'resourceable_type' => get_class($application),
'resourceable_id' => $application->id, 'resourceable_id' => $application->id,
]); ]);
@@ -2687,10 +2939,11 @@ class ApplicationsController extends Controller
'key' => $request->key, 'key' => $request->key,
'value' => $request->value, 'value' => $request->value,
'is_preview' => $request->is_preview ?? false, 'is_preview' => $request->is_preview ?? false,
'is_build_time' => $request->is_build_time ?? false,
'is_literal' => $request->is_literal ?? false, 'is_literal' => $request->is_literal ?? false,
'is_multiline' => $request->is_multiline ?? false, 'is_multiline' => $request->is_multiline ?? false,
'is_shown_once' => $request->is_shown_once ?? false, 'is_shown_once' => $request->is_shown_once ?? false,
'is_runtime' => $request->is_runtime ?? true,
'is_buildtime' => $request->is_buildtime ?? true,
'resourceable_type' => get_class($application), 'resourceable_type' => get_class($application),
'resourceable_id' => $application->id, 'resourceable_id' => $application->id,
]); ]);
@@ -2776,6 +3029,9 @@ class ApplicationsController extends Controller
'message' => 'Application not found.', 'message' => 'Application not found.',
], 404); ], 404);
} }
$this->authorize('manageEnvironment', $application);
$found_env = EnvironmentVariable::where('uuid', $request->env_uuid) $found_env = EnvironmentVariable::where('uuid', $request->env_uuid)
->where('resourceable_type', Application::class) ->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id) ->where('resourceable_id', $application->id)
@@ -2879,6 +3135,8 @@ class ApplicationsController extends Controller
return response()->json(['message' => 'Application not found.'], 404); return response()->json(['message' => 'Application not found.'], 404);
} }
$this->authorize('deploy', $application);
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
$result = queue_application_deployment( $result = queue_application_deployment(
@@ -2971,6 +3229,9 @@ class ApplicationsController extends Controller
if (! $application) { if (! $application) {
return response()->json(['message' => 'Application not found.'], 404); return response()->json(['message' => 'Application not found.'], 404);
} }
$this->authorize('deploy', $application);
StopApplication::dispatch($application); StopApplication::dispatch($application);
return response()->json( return response()->json(
@@ -3048,6 +3309,8 @@ class ApplicationsController extends Controller
return response()->json(['message' => 'Application not found.'], 404); return response()->json(['message' => 'Application not found.'], 404);
} }
$this->authorize('deploy', $application);
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
$result = queue_application_deployment( $result = queue_application_deployment(
@@ -3070,131 +3333,6 @@ class ApplicationsController extends Controller
); );
} }
// #[OA\Post(
// summary: 'Execute Command',
// description: "Execute a command on the application's current container.",
// path: '/applications/{uuid}/execute',
// operationId: 'execute-command-application',
// security: [
// ['bearerAuth' => []],
// ],
// tags: ['Applications'],
// parameters: [
// new OA\Parameter(
// name: 'uuid',
// in: 'path',
// description: 'UUID of the application.',
// required: true,
// schema: new OA\Schema(
// type: 'string',
// format: 'uuid',
// )
// ),
// ],
// requestBody: new OA\RequestBody(
// required: true,
// description: 'Command to execute.',
// content: new OA\MediaType(
// mediaType: 'application/json',
// schema: new OA\Schema(
// type: 'object',
// properties: [
// 'command' => ['type' => 'string', 'description' => 'Command to execute.'],
// ],
// ),
// ),
// ),
// responses: [
// new OA\Response(
// response: 200,
// description: "Execute a command on the application's current container.",
// content: [
// new OA\MediaType(
// mediaType: 'application/json',
// schema: new OA\Schema(
// type: 'object',
// properties: [
// 'message' => ['type' => 'string', 'example' => 'Command executed.'],
// 'response' => ['type' => 'string'],
// ]
// )
// ),
// ]
// ),
// new OA\Response(
// response: 401,
// ref: '#/components/responses/401',
// ),
// new OA\Response(
// response: 400,
// ref: '#/components/responses/400',
// ),
// new OA\Response(
// response: 404,
// ref: '#/components/responses/404',
// ),
// ]
// )]
// public function execute_command_by_uuid(Request $request)
// {
// // TODO: Need to review this from security perspective, to not allow arbitrary command execution
// $allowedFields = ['command'];
// $teamId = getTeamIdFromToken();
// if (is_null($teamId)) {
// return invalidTokenResponse();
// }
// $uuid = $request->route('uuid');
// if (! $uuid) {
// return response()->json(['message' => 'UUID is required.'], 400);
// }
// $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
// if (! $application) {
// return response()->json(['message' => 'Application not found.'], 404);
// }
// $return = validateIncomingRequest($request);
// if ($return instanceof \Illuminate\Http\JsonResponse) {
// return $return;
// }
// $validator = customApiValidator($request->all(), [
// 'command' => 'string|required',
// ]);
// $extraFields = array_diff(array_keys($request->all()), $allowedFields);
// if ($validator->fails() || ! empty($extraFields)) {
// $errors = $validator->errors();
// if (! empty($extraFields)) {
// foreach ($extraFields as $field) {
// $errors->add($field, 'This field is not allowed.');
// }
// }
// return response()->json([
// 'message' => 'Validation failed.',
// 'errors' => $errors,
// ], 422);
// }
// $container = getCurrentApplicationContainerStatus($application->destination->server, $application->id)->firstOrFail();
// $status = getContainerStatus($application->destination->server, $container['Names']);
// if ($status !== 'running') {
// return response()->json([
// 'message' => 'Application is not running.',
// ], 400);
// }
// $commands = collect([
// executeInDocker($container['Names'], $request->command),
// ]);
// $res = instant_remote_process(command: $commands, server: $application->destination->server);
// return response()->json([
// 'message' => 'Command executed.',
// 'response' => $res,
// ]);
// }
private function validateDataApplications(Request $request, Server $server) private function validateDataApplications(Request $request, Server $server)
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
@@ -3254,14 +3392,23 @@ class ApplicationsController extends Controller
'errors' => $errors, 'errors' => $errors,
], 422); ], 422);
} }
if (checkIfDomainIsAlreadyUsed($fqdn, $teamId, $uuid)) { // Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($fqdn, $teamId, $uuid);
if (isset($result['error'])) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
'errors' => [ 'errors' => ['domains' => $result['error']],
'domains' => 'One of the domain is already used.',
],
], 422); ], 422);
} }
// If there are conflicts and force is not enabled, return warning
if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $result['conflicts'],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
} }
} }
} }

View File

@@ -12,6 +12,7 @@ use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob; use App\Jobs\DeleteResourceJob;
use App\Models\Project; use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use App\Models\StandalonePostgresql;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
@@ -143,6 +144,8 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Database not found.'], 404); return response()->json(['message' => 'Database not found.'], 404);
} }
$this->authorize('view', $database);
return response()->json($this->removeSensitiveData($database)); return response()->json($this->removeSensitiveData($database));
} }
@@ -276,6 +279,9 @@ class DatabasesController extends Controller
if (! $database) { if (! $database) {
return response()->json(['message' => 'Database not found.'], 404); return response()->json(['message' => 'Database not found.'], 404);
} }
$this->authorize('update', $database);
if ($request->is_public && $request->public_port) { if ($request->is_public && $request->public_port) {
if (isPublicPortAlreadyUsed($database->destination->server, $request->public_port, $database->id)) { if (isPublicPortAlreadyUsed($database->destination->server, $request->public_port, $database->id)) {
return response()->json(['message' => 'Public port already used by another database.'], 400); return response()->json(['message' => 'Public port already used by another database.'], 400);
@@ -1028,6 +1034,9 @@ class DatabasesController extends Controller
return invalidTokenResponse(); return invalidTokenResponse();
} }
// Use a generic authorization for database creation - using PostgreSQL as representative model
$this->authorize('create', StandalonePostgresql::class);
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return; return $return;
@@ -1606,12 +1615,14 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Database not found.'], 404); return response()->json(['message' => 'Database not found.'], 404);
} }
$this->authorize('delete', $database);
DeleteResourceJob::dispatch( DeleteResourceJob::dispatch(
resource: $database, resource: $database,
deleteConfigurations: $request->query->get('delete_configurations', true),
deleteVolumes: $request->query->get('delete_volumes', true), deleteVolumes: $request->query->get('delete_volumes', true),
dockerCleanup: $request->query->get('docker_cleanup', true), deleteConnectedNetworks: $request->query->get('delete_connected_networks', true),
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) deleteConfigurations: $request->query->get('delete_configurations', true),
dockerCleanup: $request->query->get('docker_cleanup', true)
); );
return response()->json([ return response()->json([
@@ -1684,6 +1695,9 @@ class DatabasesController extends Controller
if (! $database) { if (! $database) {
return response()->json(['message' => 'Database not found.'], 404); return response()->json(['message' => 'Database not found.'], 404);
} }
$this->authorize('manage', $database);
if (str($database->status)->contains('running')) { if (str($database->status)->contains('running')) {
return response()->json(['message' => 'Database is already running.'], 400); return response()->json(['message' => 'Database is already running.'], 400);
} }
@@ -1762,6 +1776,9 @@ class DatabasesController extends Controller
if (! $database) { if (! $database) {
return response()->json(['message' => 'Database not found.'], 404); return response()->json(['message' => 'Database not found.'], 404);
} }
$this->authorize('manage', $database);
if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) { if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) {
return response()->json(['message' => 'Database is already stopped.'], 400); return response()->json(['message' => 'Database is already stopped.'], 400);
} }
@@ -1840,6 +1857,9 @@ class DatabasesController extends Controller
if (! $database) { if (! $database) {
return response()->json(['message' => 'Database not found.'], 404); return response()->json(['message' => 'Database not found.'], 404);
} }
$this->authorize('manage', $database);
RestartDatabase::dispatch($database); RestartDatabase::dispatch($database);
return response()->json( return response()->json(

View File

@@ -225,6 +225,14 @@ class DeployController extends Controller
foreach ($uuids as $uuid) { foreach ($uuids as $uuid) {
$resource = getResourceByUuid($uuid, $teamId); $resource = getResourceByUuid($uuid, $teamId);
if ($resource) { if ($resource) {
if ($pr !== 0) {
$preview = $resource->previews()->where('pull_request_id', $pr)->first();
if (! $preview) {
$deployments->push(['message' => "Pull request {$pr} not found for this resource.", 'resource_uuid' => $uuid]);
continue;
}
}
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr); ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr);
if ($deployment_uuid) { if ($deployment_uuid) {
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]); $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
@@ -299,6 +307,12 @@ class DeployController extends Controller
} }
switch ($resource?->getMorphClass()) { switch ($resource?->getMorphClass()) {
case Application::class: case Application::class:
// Check authorization for application deployment
try {
$this->authorize('deploy', $resource);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
return ['message' => 'Unauthorized to deploy this application.', 'deployment_uuid' => null];
}
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
$result = queue_application_deployment( $result = queue_application_deployment(
application: $resource, application: $resource,
@@ -313,11 +327,22 @@ class DeployController extends Controller
} }
break; break;
case Service::class: case Service::class:
// Check authorization for service deployment
try {
$this->authorize('deploy', $resource);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
return ['message' => 'Unauthorized to deploy this service.', 'deployment_uuid' => null];
}
StartService::run($resource); StartService::run($resource);
$message = "Service {$resource->name} started. It could take a while, be patient."; $message = "Service {$resource->name} started. It could take a while, be patient.";
break; break;
default: default:
// Database resource // Database resource - check authorization
try {
$this->authorize('manage', $resource);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
return ['message' => 'Unauthorized to start this database.', 'deployment_uuid' => null];
}
StartDatabase::dispatch($resource); StartDatabase::dispatch($resource);
$resource->started_at ??= now(); $resource->started_at ??= now();
@@ -423,6 +448,10 @@ class DeployController extends Controller
if (is_null($application)) { if (is_null($application)) {
return response()->json(['message' => 'Application not found'], 404); return response()->json(['message' => 'Application not found'], 404);
} }
// Check authorization to view application deployments
$this->authorize('view', $application);
$deployments = $application->deployments($skip, $take); $deployments = $application->deployments($skip, $take);
return response()->json($deployments); return response()->json($deployments);

View File

@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Project; use App\Models\Project;
use App\Support\ValidationPatterns;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
class ProjectController extends Controller class ProjectController extends Controller
@@ -227,10 +229,10 @@ class ProjectController extends Controller
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return; return $return;
} }
$validator = customApiValidator($request->all(), [ $validator = Validator::make($request->all(), [
'name' => 'string|max:255|required', 'name' => ValidationPatterns::nameRules(),
'description' => 'string|nullable', 'description' => ValidationPatterns::descriptionRules(),
]); ], ValidationPatterns::combinedMessages());
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || ! empty($extraFields)) {
@@ -337,10 +339,10 @@ class ProjectController extends Controller
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return; return $return;
} }
$validator = customApiValidator($request->all(), [ $validator = Validator::make($request->all(), [
'name' => 'string|max:255|nullable', 'name' => ValidationPatterns::nameRules(required: false),
'description' => 'string|nullable', 'description' => ValidationPatterns::descriptionRules(),
]); ], ValidationPatterns::combinedMessages());
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || ! empty($extraFields)) {
@@ -447,4 +449,255 @@ class ProjectController extends Controller
return response()->json(['message' => 'Project deleted.']); return response()->json(['message' => 'Project deleted.']);
} }
#[OA\Get(
summary: 'List Environments',
description: 'List all environments in a project.',
path: '/projects/{uuid}/environments',
operationId: 'get-environments',
security: [
['bearerAuth' => []],
],
tags: ['Projects'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'List of environments',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Environment')
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
description: 'Project not found.',
),
]
)]
public function get_environments(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->uuid) {
return response()->json(['message' => 'Project UUID is required.'], 422);
}
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$environments = $project->environments()->select('id', 'name', 'uuid')->get();
return response()->json(serializeApiResponse($environments));
}
#[OA\Post(
summary: 'Create Environment',
description: 'Create environment in project.',
path: '/projects/{uuid}/environments',
operationId: 'create-environment',
security: [
['bearerAuth' => []],
],
tags: ['Projects'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
description: 'Environment created.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the environment.'],
],
),
),
),
responses: [
new OA\Response(
response: 201,
description: 'Environment created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string', 'example' => 'env123', 'description' => 'The UUID of the environment.'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
description: 'Project not found.',
),
new OA\Response(
response: 409,
description: 'Environment with this name already exists.',
),
]
)]
public function create_environment(Request $request)
{
$allowedFields = ['name'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$validator = Validator::make($request->all(), [
'name' => ValidationPatterns::nameRules(),
], ValidationPatterns::nameMessages());
$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);
}
if (! $request->uuid) {
return response()->json(['message' => 'Project UUID is required.'], 422);
}
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$existingEnvironment = $project->environments()->where('name', $request->name)->first();
if ($existingEnvironment) {
return response()->json(['message' => 'Environment with this name already exists.'], 409);
}
$environment = $project->environments()->create([
'name' => $request->name,
]);
return response()->json([
'uuid' => $environment->uuid,
])->setStatusCode(201);
}
#[OA\Delete(
summary: 'Delete Environment',
description: 'Delete environment by name or UUID. Environment must be empty.',
path: '/projects/{uuid}/environments/{environment_name_or_uuid}',
operationId: 'delete-environment',
security: [
['bearerAuth' => []],
],
tags: ['Projects'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Environment deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Environment deleted.'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
description: 'Environment has resources, so it cannot be deleted.',
),
new OA\Response(
response: 404,
description: 'Project or environment not found.',
),
]
)]
public function delete_environment(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->uuid) {
return response()->json(['message' => 'Project UUID is required.'], 422);
}
if (! $request->environment_name_or_uuid) {
return response()->json(['message' => 'Environment name or UUID is required.'], 422);
}
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$environment = $project->environments()->whereName($request->environment_name_or_uuid)->first();
if (! $environment) {
$environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first();
}
if (! $environment) {
return response()->json(['message' => 'Environment not found.'], 404);
}
if (! $environment->isEmpty()) {
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
}
$environment->delete();
return response()->json(['message' => 'Environment deleted.']);
}
} }

View File

@@ -43,6 +43,10 @@ class ResourcesController extends Controller
if (is_null($teamId)) { if (is_null($teamId)) {
return invalidTokenResponse(); return invalidTokenResponse();
} }
// General authorization check for viewing resources - using Project as base resource type
$this->authorize('viewAny', Project::class);
$projects = Project::where('team_id', $teamId)->get(); $projects = Project::where('team_id', $teamId)->get();
$resources = collect(); $resources = collect();
$resources->push($projects->pluck('applications')->flatten()); $resources->push($projects->pluck('applications')->flatten());

View File

@@ -246,6 +246,8 @@ class ServicesController extends Controller
return invalidTokenResponse(); return invalidTokenResponse();
} }
$this->authorize('create', Service::class);
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return; return $return;
@@ -351,7 +353,6 @@ class ServicesController extends Controller
'value' => $generatedValue, 'value' => $generatedValue,
'resourceable_id' => $service->id, 'resourceable_id' => $service->id,
'resourceable_type' => $service->getMorphClass(), 'resourceable_type' => $service->getMorphClass(),
'is_build_time' => false,
'is_preview' => false, 'is_preview' => false,
]); ]);
}); });
@@ -377,14 +378,118 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} elseif (filled($request->docker_compose_raw)) { } elseif (filled($request->docker_compose_raw)) {
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network'];
$service = new Service; $validator = customApiValidator($request->all(), [
$result = $this->upsert_service($request, $service, $teamId); 'project_uuid' => 'string|required',
if ($result instanceof \Illuminate\Http\JsonResponse) { 'environment_name' => 'string|nullable',
return $result; 'environment_uuid' => 'string|nullable',
'server_uuid' => 'string|required',
'destination_uuid' => 'string',
'name' => 'string|max:255',
'description' => 'string|nullable',
'instant_deploy' => 'boolean',
'connect_to_docker_network' => 'boolean',
'docker_compose_raw' => 'string|required',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
} }
return response()->json(serializeApiResponse($result))->setStatusCode(201); $environmentUuid = $request->environment_uuid;
$environmentName = $request->environment_name;
if (blank($environmentUuid) && blank($environmentName)) {
return response()->json(['message' => 'You need to provide at least one of environment_name or environment_uuid.'], 422);
}
$serverUuid = $request->server_uuid;
$projectUuid = $request->project_uuid;
$project = Project::whereTeamId($teamId)->whereUuid($projectUuid)->first();
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$environment = $project->environments()->where('name', $environmentName)->first();
if (! $environment) {
$environment = $project->environments()->where('uuid', $environmentUuid)->first();
}
if (! $environment) {
return response()->json(['message' => 'Environment not found.'], 404);
}
$server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first();
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);
}
if ($destinations->count() > 1 && ! $request->has('destination_uuid')) {
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
}
$destination = $destinations->first();
if (! isBase64Encoded($request->docker_compose_raw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
$connectToDockerNetwork = $request->connect_to_docker_network ?? false;
$instantDeploy = $request->instant_deploy ?? false;
$service = new Service;
$service->name = $request->name ?? 'service-'.str()->random(10);
$service->description = $request->description;
$service->docker_compose_raw = $dockerComposeRaw;
$service->environment_id = $environment->id;
$service->server_id = $server->id;
$service->destination_id = $destination->id;
$service->destination_type = $destination->getMorphClass();
$service->connect_to_docker_network = $connectToDockerNetwork;
$service->save();
$service->parse(isNew: true);
if ($instantDeploy) {
StartService::dispatch($service);
}
$domains = $service->applications()->get()->pluck('fqdn')->sort();
$domains = $domains->map(function ($domain) {
if (count(explode(':', $domain)) > 2) {
return str($domain)->beforeLast(':')->value();
}
return $domain;
})->values();
return response()->json([
'uuid' => $service->uuid,
'domains' => $domains,
])->setStatusCode(201);
} else { } else {
return response()->json(['message' => 'No service type or docker_compose_raw provided.'], 400); return response()->json(['message' => 'No service type or docker_compose_raw provided.'], 400);
} }
@@ -443,6 +548,8 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.'], 404);
} }
$this->authorize('view', $service);
$service = $service->load(['applications', 'databases']); $service = $service->load(['applications', 'databases']);
return response()->json($this->removeSensitiveData($service)); return response()->json($this->removeSensitiveData($service));
@@ -508,12 +615,14 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.'], 404);
} }
$this->authorize('delete', $service);
DeleteResourceJob::dispatch( DeleteResourceJob::dispatch(
resource: $service, resource: $service,
deleteConfigurations: $request->query->get('delete_configurations', true),
deleteVolumes: $request->query->get('delete_volumes', true), deleteVolumes: $request->query->get('delete_volumes', true),
dockerCleanup: $request->query->get('docker_cleanup', true), deleteConnectedNetworks: $request->query->get('delete_connected_networks', true),
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) deleteConfigurations: $request->query->get('delete_configurations', true),
dockerCleanup: $request->query->get('docker_cleanup', true)
); );
return response()->json([ return response()->json([
@@ -550,7 +659,6 @@ class ServicesController extends Controller
mediaType: 'application/json', mediaType: 'application/json',
schema: new OA\Schema( schema: new OA\Schema(
type: 'object', type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'],
properties: [ properties: [
'name' => ['type' => 'string', 'description' => 'The service name.'], 'name' => ['type' => 'string', 'description' => 'The service name.'],
'description' => ['type' => 'string', 'description' => 'The service description.'], 'description' => ['type' => 'string', 'description' => 'The service description.'],
@@ -615,28 +723,16 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.'], 404);
} }
$result = $this->upsert_service($request, $service, $teamId); $this->authorize('update', $service);
if ($result instanceof \Illuminate\Http\JsonResponse) {
return $result;
}
return response()->json(serializeApiResponse($result))->setStatusCode(200); $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network'];
}
private function upsert_service(Request $request, Service $service, string $teamId)
{
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'project_uuid' => 'string|required',
'environment_name' => 'string|nullable',
'environment_uuid' => 'string|nullable',
'server_uuid' => 'string|required',
'destination_uuid' => 'string',
'name' => 'string|max:255', 'name' => 'string|max:255',
'description' => 'string|nullable', 'description' => 'string|nullable',
'instant_deploy' => 'boolean', 'instant_deploy' => 'boolean',
'connect_to_docker_network' => 'boolean', 'connect_to_docker_network' => 'boolean',
'docker_compose_raw' => 'string|required', 'docker_compose_raw' => 'string|nullable',
]); ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -653,70 +749,42 @@ class ServicesController extends Controller
'errors' => $errors, 'errors' => $errors,
], 422); ], 422);
} }
if ($request->has('docker_compose_raw')) {
if (! isBase64Encoded($request->docker_compose_raw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
$service->docker_compose_raw = $dockerComposeRaw;
}
$environmentUuid = $request->environment_uuid; if ($request->has('name')) {
$environmentName = $request->environment_name; $service->name = $request->name;
if (blank($environmentUuid) && blank($environmentName)) {
return response()->json(['message' => 'You need to provide at least one of environment_name or environment_uuid.'], 422);
} }
$serverUuid = $request->server_uuid; if ($request->has('description')) {
$instantDeploy = $request->instant_deploy ?? false; $service->description = $request->description;
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
} }
$environment = $project->environments()->where('name', $environmentName)->first(); if ($request->has('connect_to_docker_network')) {
if (! $environment) { $service->connect_to_docker_network = $request->connect_to_docker_network;
$environment = $project->environments()->where('uuid', $environmentUuid)->first();
} }
if (! $environment) {
return response()->json(['message' => 'Environment not found.'], 404);
}
$server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first();
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);
}
if ($destinations->count() > 1 && ! $request->has('destination_uuid')) {
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
}
$destination = $destinations->first();
if (! isBase64Encoded($request->docker_compose_raw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
$connectToDockerNetwork = $request->connect_to_docker_network ?? false;
$service->name = $request->name ?? null;
$service->description = $request->description ?? null;
$service->docker_compose_raw = $dockerComposeRaw;
$service->environment_id = $environment->id;
$service->server_id = $server->id;
$service->destination_id = $destination->id;
$service->destination_type = $destination->getMorphClass();
$service->connect_to_docker_network = $connectToDockerNetwork;
$service->save(); $service->save();
$service->parse(); $service->parse();
if ($instantDeploy) { if ($request->instant_deploy) {
StartService::dispatch($service); StartService::dispatch($service);
} }
@@ -729,10 +797,10 @@ class ServicesController extends Controller
return $domain; return $domain;
})->values(); })->values();
return [ return response()->json([
'uuid' => $service->uuid, 'uuid' => $service->uuid,
'domains' => $domains, 'domains' => $domains,
]; ])->setStatusCode(200);
} }
#[OA\Get( #[OA\Get(
@@ -795,6 +863,8 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.'], 404);
} }
$this->authorize('manageEnvironment', $service);
$envs = $service->environment_variables->map(function ($env) { $envs = $service->environment_variables->map(function ($env) {
$env->makeHidden([ $env->makeHidden([
'application_id', 'application_id',
@@ -848,7 +918,6 @@ class ServicesController extends Controller
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@@ -899,10 +968,11 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.'], 404);
} }
$this->authorize('manageEnvironment', $service);
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'key' => 'string|required', 'key' => 'string|required',
'value' => 'string|nullable', 'value' => 'string|nullable',
'is_build_time' => 'boolean',
'is_literal' => 'boolean', 'is_literal' => 'boolean',
'is_multiline' => 'boolean', 'is_multiline' => 'boolean',
'is_shown_once' => 'boolean', 'is_shown_once' => 'boolean',
@@ -966,7 +1036,6 @@ class ServicesController extends Controller
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@@ -1020,6 +1089,8 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.'], 404);
} }
$this->authorize('manageEnvironment', $service);
$bulk_data = $request->get('data'); $bulk_data = $request->get('data');
if (! $bulk_data) { if (! $bulk_data) {
return response()->json(['message' => 'Bulk data is required.'], 400); return response()->json(['message' => 'Bulk data is required.'], 400);
@@ -1030,7 +1101,6 @@ class ServicesController extends Controller
$validator = customApiValidator($item, [ $validator = customApiValidator($item, [
'key' => 'string|required', 'key' => 'string|required',
'value' => 'string|nullable', 'value' => 'string|nullable',
'is_build_time' => 'boolean',
'is_literal' => 'boolean', 'is_literal' => 'boolean',
'is_multiline' => 'boolean', 'is_multiline' => 'boolean',
'is_shown_once' => 'boolean', 'is_shown_once' => 'boolean',
@@ -1086,7 +1156,6 @@ class ServicesController extends Controller
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@@ -1136,10 +1205,11 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.'], 404);
} }
$this->authorize('manageEnvironment', $service);
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'key' => 'string|required', 'key' => 'string|required',
'value' => 'string|nullable', 'value' => 'string|nullable',
'is_build_time' => 'boolean',
'is_literal' => 'boolean', 'is_literal' => 'boolean',
'is_multiline' => 'boolean', 'is_multiline' => 'boolean',
'is_shown_once' => 'boolean', 'is_shown_once' => 'boolean',
@@ -1238,6 +1308,8 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.'], 404);
} }
$this->authorize('manageEnvironment', $service);
$env = EnvironmentVariable::where('uuid', $request->env_uuid) $env = EnvironmentVariable::where('uuid', $request->env_uuid)
->where('resourceable_type', Service::class) ->where('resourceable_type', Service::class)
->where('resourceable_id', $service->id) ->where('resourceable_id', $service->id)
@@ -1317,6 +1389,9 @@ class ServicesController extends Controller
if (! $service) { if (! $service) {
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.'], 404);
} }
$this->authorize('deploy', $service);
if (str($service->status)->contains('running')) { if (str($service->status)->contains('running')) {
return response()->json(['message' => 'Service is already running.'], 400); return response()->json(['message' => 'Service is already running.'], 400);
} }
@@ -1395,6 +1470,9 @@ class ServicesController extends Controller
if (! $service) { if (! $service) {
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.'], 404);
} }
$this->authorize('stop', $service);
if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) { if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) {
return response()->json(['message' => 'Service is already stopped.'], 400); return response()->json(['message' => 'Service is already stopped.'], 400);
} }
@@ -1482,6 +1560,9 @@ class ServicesController extends Controller
if (! $service) { if (! $service) {
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.'], 404);
} }
$this->authorize('deploy', $service);
$pullLatest = $request->boolean('latest'); $pullLatest = $request->boolean('latest');
RestartService::dispatch($service, $pullLatest); RestartService::dispatch($service, $pullLatest);

View File

@@ -179,6 +179,8 @@ class TeamController extends Controller
$members = $team->members; $members = $team->members;
$members->makeHidden([ $members->makeHidden([
'pivot', 'pivot',
'email_change_code',
'email_change_code_expires_at',
]); ]);
return response()->json( return response()->json(
@@ -264,6 +266,8 @@ class TeamController extends Controller
$team = auth()->user()->currentTeam(); $team = auth()->user()->currentTeam();
$team->members->makeHidden([ $team->members->makeHidden([
'pivot', 'pivot',
'email_change_code',
'email_change_code_expires_at',
]); ]);
return response()->json( return response()->json(

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook;
use App\Enums\ProcessStatus; use App\Enums\ProcessStatus;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\ApplicationPullRequestUpdateJob; use App\Jobs\ApplicationPullRequestUpdateJob;
use App\Jobs\DeleteResourceJob;
use App\Jobs\GithubAppPermissionJob; use App\Jobs\GithubAppPermissionJob;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
@@ -78,6 +79,7 @@ class Github extends Controller
$pull_request_html_url = data_get($payload, 'pull_request.html_url'); $pull_request_html_url = data_get($payload, 'pull_request.html_url');
$branch = data_get($payload, 'pull_request.head.ref'); $branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref'); $base_branch = data_get($payload, 'pull_request.base.ref');
$author_association = data_get($payload, 'pull_request.author_association');
} }
if (! $branch) { if (! $branch) {
return response('Nothing to do. No branch found in the request.'); return response('Nothing to do. No branch found in the request.');
@@ -95,151 +97,168 @@ class Github extends Controller
return response("Nothing to do. No applications found with branch '$base_branch'."); return response("Nothing to do. No applications found with branch '$base_branch'.");
} }
} }
foreach ($applications as $application) { $applicationsByServer = $applications->groupBy(function ($app) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github'); return $app->destination->server_id;
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); });
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
continue; foreach ($applicationsByServer as $serverId => $serverApplications) {
} foreach ($serverApplications as $application) {
$isFunctional = $application->destination->server->isFunctional(); $webhook_secret = data_get($application, 'manual_webhook_secret_github');
if (! $isFunctional) { $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
$return_payloads->push([ if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
'application' => $application->name, $return_payloads->push([
'status' => 'failed', 'application' => $application->name,
'message' => 'Server is not functional.', 'status' => 'failed',
]); 'message' => 'Invalid signature.',
]);
continue; continue;
} }
if ($x_github_event === 'push') { $isFunctional = $application->destination->server->isFunctional();
if ($application->isDeployable()) { if (! $isFunctional) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); $return_payloads->push([
if ($is_watch_path_triggered || is_null($application->watch_paths)) { 'application' => $application->name,
$deployment_uuid = new Cuid2; 'status' => 'failed',
$result = queue_application_deployment( 'message' => 'Server is not functional.',
application: $application, ]);
deployment_uuid: $deployment_uuid,
force_rebuild: false, continue;
commit: data_get($payload, 'after', 'HEAD'), }
is_webhook: true, if ($x_github_event === 'push') {
); if ($application->isDeployable()) {
if ($result['status'] === 'skipped') { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
$return_payloads->push([ if ($is_watch_path_triggered || is_null($application->watch_paths)) {
'application' => $application->name, $deployment_uuid = new Cuid2;
'status' => 'skipped', $result = queue_application_deployment(
'message' => $result['message'], application: $application,
]); deployment_uuid: $deployment_uuid,
force_rebuild: false,
commit: data_get($payload, 'after', 'HEAD'),
is_webhook: true,
);
if ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => $result['message'],
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Deployment queued.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
]);
}
} else { } else {
$paths = str($application->watch_paths)->explode("\n");
$return_payloads->push([ $return_payloads->push([
'application' => $application->name, 'status' => 'failed',
'status' => 'success', 'message' => 'Changed files do not match watch paths. Ignoring deployment.',
'message' => 'Deployment queued.',
'application_uuid' => $application->uuid, 'application_uuid' => $application->uuid,
'application_name' => $application->name, 'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'], 'details' => [
'changed_files' => $changed_files,
'watch_paths' => $paths,
],
]); ]);
} }
} else { } else {
$paths = str($application->watch_paths)->explode("\n");
$return_payloads->push([ $return_payloads->push([
'status' => 'failed', 'status' => 'failed',
'message' => 'Changed files do not match watch paths. Ignoring deployment.', 'message' => 'Deployments disabled.',
'application_uuid' => $application->uuid, 'application_uuid' => $application->uuid,
'application_name' => $application->name, 'application_name' => $application->name,
'details' => [
'changed_files' => $changed_files,
'watch_paths' => $paths,
],
]); ]);
} }
} else {
$return_payloads->push([
'status' => 'failed',
'message' => 'Deployments disabled.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
} }
} if ($x_github_event === 'pull_request') {
if ($x_github_event === 'pull_request') { if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { if ($application->isPRDeployable()) {
if ($application->isPRDeployable()) { // Check if PR deployments from public contributors are restricted
$deployment_uuid = new Cuid2; if (! $application->settings->is_pr_deployments_public_enabled) {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (! $found) { if (! in_array($author_association, $trustedAssociations)) {
if ($application->build_pack === 'dockercompose') { $return_payloads->push([
$pr_app = ApplicationPreview::create([ 'application' => $application->name,
'git_type' => 'github', 'status' => 'failed',
'application_id' => $application->id, 'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association,
'pull_request_id' => $pull_request_id, ]);
'pull_request_html_url' => $pull_request_html_url,
'docker_compose_domains' => $application->docker_compose_domains,
]);
$pr_app->generate_preview_fqdn_compose();
} else {
$pr_app = ApplicationPreview::create([
'git_type' => 'github',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
]);
$pr_app->generate_preview_fqdn();
}
}
$result = queue_application_deployment( continue;
application: $application, }
pull_request_id: $pull_request_id, }
deployment_uuid: $deployment_uuid, $deployment_uuid = new Cuid2;
force_rebuild: false, $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
commit: data_get($payload, 'head.sha', 'HEAD'), if (! $found) {
is_webhook: true, if ($application->build_pack === 'dockercompose') {
git_type: 'github' $pr_app = ApplicationPreview::create([
); 'git_type' => 'github',
if ($result['status'] === 'skipped') { 'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
'docker_compose_domains' => $application->docker_compose_domains,
]);
$pr_app->generate_preview_fqdn_compose();
} else {
$pr_app = ApplicationPreview::create([
'git_type' => 'github',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
]);
$pr_app->generate_preview_fqdn();
}
}
$result = queue_application_deployment(
application: $application,
pull_request_id: $pull_request_id,
deployment_uuid: $deployment_uuid,
force_rebuild: false,
commit: data_get($payload, 'head.sha', 'HEAD'),
is_webhook: true,
git_type: 'github'
);
if ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => $result['message'],
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment queued.',
]);
}
} else {
$return_payloads->push([ $return_payloads->push([
'application' => $application->name, 'application' => $application->name,
'status' => 'skipped', 'status' => 'failed',
'message' => $result['message'], 'message' => 'Preview deployments disabled.',
]);
}
}
if ($action === 'closed') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
DeleteResourceJob::dispatch($found);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment closed.',
]); ]);
} else { } else {
$return_payloads->push([ $return_payloads->push([
'application' => $application->name, 'application' => $application->name,
'status' => 'success', 'status' => 'failed',
'message' => 'Preview deployment queued.', 'message' => 'No preview deployment found.',
]); ]);
} }
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
}
}
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);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment closed.',
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'No preview deployment found.',
]);
} }
} }
} }
@@ -327,6 +346,7 @@ class Github extends Controller
$pull_request_html_url = data_get($payload, 'pull_request.html_url'); $pull_request_html_url = data_get($payload, 'pull_request.html_url');
$branch = data_get($payload, 'pull_request.head.ref'); $branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref'); $base_branch = data_get($payload, 'pull_request.base.ref');
$author_association = data_get($payload, 'pull_request.author_association');
} }
if (! $id || ! $branch) { if (! $id || ! $branch) {
return response('Nothing to do. No id or branch found.'); return response('Nothing to do. No id or branch found.');
@@ -344,127 +364,147 @@ class Github extends Controller
return response("Nothing to do. No applications found with branch '$base_branch'."); return response("Nothing to do. No applications found with branch '$base_branch'.");
} }
} }
foreach ($applications as $application) { $applicationsByServer = $applications->groupBy(function ($app) {
$isFunctional = $application->destination->server->isFunctional(); return $app->destination->server_id;
if (! $isFunctional) { });
$return_payloads->push([
'status' => 'failed',
'message' => 'Server is not functional.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue; foreach ($applicationsByServer as $serverId => $serverApplications) {
} foreach ($serverApplications as $application) {
if ($x_github_event === 'push') { $isFunctional = $application->destination->server->isFunctional();
if ($application->isDeployable()) { if (! $isFunctional) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
commit: data_get($payload, 'after', 'HEAD'),
force_rebuild: false,
is_webhook: true,
);
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
]);
} else {
$paths = str($application->watch_paths)->explode("\n");
$return_payloads->push([
'status' => 'failed',
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'details' => [
'changed_files' => $changed_files,
'watch_paths' => $paths,
],
]);
}
} else {
$return_payloads->push([ $return_payloads->push([
'status' => 'failed', 'status' => 'failed',
'message' => 'Deployments disabled.', 'message' => 'Server is not functional.',
'application_uuid' => $application->uuid, 'application_uuid' => $application->uuid,
'application_name' => $application->name, 'application_name' => $application->name,
]); ]);
continue;
} }
} if ($x_github_event === 'push') {
if ($x_github_event === 'pull_request') { if ($application->isDeployable()) {
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($application->isPRDeployable()) { if ($is_watch_path_triggered || is_null($application->watch_paths)) {
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); $result = queue_application_deployment(
if (! $found) { application: $application,
ApplicationPreview::create([ deployment_uuid: $deployment_uuid,
'git_type' => 'github', commit: data_get($payload, 'after', 'HEAD'),
'application_id' => $application->id, force_rebuild: false,
'pull_request_id' => $pull_request_id, is_webhook: true,
'pull_request_html_url' => $pull_request_html_url, );
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
]);
} else {
$paths = str($application->watch_paths)->explode("\n");
$return_payloads->push([
'status' => 'failed',
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'details' => [
'changed_files' => $changed_files,
'watch_paths' => $paths,
],
]); ]);
} }
$result = queue_application_deployment( } else {
application: $application, $return_payloads->push([
pull_request_id: $pull_request_id, 'status' => 'failed',
deployment_uuid: $deployment_uuid, 'message' => 'Deployments disabled.',
force_rebuild: false, 'application_uuid' => $application->uuid,
commit: data_get($payload, 'head.sha', 'HEAD'), 'application_name' => $application->name,
is_webhook: true, ]);
git_type: 'github' }
); }
if ($result['status'] === 'skipped') { if ($x_github_event === 'pull_request') {
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
if ($application->isPRDeployable()) {
// Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) {
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (! in_array($author_association, $trustedAssociations)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association,
]);
continue;
}
}
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
ApplicationPreview::create([
'git_type' => 'github',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
]);
}
$result = queue_application_deployment(
application: $application,
pull_request_id: $pull_request_id,
deployment_uuid: $deployment_uuid,
force_rebuild: false,
commit: data_get($payload, 'head.sha', 'HEAD'),
is_webhook: true,
git_type: 'github'
);
if ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => $result['message'],
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment queued.',
]);
}
} else {
$return_payloads->push([ $return_payloads->push([
'application' => $application->name, 'application' => $application->name,
'status' => 'skipped', 'status' => 'failed',
'message' => $result['message'], 'message' => 'Preview deployments disabled.',
]);
}
}
if ($action === 'closed' || $action === 'close') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
if ($containers->isNotEmpty()) {
$containers->each(function ($container) use ($application) {
$container_name = data_get($container, 'Names');
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
});
}
ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED);
DeleteResourceJob::dispatch($found);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment closed.',
]); ]);
} else { } else {
$return_payloads->push([ $return_payloads->push([
'application' => $application->name, 'application' => $application->name,
'status' => 'success', 'status' => 'failed',
'message' => 'Preview deployment queued.', 'message' => 'No preview deployment found.',
]); ]);
} }
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
}
}
if ($action === 'closed' || $action === 'close') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
if ($containers->isNotEmpty()) {
$containers->each(function ($container) use ($application) {
$container_name = data_get($container, 'Names');
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
});
}
ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED);
$found->delete();
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment closed.',
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'No preview deployment found.',
]);
} }
} }
} }

View File

@@ -4,15 +4,12 @@ namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\StripeProcessJob; use App\Jobs\StripeProcessJob;
use App\Models\Webhook;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class Stripe extends Controller class Stripe extends Controller
{ {
protected $webhook;
public function events(Request $request) public function events(Request $request)
{ {
try { try {
@@ -40,19 +37,10 @@ class Stripe extends Controller
return response('Webhook received. Cool cool cool cool cool.', 200); return response('Webhook received. Cool cool cool cool cool.', 200);
} }
$this->webhook = Webhook::create([
'type' => 'stripe',
'payload' => $request->getContent(),
]);
StripeProcessJob::dispatch($event); StripeProcessJob::dispatch($event);
return response('Webhook received. Cool cool cool cool cool.', 200); return response('Webhook received. Cool cool cool cool cool.', 200);
} catch (Exception $e) { } catch (Exception $e) {
$this->webhook->update([
'status' => 'failed',
'failure_reason' => $e->getMessage(),
]);
return response($e->getMessage(), 400); return response($e->getMessage(), 400);
} }
} }

View File

@@ -71,5 +71,8 @@ class Kernel extends HttpKernel
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
'api.ability' => \App\Http\Middleware\ApiAbility::class, 'api.ability' => \App\Http\Middleware\ApiAbility::class,
'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class, 'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class,
'can.create.resources' => \App\Http\Middleware\CanCreateResources::class,
'can.update.resource' => \App\Http\Middleware\CanUpdateResource::class,
'can.access.terminal' => \App\Http\Middleware\CanAccessTerminal::class,
]; ];
} }

View File

@@ -18,12 +18,18 @@ class ApiAllowed
return response()->json(['success' => true, 'message' => 'API is disabled.'], 403); return response()->json(['success' => true, 'message' => 'API is disabled.'], 403);
} }
if (! isDev()) { if ($settings->allowed_ips) {
if ($settings->allowed_ips) { // Check for special case: 0.0.0.0 means allow all
$allowedIps = explode(',', $settings->allowed_ips); if (trim($settings->allowed_ips) === '0.0.0.0') {
if (! in_array($request->ip(), $allowedIps)) { return $next($request);
return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); }
}
$allowedIps = explode(',', $settings->allowed_ips);
$allowedIps = array_map('trim', $allowedIps);
$allowedIps = array_filter($allowedIps); // Remove empty entries
if (! empty($allowedIps) && ! checkIPAgainstAllowlist($request->ip(), $allowedIps)) {
return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403);
} }
} }

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CanAccessTerminal
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! auth()->check()) {
abort(401, 'Authentication required');
}
// Only admins/owners can access terminal functionality
if (! auth()->user()->can('canAccessTerminal')) {
abort(403, 'Access to terminal functionality is restricted to team administrators');
}
return $next($request);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Response;
class CanCreateResources
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
return $next($request);
// if (! Gate::allows('createAnyResource')) {
// abort(403, 'You do not have permission to create resources.');
// }
// return $next($request);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Middleware;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Response;
class CanUpdateResource
{
public function handle(Request $request, Closure $next): Response
{
return $next($request);
// Get resource from route parameters
// $resource = null;
// if ($request->route('application_uuid')) {
// $resource = Application::where('uuid', $request->route('application_uuid'))->first();
// } elseif ($request->route('service_uuid')) {
// $resource = Service::where('uuid', $request->route('service_uuid'))->first();
// } elseif ($request->route('stack_service_uuid')) {
// // Handle ServiceApplication or ServiceDatabase
// $stack_service_uuid = $request->route('stack_service_uuid');
// $resource = ServiceApplication::where('uuid', $stack_service_uuid)->first() ??
// ServiceDatabase::where('uuid', $stack_service_uuid)->first();
// } elseif ($request->route('database_uuid')) {
// // Try different database types
// $database_uuid = $request->route('database_uuid');
// $resource = StandalonePostgresql::where('uuid', $database_uuid)->first() ??
// StandaloneMysql::where('uuid', $database_uuid)->first() ??
// StandaloneMariadb::where('uuid', $database_uuid)->first() ??
// StandaloneRedis::where('uuid', $database_uuid)->first() ??
// StandaloneKeydb::where('uuid', $database_uuid)->first() ??
// StandaloneDragonfly::where('uuid', $database_uuid)->first() ??
// StandaloneClickhouse::where('uuid', $database_uuid)->first() ??
// StandaloneMongodb::where('uuid', $database_uuid)->first();
// } elseif ($request->route('server_uuid')) {
// // For server routes, check if user can manage servers
// if (! auth()->user()->isAdmin()) {
// abort(403, 'You do not have permission to access this resource.');
// }
// return $next($request);
// } elseif ($request->route('environment_uuid')) {
// $resource = Environment::where('uuid', $request->route('environment_uuid'))->first();
// } elseif ($request->route('project_uuid')) {
// $resource = Project::ownedByCurrentTeam()->where('uuid', $request->route('project_uuid'))->first();
// }
// if (! $resource) {
// abort(404, 'Resource not found.');
// }
// if (! Gate::allows('update', $resource)) {
// abort(403, 'You do not have permission to update this resource.');
// }
// return $next($request);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\TeamInvitation; use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUnique;
@@ -30,6 +31,7 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
{ {
try { try {
$this->cleanupInvitationLink(); $this->cleanupInvitationLink();
$this->cleanupExpiredEmailChangeRequests();
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage()); Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage());
} }
@@ -42,4 +44,15 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
$item->isValid(); $item->isValid();
} }
} }
private function cleanupExpiredEmailChangeRequests()
{
User::whereNotNull('email_change_code_expires_at')
->where('email_change_code_expires_at', '<', now())
->update([
'pending_email' => null,
'email_change_code' => null,
'email_change_code_expires_at' => null,
]);
}
} }

View File

@@ -1,31 +0,0 @@
<?php
namespace App\Jobs;
use App\Actions\Docker\GetContainersStatus;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ContainerStatusJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 4;
public function backoff(): int
{
return isDev() ? 1 : 3;
}
public function __construct(public Server $server) {}
public function handle()
{
GetContainersStatus::run($this->server);
}
}

View File

@@ -54,6 +54,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public ?string $backup_output = null; public ?string $backup_output = null;
public ?string $error_output = null;
public bool $s3_uploaded = false;
public ?string $postgres_password = null; public ?string $postgres_password = null;
public ?string $mongo_root_username = null; public ?string $mongo_root_username = null;
@@ -351,6 +355,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$size = $this->calculate_size(); $size = $this->calculate_size();
if ($this->backup->save_s3) { if ($this->backup->save_s3) {
$this->upload_to_s3(); $this->upload_to_s3();
// If local backup is disabled, delete the local file immediately after S3 upload
if ($this->backup->disable_local_backup) {
deleteBackupsLocally($this->backup_location, $this->server);
}
} }
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); $this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
@@ -361,15 +370,34 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'size' => $size, 'size' => $size,
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
if ($this->backup_log) { // Check if backup actually failed or if it's just a post-backup issue
$this->backup_log->update([ $actualBackupFailed = ! $this->s3_uploaded && $this->backup->save_s3;
'status' => 'failed',
'message' => $this->backup_output, if ($actualBackupFailed || $size === 0) {
'size' => $size, // Real backup failure
'filename' => null, if ($this->backup_log) {
]); $this->backup_log->update([
'status' => 'failed',
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
'size' => $size,
'filename' => null,
]);
}
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
} else {
// Backup succeeded but post-processing failed (cleanup, notification, etc.)
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'success',
'message' => $this->backup_output ? $this->backup_output."\nWarning: Post-backup cleanup encountered an issue: ".$e->getMessage() : 'Warning: '.$e->getMessage(),
'size' => $size,
]);
}
// Send success notification since the backup itself succeeded
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
// Log the post-backup issue
ray('Post-backup operation failed but backup was successful: '.$e->getMessage());
} }
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->backup_output, $database));
} }
} }
if ($this->backup_log && $this->backup_log->status === 'success') { if ($this->backup_log && $this->backup_log->status === 'success') {
@@ -440,7 +468,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->backup_output = null; $this->backup_output = null;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->add_to_backup_output($e->getMessage()); $this->add_to_error_output($e->getMessage());
throw $e; throw $e;
} }
} }
@@ -466,7 +494,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->backup_output = null; $this->backup_output = null;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->add_to_backup_output($e->getMessage()); $this->add_to_error_output($e->getMessage());
throw $e; throw $e;
} }
} }
@@ -486,7 +514,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->backup_output = null; $this->backup_output = null;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->add_to_backup_output($e->getMessage()); $this->add_to_error_output($e->getMessage());
throw $e; throw $e;
} }
} }
@@ -506,7 +534,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->backup_output = null; $this->backup_output = null;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->add_to_backup_output($e->getMessage()); $this->add_to_error_output($e->getMessage());
throw $e; throw $e;
} }
} }
@@ -520,6 +548,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} }
} }
private function add_to_error_output($output): void
{
if ($this->error_output) {
$this->error_output = $this->error_output."\n".$output;
} else {
$this->error_output = $output;
}
}
private function calculate_size() private function calculate_size()
{ {
return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false); return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false);
@@ -561,13 +598,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} else { } else {
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
} }
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc config host add temporary {$endpoint} $key \"$secret\""; $commands[] = "docker exec backup-of-{$this->backup->uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; $commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
instant_remote_process($commands, $this->server); instant_remote_process($commands, $this->server);
$this->add_to_backup_output('Uploaded to S3.'); $this->s3_uploaded = true;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->add_to_backup_output($e->getMessage()); $this->s3_uploaded = false;
$this->add_to_error_output($e->getMessage());
throw $e; throw $e;
} finally { } finally {
$command = "docker rm -f backup-of-{$this->backup->uuid}"; $command = "docker rm -f backup-of-{$this->backup->uuid}";

View File

@@ -32,10 +32,10 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
public function __construct( public function __construct(
public Application|ApplicationPreview|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource, public Application|ApplicationPreview|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
public bool $deleteConfigurations = true,
public bool $deleteVolumes = true, public bool $deleteVolumes = true,
public bool $dockerCleanup = true, public bool $deleteConnectedNetworks = true,
public bool $deleteConnectedNetworks = true public bool $deleteConfigurations = true,
public bool $dockerCleanup = true
) { ) {
$this->onQueue('high'); $this->onQueue('high');
} }
@@ -52,7 +52,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
switch ($this->resource->type()) { switch ($this->resource->type()) {
case 'application': case 'application':
StopApplication::run($this->resource, previewDeployments: true); StopApplication::run($this->resource, previewDeployments: true, dockerCleanup: $this->dockerCleanup);
break; break;
case 'standalone-postgresql': case 'standalone-postgresql':
case 'standalone-redis': case 'standalone-redis':
@@ -62,11 +62,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
case 'standalone-keydb': case 'standalone-keydb':
case 'standalone-dragonfly': case 'standalone-dragonfly':
case 'standalone-clickhouse': case 'standalone-clickhouse':
StopDatabase::run($this->resource, true); StopDatabase::run($this->resource, dockerCleanup: $this->dockerCleanup);
break; break;
case 'service': case 'service':
StopService::run($this->resource, true); StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup);
DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks); DeleteService::run($this->resource, $this->deleteVolumes, $this->deleteConnectedNetworks, $this->deleteConfigurations, $this->dockerCleanup);
return; return;
} }
@@ -78,7 +78,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
$this->resource->deleteVolumes(); $this->resource->deleteVolumes();
$this->resource->persistentStorages()->delete(); $this->resource->persistentStorages()->delete();
} }
$this->resource->fileStorages()->delete(); $this->resource->fileStorages()->delete(); // these are file mounts which should probably have their own flag
$isDatabase = $this->resource instanceof StandalonePostgresql $isDatabase = $this->resource instanceof StandalonePostgresql
|| $this->resource instanceof StandaloneRedis || $this->resource instanceof StandaloneRedis
@@ -106,7 +106,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
if ($this->dockerCleanup) { if ($this->dockerCleanup) {
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server'); $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
if ($server) { if ($server) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, false, false);
} }
} }
Artisan::queue('cleanup:stucked-resources'); Artisan::queue('cleanup:stucked-resources');

View File

@@ -34,7 +34,12 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
return [(new WithoutOverlapping('docker-cleanup-'.$this->server->uuid))->expireAfter(600)->dontRelease()]; return [(new WithoutOverlapping('docker-cleanup-'.$this->server->uuid))->expireAfter(600)->dontRelease()];
} }
public function __construct(public Server $server, public bool $manualCleanup = false) {} public function __construct(
public Server $server,
public bool $manualCleanup = false,
public bool $deleteUnusedVolumes = false,
public bool $deleteUnusedNetworks = false
) {}
public function handle(): void public function handle(): void
{ {
@@ -50,7 +55,11 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
$this->usageBefore = $this->server->getDiskUsage(); $this->usageBefore = $this->server->getDiskUsage();
if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) { if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) {
$cleanup_log = CleanupDocker::run(server: $this->server); $cleanup_log = CleanupDocker::run(
server: $this->server,
deleteUnusedVolumes: $this->deleteUnusedVolumes,
deleteUnusedNetworks: $this->deleteUnusedNetworks
);
$usageAfter = $this->server->getDiskUsage(); $usageAfter = $this->server->getDiskUsage();
$message = ($this->manualCleanup ? 'Manual' : 'Forced').' Docker cleanup job executed successfully. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.'; $message = ($this->manualCleanup ? 'Manual' : 'Forced').' Docker cleanup job executed successfully. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.';
@@ -67,7 +76,11 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
} }
if (str($this->usageBefore)->isEmpty() || $this->usageBefore === null || $this->usageBefore === 0) { if (str($this->usageBefore)->isEmpty() || $this->usageBefore === null || $this->usageBefore === 0) {
$cleanup_log = CleanupDocker::run(server: $this->server); $cleanup_log = CleanupDocker::run(
server: $this->server,
deleteUnusedVolumes: $this->deleteUnusedVolumes,
deleteUnusedNetworks: $this->deleteUnusedNetworks
);
$message = 'Docker cleanup job executed successfully, but no disk usage could be determined.'; $message = 'Docker cleanup job executed successfully, but no disk usage could be determined.';
$this->execution_log->update([ $this->execution_log->update([
@@ -81,7 +94,11 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
} }
if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) { if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) {
$cleanup_log = CleanupDocker::run(server: $this->server); $cleanup_log = CleanupDocker::run(
server: $this->server,
deleteUnusedVolumes: $this->deleteUnusedVolumes,
deleteUnusedNetworks: $this->deleteUnusedNetworks
);
$usageAfter = $this->server->getDiskUsage(); $usageAfter = $this->server->getDiskUsage();
$diskSaved = $this->usageBefore - $usageAfter; $diskSaved = $this->usageBefore - $usageAfter;

126
app/Jobs/PullChangelog.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
namespace App\Jobs;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PullChangelog implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 30;
public function __construct()
{
$this->onQueue('high');
}
public function handle(): void
{
try {
// Fetch from CDN instead of GitHub API to avoid rate limits
$cdnUrl = config('constants.coolify.releases_url');
$response = Http::retry(3, 1000)
->timeout(30)
->get($cdnUrl);
if ($response->successful()) {
$releases = $response->json();
// Limit to 10 releases for processing (same as before)
$releases = array_slice($releases, 0, 10);
$changelog = $this->transformReleasesToChangelog($releases);
// Group entries by month and save them
$this->saveChangelogEntries($changelog);
} else {
// Log error instead of sending notification
Log::error('PullChangelogFromGitHub: Failed to fetch from CDN', [
'status' => $response->status(),
'url' => $cdnUrl,
]);
}
} catch (\Throwable $e) {
// Log error instead of sending notification
Log::error('PullChangelogFromGitHub: Exception occurred', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
private function transformReleasesToChangelog(array $releases): array
{
$entries = [];
foreach ($releases as $release) {
// Skip drafts and pre-releases if desired
if ($release['draft']) {
continue;
}
$publishedAt = Carbon::parse($release['published_at']);
$entry = [
'tag_name' => $release['tag_name'],
'title' => $release['name'] ?: $release['tag_name'],
'content' => $release['body'] ?: 'No release notes available.',
'published_at' => $publishedAt->toISOString(),
];
$entries[] = $entry;
}
return $entries;
}
private function saveChangelogEntries(array $entries): void
{
// Create changelogs directory if it doesn't exist
$changelogsDir = base_path('changelogs');
if (! File::exists($changelogsDir)) {
File::makeDirectory($changelogsDir, 0755, true);
}
// Group entries by year-month
$groupedEntries = [];
foreach ($entries as $entry) {
$date = Carbon::parse($entry['published_at']);
$monthKey = $date->format('Y-m');
if (! isset($groupedEntries[$monthKey])) {
$groupedEntries[$monthKey] = [];
}
$groupedEntries[$monthKey][] = $entry;
}
// Save each month's entries to separate files
foreach ($groupedEntries as $month => $monthEntries) {
// Sort entries by published date (newest first)
usort($monthEntries, function ($a, $b) {
return Carbon::parse($b['published_at'])->timestamp - Carbon::parse($a['published_at'])->timestamp;
});
$monthData = [
'entries' => $monthEntries,
'last_updated' => now()->toISOString(),
];
$filePath = base_path("changelogs/{$month}.json");
File::put($filePath, json_encode($monthData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
}
}

View File

@@ -31,7 +31,7 @@ class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue
$response = Http::retry(3, 1000)->get(config('constants.services.official')); $response = Http::retry(3, 1000)->get(config('constants.services.official'));
if ($response->successful()) { if ($response->successful()) {
$services = $response->json(); $services = $response->json();
File::put(base_path('templates/service-templates.json'), json_encode($services)); File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
} else { } else {
send_internal_notification('PullTemplatesAndVersions failed with: '.$response->status().' '.$response->body()); send_internal_notification('PullTemplatesAndVersions failed with: '.$response->status().' '.$response->body());
} }

View File

@@ -65,6 +65,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public Collection $foundApplicationPreviewsIds; public Collection $foundApplicationPreviewsIds;
public Collection $applicationContainerStatuses;
public bool $foundProxy = false; public bool $foundProxy = false;
public bool $foundLogDrainContainer = false; public bool $foundLogDrainContainer = false;
@@ -87,6 +89,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->foundServiceApplicationIds = collect(); $this->foundServiceApplicationIds = collect();
$this->foundApplicationPreviewsIds = collect(); $this->foundApplicationPreviewsIds = collect();
$this->foundServiceDatabaseIds = collect(); $this->foundServiceDatabaseIds = collect();
$this->applicationContainerStatuses = collect();
$this->allApplicationIds = collect(); $this->allApplicationIds = collect();
$this->allDatabaseUuids = collect(); $this->allDatabaseUuids = collect();
$this->allTcpProxyUuids = collect(); $this->allTcpProxyUuids = collect();
@@ -155,7 +158,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) { if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) {
$this->foundApplicationIds->push($applicationId); $this->foundApplicationIds->push($applicationId);
} }
$this->updateApplicationStatus($applicationId, $containerStatus); // Store container status for aggregation
if (! $this->applicationContainerStatuses->has($applicationId)) {
$this->applicationContainerStatuses->put($applicationId, collect());
}
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
} else { } else {
$previewKey = $applicationId.':'.$pullRequestId; $previewKey = $applicationId.':'.$pullRequestId;
if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) { if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) {
@@ -205,9 +215,86 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->updateAdditionalServersStatus(); $this->updateAdditionalServersStatus();
// Aggregate multi-container application statuses
$this->aggregateMultiContainerStatuses();
$this->checkLogDrainContainer(); $this->checkLogDrainContainer();
} }
private function aggregateMultiContainerStatuses()
{
if ($this->applicationContainerStatuses->isEmpty()) {
return;
}
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
$application = $this->applications->where('id', $applicationId)->first();
if (! $application) {
continue;
}
// Parse docker compose to check for excluded containers
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
$excludedContainers = collect();
if ($dockerComposeRaw) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $serviceConfig) {
// Check if container should be excluded
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
if ($excludeFromHc || $restartPolicy === 'no') {
$excludedContainers->push($serviceName);
}
}
} catch (\Exception $e) {
// If we can't parse, treat all containers as included
}
}
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, don't update status
if ($relevantStatuses->isEmpty()) {
continue;
}
// Aggregate status: if any container is running, app is running
$hasRunning = false;
$hasUnhealthy = false;
foreach ($relevantStatuses as $status) {
if (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
}
}
}
$aggregatedStatus = null;
if ($hasRunning) {
$aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
} else {
// All containers are exited
$aggregatedStatus = 'exited (unhealthy)';
}
// Update application status with aggregated result
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
}
}
}
private function updateApplicationStatus(string $applicationId, string $containerStatus) private function updateApplicationStatus(string $applicationId, string $containerStatus)
{ {
$application = $this->applications->where('id', $applicationId)->first(); $application = $this->applications->where('id', $applicationId)->first();

View File

@@ -4,6 +4,8 @@ namespace App\Jobs;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask; use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
use Cron\CronExpression; use Cron\CronExpression;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@@ -12,6 +14,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class ScheduledJobManager implements ShouldQueue class ScheduledJobManager implements ShouldQueue
@@ -77,6 +80,16 @@ class ScheduledJobManager implements ShouldQueue
'trace' => $e->getTraceAsString(), 'trace' => $e->getTraceAsString(),
]); ]);
} }
// Process Docker cleanups - don't let failures stop the job manager
try {
$this->processDockerCleanups();
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process docker cleanups', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
} }
private function processScheduledBackups(): void private function processScheduledBackups(): void
@@ -226,4 +239,75 @@ class ScheduledJobManager implements ShouldQueue
return $cron->isDue($executionTime); return $cron->isDue($executionTime);
} }
private function processDockerCleanups(): void
{
// Get all servers that need cleanup checks
$servers = $this->getServersForCleanup();
foreach ($servers as $server) {
try {
if (! $this->shouldProcessDockerCleanup($server)) {
continue;
}
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($serverTimezone) === false) {
$serverTimezone = config('app.timezone');
}
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
// Use the frozen execution time for consistent evaluation
if ($this->shouldRunNow($frequency, $serverTimezone)) {
DockerCleanupJob::dispatch(
$server,
false,
$server->settings->delete_unused_volumes,
$server->settings->delete_unused_networks
);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
]);
}
}
}
private function getServersForCleanup(): Collection
{
$query = Server::with('settings')
->where('ip', '!=', '1.2.3.4');
if (isCloud()) {
$servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
$own = Team::find(0)->servers()->with('settings')->get();
return $servers->merge($own);
}
return $query->get();
}
private function shouldProcessDockerCleanup(Server $server): bool
{
if (! $server->isFunctional()) {
return false;
}
// In cloud, check subscription status (except team 0)
if (isCloud() && $server->team_id !== 0) {
if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) {
return false;
}
}
return true;
}
} }

View File

@@ -3,6 +3,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Events\ScheduledTaskDone; use App\Events\ScheduledTaskDone;
use App\Exceptions\NonReportableException;
use App\Models\Application; use App\Models\Application;
use App\Models\ScheduledTask; use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution; use App\Models\ScheduledTaskExecution;
@@ -120,7 +121,7 @@ class ScheduledTaskJob implements ShouldQueue
} }
// No valid container was found. // No valid container was found.
throw new \Exception('ScheduledTaskJob failed: No valid container was found. Is the container name correct?'); throw new NonReportableException('ScheduledTaskJob failed: No valid container was found. Is the container name correct?');
} catch (\Throwable $e) { } catch (\Throwable $e) {
if ($this->task_log) { if ($this->task_log) {
$this->task_log->update([ $this->task_log->update([

View File

@@ -1,34 +0,0 @@
<?php
namespace App\Jobs;
use App\Actions\Server\ResourcesCheck;
use App\Actions\Server\ServerCheck;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ServerCheckNewJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 60;
public function __construct(public Server $server) {}
public function handle()
{
try {
ServerCheck::run($this->server);
ResourcesCheck::dispatch($this->server);
} catch (\Throwable $e) {
return handleError($e);
}
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use App\Services\ConfigurationRepository;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 30;
public function __construct(
public Server $server,
public bool $disableMux = true
) {}
public function middleware(): array
{
return [(new WithoutOverlapping('server-connection-check-'.$this->server->uuid))->expireAfter(45)->dontRelease()];
}
private function disableSshMux(): void
{
$configRepository = app(ConfigurationRepository::class);
$configRepository->disableSshMux();
}
public function handle()
{
try {
// Check if server is disabled
if ($this->server->settings->force_disabled) {
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
Log::debug('ServerConnectionCheck: Server is disabled', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
return;
}
// Temporarily disable mux if requested
if ($this->disableMux) {
$this->disableSshMux();
}
// Check basic connectivity first
$isReachable = $this->checkConnection();
if (! $isReachable) {
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
Log::warning('ServerConnectionCheck: Server not reachable', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
'server_ip' => $this->server->ip,
]);
return;
}
// Server is reachable, check if Docker is available
$isUsable = $this->checkDockerAvailability();
$this->server->settings->update([
'is_reachable' => true,
'is_usable' => $isUsable,
]);
} catch (\Throwable $e) {
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
throw $e;
}
}
private function checkConnection(): bool
{
try {
// Use instant_remote_process with a simple command
// This will automatically handle mux, sudo, IPv6, Cloudflare tunnel, etc.
$output = instant_remote_process_with_timeout(
['ls -la /'],
$this->server,
false // don't throw error
);
return $output !== null;
} catch (\Throwable $e) {
Log::debug('ServerConnectionCheck: Connection check failed', [
'server_id' => $this->server->id,
'error' => $e->getMessage(),
]);
return false;
}
}
private function checkDockerAvailability(): bool
{
try {
// Use instant_remote_process to check Docker
// The function will automatically handle sudo for non-root users
$output = instant_remote_process_with_timeout(
['docker version --format json'],
$this->server,
false // don't throw error
);
if ($output === null) {
return false;
}
// Try to parse the JSON output to ensure Docker is really working
$output = trim($output);
if (! empty($output)) {
$dockerInfo = json_decode($output, true);
return isset($dockerInfo['Server']['Version']);
}
return false;
} catch (\Throwable $e) {
Log::debug('ServerConnectionCheck: Docker check failed', [
'server_id' => $this->server->id,
'error' => $e->getMessage(),
]);
return false;
}
}
}

View File

@@ -10,12 +10,12 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class ServerResourceManager implements ShouldQueue class ServerManagerJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@@ -28,6 +28,8 @@ class ServerResourceManager implements ShouldQueue
private string $instanceTimezone; private string $instanceTimezone;
private string $checkFrequency = '* * * * *';
/** /**
* Create a new job instance. * Create a new job instance.
*/ */
@@ -36,22 +38,13 @@ class ServerResourceManager implements ShouldQueue
$this->onQueue('high'); $this->onQueue('high');
} }
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [
(new WithoutOverlapping('server-resource-manager'))
->releaseAfter(60),
];
}
public function handle(): void public function handle(): void
{ {
// Freeze the execution time at the start of the job // Freeze the execution time at the start of the job
$this->executionTime = Carbon::now(); $this->executionTime = Carbon::now();
if (isCloud()) {
$this->checkFrequency = '*/5 * * * *';
}
$this->settings = instanceSettings(); $this->settings = instanceSettings();
$this->instanceTimezone = $this->settings->instance_timezone ?: config('app.timezone'); $this->instanceTimezone = $this->settings->instance_timezone ?: config('app.timezone');
@@ -59,35 +52,17 @@ class ServerResourceManager implements ShouldQueue
$this->instanceTimezone = config('app.timezone'); $this->instanceTimezone = config('app.timezone');
} }
// Process server checks - don't let failures stop the job // Get all servers to process
try {
$this->processServerChecks();
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process server checks', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
private function processServerChecks(): void
{
$servers = $this->getServers(); $servers = $this->getServers();
foreach ($servers as $server) { // Dispatch ServerConnectionCheck for all servers efficiently
try { $this->dispatchConnectionChecks($servers);
$this->processServer($server);
} catch (\Exception $e) { // Process server-specific scheduled tasks
Log::channel('scheduled-errors')->error('Error processing server', [ $this->processScheduledTasks($servers);
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
]);
}
}
} }
private function getServers() private function getServers(): Collection
{ {
$allServers = Server::where('ip', '!=', '1.2.3.4'); $allServers = Server::where('ip', '!=', '1.2.3.4');
@@ -101,19 +76,49 @@ class ServerResourceManager implements ShouldQueue
} }
} }
private function processServer(Server $server): void private function dispatchConnectionChecks(Collection $servers): void
{ {
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
if (validate_timezone($serverTimezone) === false) {
$serverTimezone = config('app.timezone');
}
// Sentinel check if ($this->shouldRunNow($this->checkFrequency)) {
$servers->each(function (Server $server) {
try {
ServerConnectionCheckJob::dispatch($server);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
]);
}
});
}
}
private function processScheduledTasks(Collection $servers): void
{
foreach ($servers as $server) {
try {
$this->processServerTasks($server);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing server tasks', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
]);
}
}
}
private function processServerTasks(Server $server): void
{
// Check if we should run sentinel-based checks
$lastSentinelUpdate = $server->sentinel_updated_at; $lastSentinelUpdate = $server->sentinel_updated_at;
if (Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($server->waitBeforeDoingSshCheck()))) { $waitTime = $server->waitBeforeDoingSshCheck();
// Dispatch ServerCheckJob if due $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($waitTime));
$checkFrequency = isCloud() ? '*/5 * * * *' : '* * * * *'; // Every 5 min for cloud, every minute for self-hosted
if ($this->shouldRunNow($checkFrequency, $serverTimezone)) { if ($sentinelOutOfSync) {
// Dispatch jobs if Sentinel is out of sync
if ($this->shouldRunNow($this->checkFrequency)) {
ServerCheckJob::dispatch($server); ServerCheckJob::dispatch($server);
} }
@@ -122,40 +127,43 @@ class ServerResourceManager implements ShouldQueue
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) { if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency]; $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
} }
if ($this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone)) { $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency);
if ($shouldRunStorageCheck) {
ServerStorageCheckJob::dispatch($server); ServerStorageCheckJob::dispatch($server);
} }
} }
// Dispatch DockerCleanupJob if due $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
$dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); if (validate_timezone($serverTimezone) === false) {
if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) { $serverTimezone = config('app.timezone');
$dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency];
}
if ($this->shouldRunNow($dockerCleanupFrequency, $serverTimezone)) {
DockerCleanupJob::dispatch($server);
} }
// Dispatch ServerPatchCheckJob if due (weekly) // Dispatch ServerPatchCheckJob if due (weekly)
if ($this->shouldRunNow('0 0 * * 0', $serverTimezone)) { // Weekly on Sunday at midnight $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone);
if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight
ServerPatchCheckJob::dispatch($server); ServerPatchCheckJob::dispatch($server);
} }
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
if ($server->isSentinelEnabled() && $this->shouldRunNow('0 0 * * *', $serverTimezone)) { $isSentinelEnabled = $server->isSentinelEnabled();
$shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone);
if ($shouldRestartSentinel) {
dispatch(function () use ($server) { dispatch(function () use ($server) {
$server->restartContainer('coolify-sentinel'); $server->restartContainer('coolify-sentinel');
}); });
} }
} }
private function shouldRunNow(string $frequency, string $timezone): bool private function shouldRunNow(string $frequency, ?string $timezone = null): bool
{ {
$cron = new CronExpression($frequency); $cron = new CronExpression($frequency);
// Use the frozen execution time, not the current time // Use the frozen execution time, not the current time
$baseTime = $this->executionTime ?? Carbon::now(); $baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone); $executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone'));
return $cron->isDue($executionTime); return $cron->isDue($executionTime);
} }

View File

@@ -58,7 +58,7 @@ class StripeProcessJob implements ShouldQueue
case 'checkout.session.completed': case 'checkout.session.completed':
$clientReferenceId = data_get($data, 'client_reference_id'); $clientReferenceId = data_get($data, 'client_reference_id');
if (is_null($clientReferenceId)) { if (is_null($clientReferenceId)) {
send_internal_notification('Checkout session completed without client reference id.'); // send_internal_notification('Checkout session completed without client reference id.');
break; break;
} }
$userId = Str::before($clientReferenceId, ':'); $userId = Str::before($clientReferenceId, ':');
@@ -68,7 +68,7 @@ class StripeProcessJob implements ShouldQueue
$team = Team::find($teamId); $team = Team::find($teamId);
$found = $team->members->where('id', $userId)->first(); $found = $team->members->where('id', $userId)->first();
if (! $found->isAdmin()) { if (! $found->isAdmin()) {
send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
} }
$subscription = Subscription::where('team_id', $teamId)->first(); $subscription = Subscription::where('team_id', $teamId)->first();
@@ -95,7 +95,7 @@ class StripeProcessJob implements ShouldQueue
$customerId = data_get($data, 'customer'); $customerId = data_get($data, 'customer');
$planId = data_get($data, 'lines.data.0.plan.id'); $planId = data_get($data, 'lines.data.0.plan.id');
if (Str::contains($excludedPlans, $planId)) { if (Str::contains($excludedPlans, $planId)) {
send_internal_notification('Subscription excluded.'); // send_internal_notification('Subscription excluded.');
break; break;
} }
$subscription = Subscription::where('stripe_customer_id', $customerId)->first(); $subscription = Subscription::where('stripe_customer_id', $customerId)->first();
@@ -110,16 +110,38 @@ class StripeProcessJob implements ShouldQueue
break; break;
case 'invoice.payment_failed': case 'invoice.payment_failed':
$customerId = data_get($data, 'customer'); $customerId = data_get($data, 'customer');
$invoiceId = data_get($data, 'id');
$paymentIntentId = data_get($data, 'payment_intent');
$subscription = Subscription::where('stripe_customer_id', $customerId)->first(); $subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) { if (! $subscription) {
send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId); // send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No subscription found for customer: {$customerId}"); throw new \RuntimeException("No subscription found for customer: {$customerId}");
} }
$team = data_get($subscription, 'team'); $team = data_get($subscription, 'team');
if (! $team) { if (! $team) {
send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId); // send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
} }
// Verify payment status with Stripe API before sending failure notification
if ($paymentIntentId) {
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
break;
}
if (! $subscription->stripe_invoice_paid && $subscription->created_at->diffInMinutes(now()) < 5) {
SubscriptionInvoiceFailedJob::dispatch($team)->delay(now()->addSeconds(60));
break;
}
} catch (\Exception $e) {
}
}
if (! $subscription->stripe_invoice_paid) { if (! $subscription->stripe_invoice_paid) {
SubscriptionInvoiceFailedJob::dispatch($team); SubscriptionInvoiceFailedJob::dispatch($team);
// send_internal_notification('Invoice payment failed: '.$customerId); // send_internal_notification('Invoice payment failed: '.$customerId);
@@ -129,11 +151,11 @@ class StripeProcessJob implements ShouldQueue
$customerId = data_get($data, 'customer'); $customerId = data_get($data, 'customer');
$subscription = Subscription::where('stripe_customer_id', $customerId)->first(); $subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) { if (! $subscription) {
send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId); // send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}"); throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
} }
if ($subscription->stripe_invoice_paid) { if ($subscription->stripe_invoice_paid) {
send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId); // send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
return; return;
} }
@@ -154,7 +176,7 @@ class StripeProcessJob implements ShouldQueue
$team = Team::find($teamId); $team = Team::find($teamId);
$found = $team->members->where('id', $userId)->first(); $found = $team->members->where('id', $userId)->first();
if (! $found->isAdmin()) { if (! $found->isAdmin()) {
send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
} }
$subscription = Subscription::where('team_id', $teamId)->first(); $subscription = Subscription::where('team_id', $teamId)->first();
@@ -177,7 +199,7 @@ class StripeProcessJob implements ShouldQueue
$subscriptionId = data_get($data, 'items.data.0.subscription') ?? data_get($data, 'id'); $subscriptionId = data_get($data, 'items.data.0.subscription') ?? data_get($data, 'id');
$planId = data_get($data, 'items.data.0.plan.id') ?? data_get($data, 'plan.id'); $planId = data_get($data, 'items.data.0.plan.id') ?? data_get($data, 'plan.id');
if (Str::contains($excludedPlans, $planId)) { if (Str::contains($excludedPlans, $planId)) {
send_internal_notification('Subscription excluded.'); // send_internal_notification('Subscription excluded.');
break; break;
} }
$subscription = Subscription::where('stripe_customer_id', $customerId)->first(); $subscription = Subscription::where('stripe_customer_id', $customerId)->first();
@@ -194,7 +216,7 @@ class StripeProcessJob implements ShouldQueue
'stripe_invoice_paid' => false, 'stripe_invoice_paid' => false,
]); ]);
} else { } else {
send_internal_notification('No subscription and team id found'); // send_internal_notification('No subscription and team id found');
throw new \RuntimeException('No subscription and team id found'); throw new \RuntimeException('No subscription and team id found');
} }
} }
@@ -230,7 +252,7 @@ class StripeProcessJob implements ShouldQueue
$subscription->update([ $subscription->update([
'stripe_past_due' => true, 'stripe_past_due' => true,
]); ]);
send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId); // send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId);
} }
} }
if ($status === 'unpaid') { if ($status === 'unpaid') {
@@ -238,13 +260,13 @@ class StripeProcessJob implements ShouldQueue
$subscription->update([ $subscription->update([
'stripe_invoice_paid' => false, 'stripe_invoice_paid' => false,
]); ]);
send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId); // send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId);
} }
$team = data_get($subscription, 'team'); $team = data_get($subscription, 'team');
if ($team) { if ($team) {
$team->subscriptionEnded(); $team->subscriptionEnded();
} else { } else {
send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId); // send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
} }
} }
@@ -273,11 +295,11 @@ class StripeProcessJob implements ShouldQueue
if ($team) { if ($team) {
$team->subscriptionEnded(); $team->subscriptionEnded();
} else { } else {
send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId); // send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
} }
} else { } else {
send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId); // send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}"); throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
} }
break; break;

View File

@@ -23,6 +23,47 @@ class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue
public function handle() public function handle()
{ {
try { try {
// Double-check subscription status before sending failure notification
$subscription = $this->team->subscription;
if ($subscription && $subscription->stripe_customer_id) {
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
if ($subscription->stripe_subscription_id) {
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
if (in_array($stripeSubscription->status, ['active', 'trialing'])) {
if (! $subscription->stripe_invoice_paid) {
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
}
return;
}
}
$invoices = $stripe->invoices->all([
'customer' => $subscription->stripe_customer_id,
'limit' => 3,
]);
foreach ($invoices->data as $invoice) {
if ($invoice->paid && $invoice->created > (time() - 3600)) {
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
return;
}
}
} catch (\Exception $e) {
}
}
// If we reach here, payment genuinely failed
$session = getStripeCustomerPortalSession($this->team); $session = getStripeCustomerPortalSession($this->team);
$mail = new MailMessage; $mail = new MailMessage;
$mail->view('emails.subscription-invoice-failed', [ $mail->view('emails.subscription-invoice-failed', [

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Jobs;
use App\Models\Team;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
class UpdateStripeCustomerEmailJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $backoff = [10, 30, 60];
public function __construct(
private Team $team,
private int $userId,
private string $newEmail,
private string $oldEmail
) {
$this->onQueue('high');
}
public function handle(): void
{
try {
if (! isCloud() || ! $this->team->subscription) {
Log::info('Skipping Stripe email update - not cloud or no subscription', [
'team_id' => $this->team->id,
'user_id' => $this->userId,
]);
return;
}
// Check if the user changing email is a team owner
$isOwner = $this->team->members()
->wherePivot('role', 'owner')
->where('users.id', $this->userId)
->exists();
if (! $isOwner) {
Log::info('Skipping Stripe email update - user is not team owner', [
'team_id' => $this->team->id,
'user_id' => $this->userId,
]);
return;
}
// Get current Stripe customer email to verify it matches the user's old email
$stripe_customer_id = data_get($this->team, 'subscription.stripe_customer_id');
if (! $stripe_customer_id) {
Log::info('Skipping Stripe email update - no Stripe customer ID', [
'team_id' => $this->team->id,
'user_id' => $this->userId,
]);
return;
}
Stripe::setApiKey(config('subscription.stripe_api_key'));
try {
$stripeCustomer = \Stripe\Customer::retrieve($stripe_customer_id);
$currentStripeEmail = $stripeCustomer->email;
// Only update if the current Stripe email matches the user's old email
if (strtolower($currentStripeEmail) !== strtolower($this->oldEmail)) {
Log::info('Skipping Stripe email update - Stripe customer email does not match user old email', [
'team_id' => $this->team->id,
'user_id' => $this->userId,
'stripe_email' => $currentStripeEmail,
'user_old_email' => $this->oldEmail,
]);
return;
}
// Update Stripe customer email
\Stripe\Customer::update($stripe_customer_id, ['email' => $this->newEmail]);
} catch (\Exception $e) {
Log::error('Failed to retrieve or update Stripe customer', [
'team_id' => $this->team->id,
'user_id' => $this->userId,
'stripe_customer_id' => $stripe_customer_id,
'error' => $e->getMessage(),
]);
throw $e;
}
Log::info('Successfully updated Stripe customer email', [
'team_id' => $this->team->id,
'user_id' => $this->userId,
'old_email' => $this->oldEmail,
'new_email' => $this->newEmail,
]);
} catch (\Exception $e) {
Log::error('Failed to update Stripe customer email', [
'team_id' => $this->team->id,
'user_id' => $this->userId,
'old_email' => $this->oldEmail,
'new_email' => $this->newEmail,
'error' => $e->getMessage(),
'attempt' => $this->attempts(),
]);
// Re-throw to trigger retry
throw $e;
}
}
public function failed(\Throwable $exception): void
{
Log::error('Permanently failed to update Stripe customer email after all retries', [
'team_id' => $this->team->id,
'user_id' => $this->userId,
'old_email' => $this->oldEmail,
'new_email' => $this->newEmail,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationDeploymentQueue;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
@@ -30,6 +31,12 @@ class Dashboard extends Component
public function cleanupQueue() public function cleanupQueue()
{ {
try {
$this->authorize('cleanupDeploymentQueue', Application::class);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
return handleError($e, $this);
}
Artisan::queue('cleanup:deployment-queue', [ Artisan::queue('cleanup:deployment-queue', [
'--team-id' => currentTeam()->id, '--team-id' => currentTeam()->id,
]); ]);

View File

@@ -5,6 +5,7 @@ namespace App\Livewire\Destination\New;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneDocker; use App\Models\StandaloneDocker;
use App\Models\SwarmDocker; use App\Models\SwarmDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
@@ -12,6 +13,8 @@ use Visus\Cuid2\Cuid2;
class Docker extends Component class Docker extends Component
{ {
use AuthorizesRequests;
#[Locked] #[Locked]
public $servers; public $servers;
@@ -67,6 +70,7 @@ class Docker extends Component
public function submit() public function submit()
{ {
try { try {
$this->authorize('create', StandaloneDocker::class);
$this->validate(); $this->validate();
if ($this->isSwarm) { if ($this->isSwarm) {
$found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first(); $found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first();

View File

@@ -5,12 +5,15 @@ namespace App\Livewire\Destination;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneDocker; use App\Models\StandaloneDocker;
use App\Models\SwarmDocker; use App\Models\SwarmDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
class Show extends Component class Show extends Component
{ {
use AuthorizesRequests;
#[Locked] #[Locked]
public $destination; public $destination;
@@ -63,6 +66,8 @@ class Show extends Component
public function submit() public function submit()
{ {
try { try {
$this->authorize('update', $this->destination);
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Destination saved.'); $this->dispatch('success', 'Destination saved.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -73,6 +78,8 @@ class Show extends Component
public function delete() public function delete()
{ {
try { try {
$this->authorize('delete', $this->destination);
if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) { if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) {
if ($this->destination->attachedTo()) { if ($this->destination->attachedTo()) {
return $this->dispatch('error', 'You must delete all resources before deleting this destination.'); return $this->dispatch('error', 'You must delete all resources before deleting this destination.');

View File

@@ -0,0 +1,372 @@
<?php
namespace App\Livewire;
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
class GlobalSearch extends Component
{
public $searchQuery = '';
public $isModalOpen = false;
public $searchResults = [];
public $allSearchableItems = [];
public function mount()
{
$this->searchQuery = '';
$this->isModalOpen = false;
$this->searchResults = [];
$this->allSearchableItems = [];
}
public function openSearchModal()
{
$this->isModalOpen = true;
$this->loadSearchableItems();
$this->dispatch('search-modal-opened');
}
public function closeSearchModal()
{
$this->isModalOpen = false;
$this->searchQuery = '';
$this->searchResults = [];
}
public static function getCacheKey($teamId)
{
return 'global_search_items_'.$teamId;
}
public static function clearTeamCache($teamId)
{
Cache::forget(self::getCacheKey($teamId));
}
public function updatedSearchQuery()
{
$this->search();
}
private function loadSearchableItems()
{
// Try to get from Redis cache first
$cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
$this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
ray()->showQueries();
$items = collect();
$team = auth()->user()->currentTeam();
// Get all applications
$applications = Application::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($app) {
// Collect all FQDNs from the application
$fqdns = collect([]);
// For regular applications
if ($app->fqdn) {
$fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
}
// For docker compose based applications
if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) {
try {
$composeDomains = json_decode($app->docker_compose_domains, true);
if (is_array($composeDomains)) {
foreach ($composeDomains as $serviceName => $domains) {
if (is_array($domains)) {
$fqdns = $fqdns->merge($domains);
}
}
}
} catch (\Exception $e) {
// Ignore JSON parsing errors
}
}
$fqdnsString = $fqdns->implode(' ');
return [
'id' => $app->id,
'name' => $app->name,
'type' => 'application',
'uuid' => $app->uuid,
'description' => $app->description,
'link' => $app->link(),
'project' => $app->environment->project->name ?? null,
'environment' => $app->environment->name ?? null,
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString),
];
});
// Get all services
$services = Service::ownedByCurrentTeam()
->with(['environment.project', 'applications'])
->get()
->map(function ($service) {
// Collect all FQDNs from service applications
$fqdns = collect([]);
foreach ($service->applications as $app) {
if ($app->fqdn) {
$appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
$fqdns = $fqdns->merge($appFqdns);
}
}
$fqdnsString = $fqdns->implode(' ');
return [
'id' => $service->id,
'name' => $service->name,
'type' => 'service',
'uuid' => $service->uuid,
'description' => $service->description,
'link' => $service->link(),
'project' => $service->environment->project->name ?? null,
'environment' => $service->environment->name ?? null,
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString),
];
});
// Get all standalone databases
$databases = collect();
// PostgreSQL
$databases = $databases->merge(
StandalonePostgresql::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'postgresql',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' postgresql '.$db->description),
];
})
);
// MySQL
$databases = $databases->merge(
StandaloneMysql::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'mysql',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' mysql '.$db->description),
];
})
);
// MariaDB
$databases = $databases->merge(
StandaloneMariadb::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'mariadb',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' mariadb '.$db->description),
];
})
);
// MongoDB
$databases = $databases->merge(
StandaloneMongodb::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'mongodb',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' mongodb '.$db->description),
];
})
);
// Redis
$databases = $databases->merge(
StandaloneRedis::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'redis',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' redis '.$db->description),
];
})
);
// KeyDB
$databases = $databases->merge(
StandaloneKeydb::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'keydb',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' keydb '.$db->description),
];
})
);
// Dragonfly
$databases = $databases->merge(
StandaloneDragonfly::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'dragonfly',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' dragonfly '.$db->description),
];
})
);
// Clickhouse
$databases = $databases->merge(
StandaloneClickhouse::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'clickhouse',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' clickhouse '.$db->description),
];
})
);
// Get all servers
$servers = Server::ownedByCurrentTeam()
->get()
->map(function ($server) {
return [
'id' => $server->id,
'name' => $server->name,
'type' => 'server',
'uuid' => $server->uuid,
'description' => $server->description,
'link' => $server->url(),
'project' => null,
'environment' => null,
'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description),
];
});
// Merge all collections
$items = $items->merge($applications)
->merge($services)
->merge($databases)
->merge($servers);
return $items->toArray();
});
}
private function search()
{
if (strlen($this->searchQuery) < 2) {
$this->searchResults = [];
return;
}
$query = strtolower($this->searchQuery);
// Case-insensitive search in the items
$this->searchResults = collect($this->allSearchableItems)
->filter(function ($item) use ($query) {
return str_contains($item['search_text'], $query);
})
->take(20)
->values()
->toArray();
}
public function render()
{
return view('livewire.global-search');
}
}

View File

@@ -42,7 +42,7 @@ class Help extends Component
'content' => 'User: `'.auth()->user()?->email.'` with subject: `'.$this->subject.'` has the following problem: `'.$this->description.'`', 'content' => 'User: `'.auth()->user()?->email.'` with subject: `'.$this->subject.'` has the following problem: `'.$this->description.'`',
]); ]);
} else { } else {
send_user_an_email($mail, auth()->user()?->email, 'hi@coollabs.io'); send_user_an_email($mail, auth()->user()?->email, 'feedback@coollabs.io');
} }
$this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.'); $this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.');
$this->reset('description', 'subject'); $this->reset('description', 'subject');

View File

@@ -5,11 +5,14 @@ namespace App\Livewire\Notifications;
use App\Models\DiscordNotificationSettings; use App\Models\DiscordNotificationSettings;
use App\Models\Team; use App\Models\Team;
use App\Notifications\Test; use App\Notifications\Test;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
class Discord extends Component class Discord extends Component
{ {
use AuthorizesRequests;
public Team $team; public Team $team;
public DiscordNotificationSettings $settings; public DiscordNotificationSettings $settings;
@@ -67,6 +70,7 @@ class Discord extends Component
try { try {
$this->team = auth()->user()->currentTeam(); $this->team = auth()->user()->currentTeam();
$this->settings = $this->team->discordNotificationSettings; $this->settings = $this->team->discordNotificationSettings;
$this->authorize('view', $this->settings);
$this->syncData(); $this->syncData();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@@ -77,6 +81,7 @@ class Discord extends Component
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
$this->authorize('update', $this->settings);
$this->settings->discord_enabled = $this->discordEnabled; $this->settings->discord_enabled = $this->discordEnabled;
$this->settings->discord_webhook_url = $this->discordWebhookUrl; $this->settings->discord_webhook_url = $this->discordWebhookUrl;
@@ -182,6 +187,7 @@ class Discord extends Component
public function sendTestNotification() public function sendTestNotification()
{ {
try { try {
$this->authorize('sendTest', $this->settings);
$this->team->notify(new Test(channel: 'discord')); $this->team->notify(new Test(channel: 'discord'));
$this->dispatch('success', 'Test notification sent.'); $this->dispatch('success', 'Test notification sent.');
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@@ -5,6 +5,7 @@ namespace App\Livewire\Notifications;
use App\Models\EmailNotificationSettings; use App\Models\EmailNotificationSettings;
use App\Models\Team; use App\Models\Team;
use App\Notifications\Test; use App\Notifications\Test;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
@@ -12,6 +13,8 @@ use Livewire\Component;
class Email extends Component class Email extends Component
{ {
use AuthorizesRequests;
protected $listeners = ['refresh' => '$refresh']; protected $listeners = ['refresh' => '$refresh'];
#[Locked] #[Locked]
@@ -110,6 +113,7 @@ class Email extends Component
$this->team = auth()->user()->currentTeam(); $this->team = auth()->user()->currentTeam();
$this->emails = auth()->user()->email; $this->emails = auth()->user()->email;
$this->settings = $this->team->emailNotificationSettings; $this->settings = $this->team->emailNotificationSettings;
$this->authorize('view', $this->settings);
$this->syncData(); $this->syncData();
$this->testEmailAddress = auth()->user()->email; $this->testEmailAddress = auth()->user()->email;
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -121,6 +125,7 @@ class Email extends Component
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
$this->authorize('update', $this->settings);
$this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_address = $this->smtpFromAddress;
$this->settings->smtp_from_name = $this->smtpFromName; $this->settings->smtp_from_name = $this->smtpFromName;
@@ -311,6 +316,7 @@ class Email extends Component
public function sendTestEmail() public function sendTestEmail()
{ {
try { try {
$this->authorize('sendTest', $this->settings);
$this->validate([ $this->validate([
'testEmailAddress' => 'required|email', 'testEmailAddress' => 'required|email',
], [ ], [
@@ -338,6 +344,7 @@ class Email extends Component
public function copyFromInstanceSettings() public function copyFromInstanceSettings()
{ {
$this->authorize('update', $this->settings);
$settings = instanceSettings(); $settings = instanceSettings();
$this->smtpFromAddress = $settings->smtp_from_address; $this->smtpFromAddress = $settings->smtp_from_address;
$this->smtpFromName = $settings->smtp_from_name; $this->smtpFromName = $settings->smtp_from_name;

View File

@@ -5,12 +5,15 @@ namespace App\Livewire\Notifications;
use App\Models\PushoverNotificationSettings; use App\Models\PushoverNotificationSettings;
use App\Models\Team; use App\Models\Team;
use App\Notifications\Test; use App\Notifications\Test;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
class Pushover extends Component class Pushover extends Component
{ {
use AuthorizesRequests;
protected $listeners = ['refresh' => '$refresh']; protected $listeners = ['refresh' => '$refresh'];
#[Locked] #[Locked]
@@ -72,6 +75,7 @@ class Pushover extends Component
try { try {
$this->team = auth()->user()->currentTeam(); $this->team = auth()->user()->currentTeam();
$this->settings = $this->team->pushoverNotificationSettings; $this->settings = $this->team->pushoverNotificationSettings;
$this->authorize('view', $this->settings);
$this->syncData(); $this->syncData();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@@ -82,6 +86,7 @@ class Pushover extends Component
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
$this->authorize('update', $this->settings);
$this->settings->pushover_enabled = $this->pushoverEnabled; $this->settings->pushover_enabled = $this->pushoverEnabled;
$this->settings->pushover_user_key = $this->pushoverUserKey; $this->settings->pushover_user_key = $this->pushoverUserKey;
$this->settings->pushover_api_token = $this->pushoverApiToken; $this->settings->pushover_api_token = $this->pushoverApiToken;
@@ -175,6 +180,7 @@ class Pushover extends Component
public function sendTestNotification() public function sendTestNotification()
{ {
try { try {
$this->authorize('sendTest', $this->settings);
$this->team->notify(new Test(channel: 'pushover')); $this->team->notify(new Test(channel: 'pushover'));
$this->dispatch('success', 'Test notification sent.'); $this->dispatch('success', 'Test notification sent.');
} catch (\Throwable $e) { } catch (\Throwable $e) {

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