Merge branch 'next' into feat/manage-db-using-api
This commit is contained in:
11
.cursor/mcp.json
Normal file
11
.cursor/mcp.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
293
.cursor/rules/README.mdc
Normal file
293
.cursor/rules/README.mdc
Normal file
@@ -0,0 +1,293 @@
|
||||
---
|
||||
description: Complete guide to Coolify Cursor rules and development patterns
|
||||
globs: .cursor/rules/*.mdc
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Cursor Rules - Complete Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This comprehensive set of Cursor Rules provides deep insights into **Coolify**, an open-source self-hostable alternative to Heroku/Netlify/Vercel. These rules will help you understand, navigate, and contribute to this complex Laravel-based deployment platform.
|
||||
|
||||
## Rule Categories
|
||||
|
||||
### 🏗️ Architecture & Foundation
|
||||
- **[project-overview.mdc](mdc:.cursor/rules/project-overview.mdc)** - What Coolify is and its core mission
|
||||
- **[technology-stack.mdc](mdc:.cursor/rules/technology-stack.mdc)** - Complete technology stack and dependencies
|
||||
- **[application-architecture.mdc](mdc:.cursor/rules/application-architecture.mdc)** - Laravel application structure and patterns
|
||||
|
||||
### 🎨 Frontend Development
|
||||
- **[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
|
||||
- **[database-patterns.mdc](mdc:.cursor/rules/database-patterns.mdc)** - Database architecture, models, and data management
|
||||
- **[deployment-architecture.mdc](mdc:.cursor/rules/deployment-architecture.mdc)** - Docker orchestration and deployment workflows
|
||||
|
||||
### 🌐 API & Communication
|
||||
- **[api-and-routing.mdc](mdc:.cursor/rules/api-and-routing.mdc)** - RESTful APIs, webhooks, and routing patterns
|
||||
|
||||
### 🧪 Quality Assurance
|
||||
- **[testing-patterns.mdc](mdc:.cursor/rules/testing-patterns.mdc)** - Testing strategies with Pest PHP and Laravel Dusk
|
||||
|
||||
### 🔧 Development Process
|
||||
- **[development-workflow.mdc](mdc:.cursor/rules/development-workflow.mdc)** - Development setup, coding standards, and contribution guidelines
|
||||
|
||||
### 🔒 Security
|
||||
- **[security-patterns.mdc](mdc:.cursor/rules/security-patterns.mdc)** - Security architecture, authentication, and best practices
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
### Core Application Files
|
||||
- **[app/Models/Application.php](mdc:app/Models/Application.php)** - Main application entity (74KB, highly complex)
|
||||
- **[app/Models/Server.php](mdc:app/Models/Server.php)** - Server management (46KB, complex)
|
||||
- **[app/Models/Service.php](mdc:app/Models/Service.php)** - Service definitions (58KB, complex)
|
||||
- **[app/Models/Team.php](mdc:app/Models/Team.php)** - Multi-tenant structure (8.9KB)
|
||||
|
||||
### Configuration Files
|
||||
- **[composer.json](mdc:composer.json)** - PHP dependencies and Laravel setup
|
||||
- **[package.json](mdc:package.json)** - Frontend dependencies and build scripts
|
||||
- **[vite.config.js](mdc:vite.config.js)** - Frontend build configuration
|
||||
- **[docker-compose.dev.yml](mdc:docker-compose.dev.yml)** - Development environment
|
||||
|
||||
### API Documentation
|
||||
- **[openapi.json](mdc:openapi.json)** - Complete API documentation (373KB)
|
||||
- **[routes/api.php](mdc:routes/api.php)** - API endpoint definitions (13KB)
|
||||
- **[routes/web.php](mdc:routes/web.php)** - Web application routes (21KB)
|
||||
|
||||
## Key Concepts to Understand
|
||||
|
||||
### 1. Multi-Tenant Architecture
|
||||
Coolify uses a **team-based multi-tenancy** model where:
|
||||
- Users belong to multiple teams
|
||||
- Resources are scoped to teams
|
||||
- Access control is team-based
|
||||
- Data isolation is enforced at the database level
|
||||
|
||||
### 2. Deployment Philosophy
|
||||
- **Docker-first** approach for all deployments
|
||||
- **Zero-downtime** deployments with health checks
|
||||
- **Git-based** workflows with webhook integration
|
||||
- **Multi-server** support with SSH connections
|
||||
|
||||
### 3. Technology Stack
|
||||
- **Backend**: Laravel 11 + PHP 8.4
|
||||
- **Frontend**: Livewire 3.5 + Alpine.js + Tailwind CSS 4.1
|
||||
- **Database**: PostgreSQL 15 + Redis 7
|
||||
- **Containerization**: Docker + Docker Compose
|
||||
- **Testing**: Pest PHP 3.8 + Laravel Dusk
|
||||
|
||||
### 4. Security Model
|
||||
- **Defense-in-depth** security architecture
|
||||
- **OAuth integration** with multiple providers
|
||||
- **API token** authentication with Sanctum
|
||||
- **Encrypted storage** for sensitive data
|
||||
- **SSH key** management for server access
|
||||
|
||||
## Development Quick Start
|
||||
|
||||
### Local Setup
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone https://github.com/coollabsio/coolify.git
|
||||
cd coolify
|
||||
cp .env.example .env
|
||||
|
||||
# Docker development (recommended)
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
docker-compose exec app composer install
|
||||
docker-compose exec app npm install
|
||||
docker-compose exec app php artisan migrate
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# PHP code style
|
||||
./vendor/bin/pint
|
||||
|
||||
# Static analysis
|
||||
./vendor/bin/phpstan analyse
|
||||
|
||||
# Run tests
|
||||
./vendor/bin/pest
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Livewire Components
|
||||
```php
|
||||
class ApplicationShow extends Component
|
||||
{
|
||||
public Application $application;
|
||||
|
||||
protected $listeners = [
|
||||
'deployment.started' => 'refresh',
|
||||
'deployment.completed' => 'refresh',
|
||||
];
|
||||
|
||||
public function deploy(): void
|
||||
{
|
||||
$this->authorize('deploy', $this->application);
|
||||
app(ApplicationDeploymentService::class)->deploy($this->application);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Controllers
|
||||
```php
|
||||
class ApplicationController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth:sanctum');
|
||||
$this->middleware('team.access');
|
||||
}
|
||||
|
||||
public function deploy(Application $application): JsonResponse
|
||||
{
|
||||
$this->authorize('deploy', $application);
|
||||
$deployment = app(ApplicationDeploymentService::class)->deploy($application);
|
||||
return response()->json(['deployment_id' => $deployment->id]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Queue Jobs
|
||||
```php
|
||||
class DeployApplicationJob implements ShouldQueue
|
||||
{
|
||||
public function handle(DockerService $dockerService): void
|
||||
{
|
||||
$this->deployment->update(['status' => 'running']);
|
||||
|
||||
try {
|
||||
$dockerService->deployContainer($this->deployment->application);
|
||||
$this->deployment->update(['status' => 'success']);
|
||||
} catch (Exception $e) {
|
||||
$this->deployment->update(['status' => 'failed']);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Feature Tests
|
||||
```php
|
||||
test('user can deploy application via API', function () {
|
||||
$user = User::factory()->create();
|
||||
$application = Application::factory()->create(['team_id' => $user->currentTeam->id]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson("/api/v1/applications/{$application->id}/deploy");
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect($application->deployments()->count())->toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
### Browser Tests
|
||||
```php
|
||||
test('user can create application through UI', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user) {
|
||||
$browser->loginAs($user)
|
||||
->visit('/applications/create')
|
||||
->type('name', 'Test App')
|
||||
->press('Create Application')
|
||||
->assertSee('Application created successfully');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication
|
||||
- Multi-provider OAuth support
|
||||
- API token authentication
|
||||
- Team-based access control
|
||||
- Session management
|
||||
|
||||
### Data Protection
|
||||
- Encrypted environment variables
|
||||
- Secure SSH key storage
|
||||
- Input validation and sanitization
|
||||
- SQL injection prevention
|
||||
|
||||
### Container Security
|
||||
- Non-root container users
|
||||
- Minimal capabilities
|
||||
- Read-only filesystems
|
||||
- Network isolation
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Database
|
||||
- Eager loading relationships
|
||||
- Query optimization
|
||||
- Connection pooling
|
||||
- Caching strategies
|
||||
|
||||
### Frontend
|
||||
- Lazy loading components
|
||||
- Asset optimization
|
||||
- CDN integration
|
||||
- Real-time updates via WebSockets
|
||||
|
||||
## Contributing Guidelines
|
||||
|
||||
### Code Standards
|
||||
- PSR-12 PHP coding standards
|
||||
- Laravel best practices
|
||||
- Comprehensive test coverage
|
||||
- Security-first approach
|
||||
|
||||
### Pull Request Process
|
||||
1. Fork repository
|
||||
2. Create feature branch
|
||||
3. Implement with tests
|
||||
4. Run quality checks
|
||||
5. Submit PR with clear description
|
||||
|
||||
## Useful Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Start development environment
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# Run tests
|
||||
./vendor/bin/pest
|
||||
|
||||
# Code formatting
|
||||
./vendor/bin/pint
|
||||
|
||||
# Frontend development
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
# Install Coolify
|
||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||
|
||||
# Update Coolify
|
||||
./scripts/upgrade.sh
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
### Documentation
|
||||
- **[README.md](mdc:README.md)** - Project overview and installation
|
||||
- **[CONTRIBUTING.md](mdc:CONTRIBUTING.md)** - Contribution guidelines
|
||||
- **[CHANGELOG.md](mdc:CHANGELOG.md)** - Release history
|
||||
- **[TECH_STACK.md](mdc:TECH_STACK.md)** - Technology overview
|
||||
|
||||
### Configuration
|
||||
- **[config/](mdc:config)** - Laravel configuration files
|
||||
- **[database/migrations/](mdc:database/migrations)** - Database schema
|
||||
- **[tests/](mdc:tests)** - Test suite
|
||||
|
||||
This comprehensive rule set provides everything needed to understand, develop, and contribute to the Coolify project effectively. Each rule focuses on specific aspects while maintaining connections to the broader architecture.
|
||||
474
.cursor/rules/api-and-routing.mdc
Normal file
474
.cursor/rules/api-and-routing.mdc
Normal file
@@ -0,0 +1,474 @@
|
||||
---
|
||||
description: RESTful API design, routing patterns, webhooks, and HTTP communication
|
||||
globs: routes/*.php, app/Http/Controllers/**/*.php, app/Http/Resources/*.php, app/Http/Requests/*.php
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify API & Routing Architecture
|
||||
|
||||
## Routing Structure
|
||||
|
||||
Coolify implements **multi-layered routing** with web interfaces, RESTful APIs, webhook endpoints, and real-time communication channels.
|
||||
|
||||
## Route Files
|
||||
|
||||
### Core Route Definitions
|
||||
- **[routes/web.php](mdc:routes/web.php)** - Web application routes (21KB, 362 lines)
|
||||
- **[routes/api.php](mdc:routes/api.php)** - RESTful API endpoints (13KB, 185 lines)
|
||||
- **[routes/webhooks.php](mdc:routes/webhooks.php)** - Webhook receivers (815B, 22 lines)
|
||||
- **[routes/channels.php](mdc:routes/channels.php)** - WebSocket channel definitions (829B, 33 lines)
|
||||
- **[routes/console.php](mdc:routes/console.php)** - Artisan command routes (592B, 20 lines)
|
||||
|
||||
## Web Application Routing
|
||||
|
||||
### Authentication Routes
|
||||
```php
|
||||
// Laravel Fortify authentication
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('/login', [AuthController::class, 'login']);
|
||||
Route::get('/register', [AuthController::class, 'register']);
|
||||
Route::get('/forgot-password', [AuthController::class, 'forgotPassword']);
|
||||
});
|
||||
```
|
||||
|
||||
### Dashboard & Core Features
|
||||
```php
|
||||
// Main application routes
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/dashboard', Dashboard::class)->name('dashboard');
|
||||
Route::get('/projects', ProjectIndex::class)->name('projects');
|
||||
Route::get('/servers', ServerIndex::class)->name('servers');
|
||||
Route::get('/teams', TeamIndex::class)->name('teams');
|
||||
});
|
||||
```
|
||||
|
||||
### Resource Management Routes
|
||||
```php
|
||||
// Server management
|
||||
Route::prefix('servers')->group(function () {
|
||||
Route::get('/{server}', ServerShow::class)->name('server.show');
|
||||
Route::get('/{server}/edit', ServerEdit::class)->name('server.edit');
|
||||
Route::get('/{server}/logs', ServerLogs::class)->name('server.logs');
|
||||
});
|
||||
|
||||
// Application management
|
||||
Route::prefix('applications')->group(function () {
|
||||
Route::get('/{application}', ApplicationShow::class)->name('application.show');
|
||||
Route::get('/{application}/deployments', ApplicationDeployments::class);
|
||||
Route::get('/{application}/environment-variables', ApplicationEnvironmentVariables::class);
|
||||
Route::get('/{application}/logs', ApplicationLogs::class);
|
||||
});
|
||||
```
|
||||
|
||||
## RESTful API Architecture
|
||||
|
||||
### API Versioning
|
||||
```php
|
||||
// API route structure
|
||||
Route::prefix('v1')->group(function () {
|
||||
// Application endpoints
|
||||
Route::apiResource('applications', ApplicationController::class);
|
||||
Route::apiResource('servers', ServerController::class);
|
||||
Route::apiResource('teams', TeamController::class);
|
||||
});
|
||||
```
|
||||
|
||||
### Authentication & Authorization
|
||||
```php
|
||||
// Sanctum API authentication
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::get('/user', function (Request $request) {
|
||||
return $request->user();
|
||||
});
|
||||
|
||||
// Team-scoped resources
|
||||
Route::middleware('team.access')->group(function () {
|
||||
Route::apiResource('applications', ApplicationController::class);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Application Management API
|
||||
```php
|
||||
// Application CRUD operations
|
||||
Route::prefix('applications')->group(function () {
|
||||
Route::get('/', [ApplicationController::class, 'index']);
|
||||
Route::post('/', [ApplicationController::class, 'store']);
|
||||
Route::get('/{application}', [ApplicationController::class, 'show']);
|
||||
Route::patch('/{application}', [ApplicationController::class, 'update']);
|
||||
Route::delete('/{application}', [ApplicationController::class, 'destroy']);
|
||||
|
||||
// Deployment operations
|
||||
Route::post('/{application}/deploy', [ApplicationController::class, 'deploy']);
|
||||
Route::post('/{application}/restart', [ApplicationController::class, 'restart']);
|
||||
Route::post('/{application}/stop', [ApplicationController::class, 'stop']);
|
||||
Route::get('/{application}/logs', [ApplicationController::class, 'logs']);
|
||||
});
|
||||
```
|
||||
|
||||
### Server Management API
|
||||
```php
|
||||
// Server operations
|
||||
Route::prefix('servers')->group(function () {
|
||||
Route::get('/', [ServerController::class, 'index']);
|
||||
Route::post('/', [ServerController::class, 'store']);
|
||||
Route::get('/{server}', [ServerController::class, 'show']);
|
||||
Route::patch('/{server}', [ServerController::class, 'update']);
|
||||
Route::delete('/{server}', [ServerController::class, 'destroy']);
|
||||
|
||||
// Server actions
|
||||
Route::post('/{server}/validate', [ServerController::class, 'validate']);
|
||||
Route::get('/{server}/usage', [ServerController::class, 'usage']);
|
||||
Route::post('/{server}/cleanup', [ServerController::class, 'cleanup']);
|
||||
});
|
||||
```
|
||||
|
||||
### Database Management API
|
||||
```php
|
||||
// Database operations
|
||||
Route::prefix('databases')->group(function () {
|
||||
Route::get('/', [DatabaseController::class, 'index']);
|
||||
Route::post('/', [DatabaseController::class, 'store']);
|
||||
Route::get('/{database}', [DatabaseController::class, 'show']);
|
||||
Route::patch('/{database}', [DatabaseController::class, 'update']);
|
||||
Route::delete('/{database}', [DatabaseController::class, 'destroy']);
|
||||
|
||||
// Database actions
|
||||
Route::post('/{database}/backup', [DatabaseController::class, 'backup']);
|
||||
Route::post('/{database}/restore', [DatabaseController::class, 'restore']);
|
||||
Route::get('/{database}/logs', [DatabaseController::class, 'logs']);
|
||||
});
|
||||
```
|
||||
|
||||
## Webhook Architecture
|
||||
|
||||
### Git Integration Webhooks
|
||||
```php
|
||||
// GitHub webhook endpoints
|
||||
Route::post('/webhooks/github/{application}', [GitHubWebhookController::class, 'handle'])
|
||||
->name('webhooks.github');
|
||||
|
||||
// GitLab webhook endpoints
|
||||
Route::post('/webhooks/gitlab/{application}', [GitLabWebhookController::class, 'handle'])
|
||||
->name('webhooks.gitlab');
|
||||
|
||||
// Generic Git webhooks
|
||||
Route::post('/webhooks/git/{application}', [GitWebhookController::class, 'handle'])
|
||||
->name('webhooks.git');
|
||||
```
|
||||
|
||||
### Deployment Webhooks
|
||||
```php
|
||||
// Deployment status webhooks
|
||||
Route::post('/webhooks/deployment/{deployment}/success', [DeploymentWebhookController::class, 'success']);
|
||||
Route::post('/webhooks/deployment/{deployment}/failure', [DeploymentWebhookController::class, 'failure']);
|
||||
Route::post('/webhooks/deployment/{deployment}/progress', [DeploymentWebhookController::class, 'progress']);
|
||||
```
|
||||
|
||||
### Third-Party Integration Webhooks
|
||||
```php
|
||||
// Monitoring webhooks
|
||||
Route::post('/webhooks/monitoring/{server}', [MonitoringWebhookController::class, 'handle']);
|
||||
|
||||
// Backup status webhooks
|
||||
Route::post('/webhooks/backup/{backup}', [BackupWebhookController::class, 'handle']);
|
||||
|
||||
// SSL certificate webhooks
|
||||
Route::post('/webhooks/ssl/{certificate}', [SslWebhookController::class, 'handle']);
|
||||
```
|
||||
|
||||
## WebSocket Channel Definitions
|
||||
|
||||
### Real-Time Channels
|
||||
```php
|
||||
// Private channels for team members
|
||||
Broadcast::channel('team.{teamId}', function ($user, $teamId) {
|
||||
return $user->teams->contains('id', $teamId);
|
||||
});
|
||||
|
||||
// Application deployment channels
|
||||
Broadcast::channel('application.{applicationId}', function ($user, $applicationId) {
|
||||
return $user->hasAccessToApplication($applicationId);
|
||||
});
|
||||
|
||||
// Server monitoring channels
|
||||
Broadcast::channel('server.{serverId}', function ($user, $serverId) {
|
||||
return $user->hasAccessToServer($serverId);
|
||||
});
|
||||
```
|
||||
|
||||
### Presence Channels
|
||||
```php
|
||||
// Team collaboration presence
|
||||
Broadcast::channel('team.{teamId}.presence', function ($user, $teamId) {
|
||||
if ($user->teams->contains('id', $teamId)) {
|
||||
return ['id' => $user->id, 'name' => $user->name];
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Controllers
|
||||
|
||||
### Location: [app/Http/Controllers/Api/](mdc:app/Http/Controllers)
|
||||
|
||||
#### Resource Controllers
|
||||
```php
|
||||
class ApplicationController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
return ApplicationResource::collection(
|
||||
$request->user()->currentTeam->applications()
|
||||
->with(['server', 'environment'])
|
||||
->paginate()
|
||||
);
|
||||
}
|
||||
|
||||
public function store(StoreApplicationRequest $request)
|
||||
{
|
||||
$application = $request->user()->currentTeam
|
||||
->applications()
|
||||
->create($request->validated());
|
||||
|
||||
return new ApplicationResource($application);
|
||||
}
|
||||
|
||||
public function deploy(Application $application)
|
||||
{
|
||||
$deployment = $application->deploy();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Deployment started',
|
||||
'deployment_id' => $deployment->id
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Responses & Resources
|
||||
```php
|
||||
// API Resource classes
|
||||
class ApplicationResource extends JsonResource
|
||||
{
|
||||
public function toArray($request)
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'fqdn' => $this->fqdn,
|
||||
'status' => $this->status,
|
||||
'git_repository' => $this->git_repository,
|
||||
'git_branch' => $this->git_branch,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
'server' => new ServerResource($this->whenLoaded('server')),
|
||||
'environment' => new EnvironmentResource($this->whenLoaded('environment')),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Authentication
|
||||
|
||||
### Sanctum Token Authentication
|
||||
```php
|
||||
// API token generation
|
||||
Route::post('/auth/tokens', function (Request $request) {
|
||||
$request->validate([
|
||||
'name' => 'required|string',
|
||||
'abilities' => 'array'
|
||||
]);
|
||||
|
||||
$token = $request->user()->createToken(
|
||||
$request->name,
|
||||
$request->abilities ?? []
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'token' => $token->plainTextToken,
|
||||
'abilities' => $token->accessToken->abilities
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
### Team-Based Authorization
|
||||
```php
|
||||
// Team access middleware
|
||||
class EnsureTeamAccess
|
||||
{
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
$teamId = $request->route('team');
|
||||
|
||||
if (!$request->user()->teams->contains('id', $teamId)) {
|
||||
abort(403, 'Access denied to team resources');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### API Rate Limits
|
||||
```php
|
||||
// API throttling configuration
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
|
||||
// Deployment rate limiting
|
||||
RateLimiter::for('deployments', function (Request $request) {
|
||||
return Limit::perMinute(10)->by($request->user()->id);
|
||||
});
|
||||
```
|
||||
|
||||
### Webhook Rate Limiting
|
||||
```php
|
||||
// Webhook throttling
|
||||
RateLimiter::for('webhooks', function (Request $request) {
|
||||
return Limit::perMinute(100)->by($request->ip());
|
||||
});
|
||||
```
|
||||
|
||||
## Route Model Binding
|
||||
|
||||
### Custom Route Bindings
|
||||
```php
|
||||
// Custom model binding for applications
|
||||
Route::bind('application', function ($value) {
|
||||
return Application::where('uuid', $value)
|
||||
->orWhere('id', $value)
|
||||
->firstOrFail();
|
||||
});
|
||||
|
||||
// Team-scoped model binding
|
||||
Route::bind('team_application', function ($value, $route) {
|
||||
$teamId = $route->parameter('team');
|
||||
return Application::whereHas('environment.project', function ($query) use ($teamId) {
|
||||
$query->where('team_id', $teamId);
|
||||
})->findOrFail($value);
|
||||
});
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
### OpenAPI Specification
|
||||
- **[openapi.json](mdc:openapi.json)** - API documentation (373KB, 8316 lines)
|
||||
- **[openapi.yaml](mdc:openapi.yaml)** - YAML format documentation (184KB, 5579 lines)
|
||||
|
||||
### Documentation Generation
|
||||
```php
|
||||
// Swagger/OpenAPI annotations
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/applications",
|
||||
* summary="List applications",
|
||||
* tags={"Applications"},
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="List of applications",
|
||||
* @OA\JsonContent(type="array", @OA\Items(ref="#/components/schemas/Application"))
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### API Error Responses
|
||||
```php
|
||||
// Standardized error response format
|
||||
class ApiExceptionHandler
|
||||
{
|
||||
public function render($request, Throwable $exception)
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
'error_code' => $this->getErrorCode($exception),
|
||||
'timestamp' => now()->toISOString()
|
||||
], $this->getStatusCode($exception));
|
||||
}
|
||||
|
||||
return parent::render($request, $exception);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Error Handling
|
||||
```php
|
||||
// Form request validation
|
||||
class StoreApplicationRequest extends FormRequest
|
||||
{
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'git_repository' => 'required|url',
|
||||
'git_branch' => 'required|string',
|
||||
'server_id' => 'required|exists:servers,id',
|
||||
'environment_id' => 'required|exists:environments,id'
|
||||
];
|
||||
}
|
||||
|
||||
public function failedValidation(Validator $validator)
|
||||
{
|
||||
throw new HttpResponseException(
|
||||
response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $validator->errors()
|
||||
], 422)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Real-Time API Integration
|
||||
|
||||
### WebSocket Events
|
||||
```php
|
||||
// Broadcasting deployment events
|
||||
class DeploymentStarted implements ShouldBroadcast
|
||||
{
|
||||
public $application;
|
||||
public $deployment;
|
||||
|
||||
public function broadcastOn()
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("application.{$this->application->id}"),
|
||||
new PrivateChannel("team.{$this->application->team->id}")
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastWith()
|
||||
{
|
||||
return [
|
||||
'deployment_id' => $this->deployment->id,
|
||||
'status' => 'started',
|
||||
'timestamp' => now()
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Event Streaming
|
||||
```php
|
||||
// Server-Sent Events for real-time updates
|
||||
Route::get('/api/v1/applications/{application}/events', function (Application $application) {
|
||||
return response()->stream(function () use ($application) {
|
||||
while (true) {
|
||||
$events = $application->getRecentEvents();
|
||||
foreach ($events as $event) {
|
||||
echo "data: " . json_encode($event) . "\n\n";
|
||||
}
|
||||
usleep(1000000); // 1 second
|
||||
}
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/event-stream',
|
||||
'Cache-Control' => 'no-cache',
|
||||
]);
|
||||
});
|
||||
```
|
||||
368
.cursor/rules/application-architecture.mdc
Normal file
368
.cursor/rules/application-architecture.mdc
Normal file
@@ -0,0 +1,368 @@
|
||||
---
|
||||
description: Laravel application structure, patterns, and architectural decisions
|
||||
globs: app/**/*.php, config/*.php, bootstrap/**/*.php
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Application Architecture
|
||||
|
||||
## Laravel Project Structure
|
||||
|
||||
### **Core Application Directory** ([app/](mdc:app))
|
||||
|
||||
```
|
||||
app/
|
||||
├── Actions/ # Business logic actions (Action pattern)
|
||||
├── Console/ # Artisan commands
|
||||
├── Contracts/ # Interface definitions
|
||||
├── Data/ # Data Transfer Objects (Spatie Laravel Data)
|
||||
├── Enums/ # Enumeration classes
|
||||
├── Events/ # Event classes
|
||||
├── Exceptions/ # Custom exception classes
|
||||
├── Helpers/ # Utility helper classes
|
||||
├── Http/ # HTTP layer (Controllers, Middleware, Requests)
|
||||
├── Jobs/ # Background job classes
|
||||
├── Listeners/ # Event listeners
|
||||
├── Livewire/ # Livewire components (Frontend)
|
||||
├── Models/ # Eloquent models (Domain entities)
|
||||
├── Notifications/ # Notification classes
|
||||
├── Policies/ # Authorization policies
|
||||
├── Providers/ # Service providers
|
||||
├── Repositories/ # Repository pattern implementations
|
||||
├── Services/ # Service layer classes
|
||||
├── Traits/ # Reusable trait classes
|
||||
└── View/ # View composers and creators
|
||||
```
|
||||
|
||||
## Core Domain Models
|
||||
|
||||
### **Infrastructure Management**
|
||||
|
||||
#### **[Server.php](mdc:app/Models/Server.php)** (46KB, 1343 lines)
|
||||
- **Purpose**: Physical/virtual server management
|
||||
- **Key Relationships**:
|
||||
- `hasMany(Application::class)` - Deployed applications
|
||||
- `hasMany(StandalonePostgresql::class)` - Database instances
|
||||
- `belongsTo(Team::class)` - Team ownership
|
||||
- **Key Features**:
|
||||
- SSH connection management
|
||||
- Resource monitoring
|
||||
- Proxy configuration (Traefik/Caddy)
|
||||
- Docker daemon interaction
|
||||
|
||||
#### **[Application.php](mdc:app/Models/Application.php)** (74KB, 1734 lines)
|
||||
- **Purpose**: Application deployment and management
|
||||
- **Key Relationships**:
|
||||
- `belongsTo(Server::class)` - Deployment target
|
||||
- `belongsTo(Environment::class)` - Environment context
|
||||
- `hasMany(ApplicationDeploymentQueue::class)` - Deployment history
|
||||
- **Key Features**:
|
||||
- Git repository integration
|
||||
- Docker build and deployment
|
||||
- Environment variable management
|
||||
- SSL certificate handling
|
||||
|
||||
#### **[Service.php](mdc:app/Models/Service.php)** (58KB, 1325 lines)
|
||||
- **Purpose**: Multi-container service orchestration
|
||||
- **Key Relationships**:
|
||||
- `hasMany(ServiceApplication::class)` - Service components
|
||||
- `hasMany(ServiceDatabase::class)` - Service databases
|
||||
- `belongsTo(Environment::class)` - Environment context
|
||||
- **Key Features**:
|
||||
- Docker Compose generation
|
||||
- Service dependency management
|
||||
- Health check configuration
|
||||
|
||||
### **Team & Project Organization**
|
||||
|
||||
#### **[Team.php](mdc:app/Models/Team.php)** (8.9KB, 308 lines)
|
||||
- **Purpose**: Multi-tenant team management
|
||||
- **Key Relationships**:
|
||||
- `hasMany(User::class)` - Team members
|
||||
- `hasMany(Project::class)` - Team projects
|
||||
- `hasMany(Server::class)` - Team servers
|
||||
- **Key Features**:
|
||||
- Resource limits and quotas
|
||||
- Team-based access control
|
||||
- Subscription management
|
||||
|
||||
#### **[Project.php](mdc:app/Models/Project.php)** (4.3KB, 156 lines)
|
||||
- **Purpose**: Project organization and grouping
|
||||
- **Key Relationships**:
|
||||
- `hasMany(Environment::class)` - Project environments
|
||||
- `belongsTo(Team::class)` - Team ownership
|
||||
- **Key Features**:
|
||||
- Environment isolation
|
||||
- Resource organization
|
||||
|
||||
#### **[Environment.php](mdc:app/Models/Environment.php)**
|
||||
- **Purpose**: Environment-specific configuration
|
||||
- **Key Relationships**:
|
||||
- `hasMany(Application::class)` - Environment applications
|
||||
- `hasMany(Service::class)` - Environment services
|
||||
- `belongsTo(Project::class)` - Project context
|
||||
|
||||
### **Database Management Models**
|
||||
|
||||
#### **Standalone Database Models**
|
||||
- **[StandalonePostgresql.php](mdc:app/Models/StandalonePostgresql.php)** (11KB, 351 lines)
|
||||
- **[StandaloneMysql.php](mdc:app/Models/StandaloneMysql.php)** (11KB, 351 lines)
|
||||
- **[StandaloneMariadb.php](mdc:app/Models/StandaloneMariadb.php)** (10KB, 337 lines)
|
||||
- **[StandaloneMongodb.php](mdc:app/Models/StandaloneMongodb.php)** (12KB, 370 lines)
|
||||
- **[StandaloneRedis.php](mdc:app/Models/StandaloneRedis.php)** (12KB, 394 lines)
|
||||
- **[StandaloneKeydb.php](mdc:app/Models/StandaloneKeydb.php)** (11KB, 347 lines)
|
||||
- **[StandaloneDragonfly.php](mdc:app/Models/StandaloneDragonfly.php)** (11KB, 347 lines)
|
||||
- **[StandaloneClickhouse.php](mdc:app/Models/StandaloneClickhouse.php)** (10KB, 336 lines)
|
||||
|
||||
**Common Features**:
|
||||
- Database configuration management
|
||||
- Backup scheduling and execution
|
||||
- Connection string generation
|
||||
- Health monitoring
|
||||
|
||||
### **Configuration & Settings**
|
||||
|
||||
#### **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** (7.6KB, 219 lines)
|
||||
- **Purpose**: Application environment variable management
|
||||
- **Key Features**:
|
||||
- Encrypted value storage
|
||||
- Build-time vs runtime variables
|
||||
- Shared variable inheritance
|
||||
|
||||
#### **[InstanceSettings.php](mdc:app/Models/InstanceSettings.php)** (3.2KB, 124 lines)
|
||||
- **Purpose**: Global Coolify instance configuration
|
||||
- **Key Features**:
|
||||
- FQDN and port configuration
|
||||
- Auto-update settings
|
||||
- Security configurations
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### **Action Pattern** ([app/Actions/](mdc:app/Actions))
|
||||
|
||||
Using [lorisleiva/laravel-actions](mdc:composer.json) for business logic encapsulation:
|
||||
|
||||
```php
|
||||
// Example Action structure
|
||||
class DeployApplication extends Action
|
||||
{
|
||||
public function handle(Application $application): void
|
||||
{
|
||||
// Business logic for deployment
|
||||
}
|
||||
|
||||
public function asJob(Application $application): void
|
||||
{
|
||||
// Queue job implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Action Categories**:
|
||||
- **Application/**: Deployment and management actions
|
||||
- **Database/**: Database operations
|
||||
- **Server/**: Server management actions
|
||||
- **Service/**: Service orchestration actions
|
||||
|
||||
### **Repository Pattern** ([app/Repositories/](mdc:app/Repositories))
|
||||
|
||||
Data access abstraction layer:
|
||||
- Encapsulates database queries
|
||||
- Provides testable data layer
|
||||
- Abstracts complex query logic
|
||||
|
||||
### **Service Layer** ([app/Services/](mdc:app/Services))
|
||||
|
||||
Business logic services:
|
||||
- External API integrations
|
||||
- Complex business operations
|
||||
- Cross-cutting concerns
|
||||
|
||||
## Data Flow Architecture
|
||||
|
||||
### **Request Lifecycle**
|
||||
|
||||
1. **HTTP Request** → [routes/web.php](mdc:routes/web.php)
|
||||
2. **Middleware** → Authentication, authorization
|
||||
3. **Livewire Component** → [app/Livewire/](mdc:app/Livewire)
|
||||
4. **Action/Service** → Business logic execution
|
||||
5. **Model/Repository** → Data persistence
|
||||
6. **Response** → Livewire reactive update
|
||||
|
||||
### **Background Processing**
|
||||
|
||||
1. **Job Dispatch** → Queue system (Redis)
|
||||
2. **Job Processing** → [app/Jobs/](mdc:app/Jobs)
|
||||
3. **Action Execution** → Business logic
|
||||
4. **Event Broadcasting** → Real-time updates
|
||||
5. **Notification** → User feedback
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### **Multi-Tenant Isolation**
|
||||
|
||||
```php
|
||||
// Team-based query scoping
|
||||
class Application extends Model
|
||||
{
|
||||
public function scopeOwnedByCurrentTeam($query)
|
||||
{
|
||||
return $query->whereHas('environment.project.team', function ($q) {
|
||||
$q->where('id', currentTeam()->id);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Authorization Layers**
|
||||
|
||||
1. **Team Membership** → User belongs to team
|
||||
2. **Resource Ownership** → Resource belongs to team
|
||||
3. **Policy Authorization** → [app/Policies/](mdc:app/Policies)
|
||||
4. **Environment Isolation** → Project/environment boundaries
|
||||
|
||||
### **Data Protection**
|
||||
|
||||
- **Environment Variables**: Encrypted at rest
|
||||
- **SSH Keys**: Secure storage and transmission
|
||||
- **API Tokens**: Sanctum-based authentication
|
||||
- **Audit Logging**: [spatie/laravel-activitylog](mdc:composer.json)
|
||||
|
||||
## Configuration Hierarchy
|
||||
|
||||
### **Global Configuration**
|
||||
- **[InstanceSettings](mdc:app/Models/InstanceSettings.php)**: System-wide settings
|
||||
- **[config/](mdc:config)**: Laravel configuration files
|
||||
|
||||
### **Team Configuration**
|
||||
- **[Team](mdc:app/Models/Team.php)**: Team-specific settings
|
||||
- **[ServerSetting](mdc:app/Models/ServerSetting.php)**: Server configurations
|
||||
|
||||
### **Project Configuration**
|
||||
- **[ProjectSetting](mdc:app/Models/ProjectSetting.php)**: Project settings
|
||||
- **[Environment](mdc:app/Models/Environment.php)**: Environment variables
|
||||
|
||||
### **Application Configuration**
|
||||
- **[ApplicationSetting](mdc:app/Models/ApplicationSetting.php)**: App-specific settings
|
||||
- **[EnvironmentVariable](mdc:app/Models/EnvironmentVariable.php)**: Runtime configuration
|
||||
|
||||
## Event-Driven Architecture
|
||||
|
||||
### **Event Broadcasting** ([app/Events/](mdc:app/Events))
|
||||
|
||||
Real-time updates using Laravel Echo and WebSockets:
|
||||
|
||||
```php
|
||||
// Example event structure
|
||||
class ApplicationDeploymentStarted implements ShouldBroadcast
|
||||
{
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("team.{$this->application->team->id}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Event Listeners** ([app/Listeners/](mdc:app/Listeners))
|
||||
|
||||
- Deployment status updates
|
||||
- Resource monitoring alerts
|
||||
- Notification dispatching
|
||||
- Audit log creation
|
||||
|
||||
## Database Design Patterns
|
||||
|
||||
### **Polymorphic Relationships**
|
||||
|
||||
```php
|
||||
// Environment variables can belong to multiple resource types
|
||||
class EnvironmentVariable extends Model
|
||||
{
|
||||
public function resource(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Team-Based Soft Scoping**
|
||||
|
||||
All major resources include team-based query scoping:
|
||||
|
||||
```php
|
||||
// Automatic team filtering
|
||||
$applications = Application::ownedByCurrentTeam()->get();
|
||||
$servers = Server::ownedByCurrentTeam()->get();
|
||||
```
|
||||
|
||||
### **Configuration Inheritance**
|
||||
|
||||
Environment variables cascade from:
|
||||
1. **Shared Variables** → Team-wide defaults
|
||||
2. **Project Variables** → Project-specific overrides
|
||||
3. **Application Variables** → Application-specific values
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### **Git Provider Integration**
|
||||
|
||||
Abstracted git operations supporting:
|
||||
- **GitHub**: [app/Models/GithubApp.php](mdc:app/Models/GithubApp.php)
|
||||
- **GitLab**: [app/Models/GitlabApp.php](mdc:app/Models/GitlabApp.php)
|
||||
- **Bitbucket**: Webhook integration
|
||||
- **Gitea**: Self-hosted Git support
|
||||
|
||||
### **Docker Integration**
|
||||
|
||||
- **Container Management**: Direct Docker API communication
|
||||
- **Image Building**: Dockerfile and Buildpack support
|
||||
- **Network Management**: Custom Docker networks
|
||||
- **Volume Management**: Persistent storage handling
|
||||
|
||||
### **SSH Communication**
|
||||
|
||||
- **[phpseclib/phpseclib](mdc:composer.json)**: Secure SSH connections
|
||||
- **Multiplexing**: Connection pooling for efficiency
|
||||
- **Key Management**: [PrivateKey](mdc:app/Models/PrivateKey.php) model
|
||||
|
||||
## Testing Architecture
|
||||
|
||||
### **Test Structure** ([tests/](mdc:tests))
|
||||
|
||||
```
|
||||
tests/
|
||||
├── Feature/ # Integration tests
|
||||
├── Unit/ # Unit tests
|
||||
├── Browser/ # Dusk browser tests
|
||||
├── Traits/ # Test helper traits
|
||||
├── Pest.php # Pest configuration
|
||||
└── TestCase.php # Base test case
|
||||
```
|
||||
|
||||
### **Testing Patterns**
|
||||
|
||||
- **Feature Tests**: Full request lifecycle testing
|
||||
- **Unit Tests**: Individual class/method testing
|
||||
- **Browser Tests**: End-to-end user workflows
|
||||
- **Database Testing**: Factories and seeders
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### **Query Optimization**
|
||||
|
||||
- **Eager Loading**: Prevent N+1 queries
|
||||
- **Query Scoping**: Team-based filtering
|
||||
- **Database Indexing**: Optimized for common queries
|
||||
|
||||
### **Caching Strategy**
|
||||
|
||||
- **Redis**: Session and cache storage
|
||||
- **Model Caching**: Frequently accessed data
|
||||
- **Query Caching**: Expensive query results
|
||||
|
||||
### **Background Processing**
|
||||
|
||||
- **Queue Workers**: Horizon-managed job processing
|
||||
- **Job Batching**: Related job grouping
|
||||
- **Failed Job Handling**: Automatic retry logic
|
||||
53
.cursor/rules/cursor_rules.mdc
Normal file
53
.cursor/rules/cursor_rules.mdc
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
description: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness.
|
||||
globs: .cursor/rules/*.mdc
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
- **Required Rule Structure:**
|
||||
```markdown
|
||||
---
|
||||
description: Clear, one-line description of what the rule enforces
|
||||
globs: path/to/files/*.ext, other/path/**/*
|
||||
alwaysApply: boolean
|
||||
---
|
||||
|
||||
- **Main Points in Bold**
|
||||
- Sub-points with details
|
||||
- Examples and explanations
|
||||
```
|
||||
|
||||
- **File References:**
|
||||
- Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files
|
||||
- Example: [prisma.mdc](mdc:.cursor/rules/prisma.mdc) for rule references
|
||||
- Example: [schema.prisma](mdc:prisma/schema.prisma) for code references
|
||||
|
||||
- **Code Examples:**
|
||||
- Use language-specific code blocks
|
||||
```typescript
|
||||
// ✅ DO: Show good examples
|
||||
const goodExample = true;
|
||||
|
||||
// ❌ DON'T: Show anti-patterns
|
||||
const badExample = false;
|
||||
```
|
||||
|
||||
- **Rule Content Guidelines:**
|
||||
- Start with high-level overview
|
||||
- Include specific, actionable requirements
|
||||
- Show examples of correct implementation
|
||||
- Reference existing code when possible
|
||||
- Keep rules DRY by referencing other rules
|
||||
|
||||
- **Rule Maintenance:**
|
||||
- Update rules when new patterns emerge
|
||||
- Add examples from actual codebase
|
||||
- Remove outdated patterns
|
||||
- Cross-reference related rules
|
||||
|
||||
- **Best Practices:**
|
||||
- Use bullet points for clarity
|
||||
- Keep descriptions concise
|
||||
- Include both DO and DON'T examples
|
||||
- Reference actual code over theoretical examples
|
||||
- Use consistent formatting across rules
|
||||
306
.cursor/rules/database-patterns.mdc
Normal file
306
.cursor/rules/database-patterns.mdc
Normal file
@@ -0,0 +1,306 @@
|
||||
---
|
||||
description: Database architecture, models, migrations, relationships, and data management patterns
|
||||
globs: app/Models/*.php, database/migrations/*.php, database/seeders/*.php, app/Actions/Database/*.php
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Database Architecture & Patterns
|
||||
|
||||
## Database Strategy
|
||||
|
||||
Coolify uses **PostgreSQL 15** as the primary database with **Redis 7** for caching and real-time features. The architecture supports managing multiple external databases across different servers.
|
||||
|
||||
## Primary Database (PostgreSQL)
|
||||
|
||||
### Core Tables & Models
|
||||
|
||||
#### User & Team Management
|
||||
- **[User.php](mdc:app/Models/User.php)** - User authentication and profiles
|
||||
- **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure
|
||||
- **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Team collaboration invitations
|
||||
- **[PersonalAccessToken.php](mdc:app/Models/PersonalAccessToken.php)** - API token management
|
||||
|
||||
#### Infrastructure Management
|
||||
- **[Server.php](mdc:app/Models/Server.php)** - Physical/virtual server definitions (46KB, complex)
|
||||
- **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - SSH key management
|
||||
- **[ServerSetting.php](mdc:app/Models/ServerSetting.php)** - Server-specific configurations
|
||||
|
||||
#### Project Organization
|
||||
- **[Project.php](mdc:app/Models/Project.php)** - Project containers for applications
|
||||
- **[Environment.php](mdc:app/Models/Environment.php)** - Environment isolation (staging, production, etc.)
|
||||
- **[ProjectSetting.php](mdc:app/Models/ProjectSetting.php)** - Project-specific settings
|
||||
|
||||
#### Application Deployment
|
||||
- **[Application.php](mdc:app/Models/Application.php)** - Main application entity (74KB, highly complex)
|
||||
- **[ApplicationSetting.php](mdc:app/Models/ApplicationSetting.php)** - Application configurations
|
||||
- **[ApplicationDeploymentQueue.php](mdc:app/Models/ApplicationDeploymentQueue.php)** - Deployment orchestration
|
||||
- **[ApplicationPreview.php](mdc:app/Models/ApplicationPreview.php)** - Preview environment management
|
||||
|
||||
#### Service Management
|
||||
- **[Service.php](mdc:app/Models/Service.php)** - Service definitions (58KB, complex)
|
||||
- **[ServiceApplication.php](mdc:app/Models/ServiceApplication.php)** - Service components
|
||||
- **[ServiceDatabase.php](mdc:app/Models/ServiceDatabase.php)** - Service-attached databases
|
||||
|
||||
## Database Type Support
|
||||
|
||||
### Standalone Database Models
|
||||
Each database type has its own dedicated model with specific configurations:
|
||||
|
||||
#### SQL Databases
|
||||
- **[StandalonePostgresql.php](mdc:app/Models/StandalonePostgresql.php)** - PostgreSQL instances
|
||||
- **[StandaloneMysql.php](mdc:app/Models/StandaloneMysql.php)** - MySQL instances
|
||||
- **[StandaloneMariadb.php](mdc:app/Models/StandaloneMariadb.php)** - MariaDB instances
|
||||
|
||||
#### NoSQL & Analytics
|
||||
- **[StandaloneMongodb.php](mdc:app/Models/StandaloneMongodb.php)** - MongoDB instances
|
||||
- **[StandaloneClickhouse.php](mdc:app/Models/StandaloneClickhouse.php)** - ClickHouse analytics
|
||||
|
||||
#### Caching & In-Memory
|
||||
- **[StandaloneRedis.php](mdc:app/Models/StandaloneRedis.php)** - Redis instances
|
||||
- **[StandaloneKeydb.php](mdc:app/Models/StandaloneKeydb.php)** - KeyDB instances
|
||||
- **[StandaloneDragonfly.php](mdc:app/Models/StandaloneDragonfly.php)** - Dragonfly instances
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Environment Variables
|
||||
- **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** - Application-specific environment variables
|
||||
- **[SharedEnvironmentVariable.php](mdc:app/Models/SharedEnvironmentVariable.php)** - Shared across applications
|
||||
|
||||
### Settings Hierarchy
|
||||
- **[InstanceSettings.php](mdc:app/Models/InstanceSettings.php)** - Global Coolify instance settings
|
||||
- **[ServerSetting.php](mdc:app/Models/ServerSetting.php)** - Server-specific settings
|
||||
- **[ProjectSetting.php](mdc:app/Models/ProjectSetting.php)** - Project-level settings
|
||||
- **[ApplicationSetting.php](mdc:app/Models/ApplicationSetting.php)** - Application settings
|
||||
|
||||
## Storage & Backup Systems
|
||||
|
||||
### Storage Management
|
||||
- **[S3Storage.php](mdc:app/Models/S3Storage.php)** - S3-compatible storage configurations
|
||||
- **[LocalFileVolume.php](mdc:app/Models/LocalFileVolume.php)** - Local filesystem volumes
|
||||
- **[LocalPersistentVolume.php](mdc:app/Models/LocalPersistentVolume.php)** - Persistent volume management
|
||||
|
||||
### Backup Infrastructure
|
||||
- **[ScheduledDatabaseBackup.php](mdc:app/Models/ScheduledDatabaseBackup.php)** - Automated backup scheduling
|
||||
- **[ScheduledDatabaseBackupExecution.php](mdc:app/Models/ScheduledDatabaseBackupExecution.php)** - Backup execution tracking
|
||||
|
||||
### Task Scheduling
|
||||
- **[ScheduledTask.php](mdc:app/Models/ScheduledTask.php)** - Cron job management
|
||||
- **[ScheduledTaskExecution.php](mdc:app/Models/ScheduledTaskExecution.php)** - Task execution history
|
||||
|
||||
## Notification & Integration Models
|
||||
|
||||
### Notification Channels
|
||||
- **[EmailNotificationSettings.php](mdc:app/Models/EmailNotificationSettings.php)** - Email notifications
|
||||
- **[DiscordNotificationSettings.php](mdc:app/Models/DiscordNotificationSettings.php)** - Discord integration
|
||||
- **[SlackNotificationSettings.php](mdc:app/Models/SlackNotificationSettings.php)** - Slack integration
|
||||
- **[TelegramNotificationSettings.php](mdc:app/Models/TelegramNotificationSettings.php)** - Telegram bot
|
||||
- **[PushoverNotificationSettings.php](mdc:app/Models/PushoverNotificationSettings.php)** - Pushover notifications
|
||||
|
||||
### Source Control Integration
|
||||
- **[GithubApp.php](mdc:app/Models/GithubApp.php)** - GitHub App integration
|
||||
- **[GitlabApp.php](mdc:app/Models/GitlabApp.php)** - GitLab integration
|
||||
|
||||
### OAuth & Authentication
|
||||
- **[OauthSetting.php](mdc:app/Models/OauthSetting.php)** - OAuth provider configurations
|
||||
|
||||
## Docker & Container Management
|
||||
|
||||
### Container Orchestration
|
||||
- **[StandaloneDocker.php](mdc:app/Models/StandaloneDocker.php)** - Standalone Docker containers
|
||||
- **[SwarmDocker.php](mdc:app/Models/SwarmDocker.php)** - Docker Swarm management
|
||||
|
||||
### SSL & Security
|
||||
- **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate management
|
||||
|
||||
## Database Migration Strategy
|
||||
|
||||
### Migration Location: [database/migrations/](mdc:database/migrations)
|
||||
|
||||
#### Migration Patterns
|
||||
```php
|
||||
// Typical Coolify migration structure
|
||||
Schema::create('applications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('fqdn')->nullable();
|
||||
$table->json('environment_variables')->nullable();
|
||||
$table->foreignId('destination_id');
|
||||
$table->foreignId('source_id');
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
### Schema Versioning
|
||||
- **Incremental migrations** for database evolution
|
||||
- **Data migrations** for complex transformations
|
||||
- **Rollback support** for deployment safety
|
||||
|
||||
## Eloquent Model Patterns
|
||||
|
||||
### Base Model Structure
|
||||
- **[BaseModel.php](mdc:app/Models/BaseModel.php)** - Common model functionality
|
||||
- **UUID primary keys** for distributed systems
|
||||
- **Soft deletes** for audit trails
|
||||
- **Activity logging** with Spatie package
|
||||
|
||||
### Relationship Patterns
|
||||
```php
|
||||
// Typical relationship structure in Application model
|
||||
class Application extends Model
|
||||
{
|
||||
public function server()
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function environment()
|
||||
{
|
||||
return $this->belongsTo(Environment::class);
|
||||
}
|
||||
|
||||
public function deployments()
|
||||
{
|
||||
return $this->hasMany(ApplicationDeploymentQueue::class);
|
||||
}
|
||||
|
||||
public function environmentVariables()
|
||||
{
|
||||
return $this->hasMany(EnvironmentVariable::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Model Traits
|
||||
```php
|
||||
// Common traits used across models
|
||||
use SoftDeletes;
|
||||
use LogsActivity;
|
||||
use HasFactory;
|
||||
use HasUuids;
|
||||
```
|
||||
|
||||
## Caching Strategy (Redis)
|
||||
|
||||
### Cache Usage Patterns
|
||||
- **Session storage** - User authentication sessions
|
||||
- **Queue backend** - Background job processing
|
||||
- **Model caching** - Expensive query results
|
||||
- **Real-time data** - WebSocket state management
|
||||
|
||||
### Cache Keys Structure
|
||||
```
|
||||
coolify:session:{session_id}
|
||||
coolify:server:{server_id}:status
|
||||
coolify:deployment:{deployment_id}:logs
|
||||
coolify:user:{user_id}:teams
|
||||
```
|
||||
|
||||
## Query Optimization Patterns
|
||||
|
||||
### Eager Loading
|
||||
```php
|
||||
// Optimized queries with relationships
|
||||
$applications = Application::with([
|
||||
'server',
|
||||
'environment.project',
|
||||
'environmentVariables',
|
||||
'deployments' => function ($query) {
|
||||
$query->latest()->limit(5);
|
||||
}
|
||||
])->get();
|
||||
```
|
||||
|
||||
### Chunking for Large Datasets
|
||||
```php
|
||||
// Processing large datasets efficiently
|
||||
Server::chunk(100, function ($servers) {
|
||||
foreach ($servers as $server) {
|
||||
// Process server monitoring
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Database Indexes
|
||||
- **Primary keys** on all tables
|
||||
- **Foreign key indexes** for relationships
|
||||
- **Composite indexes** for common queries
|
||||
- **Unique constraints** for business rules
|
||||
|
||||
## Data Consistency Patterns
|
||||
|
||||
### Database Transactions
|
||||
```php
|
||||
// Atomic operations for deployment
|
||||
DB::transaction(function () {
|
||||
$application = Application::create($data);
|
||||
$application->environmentVariables()->createMany($envVars);
|
||||
$application->deployments()->create(['status' => 'queued']);
|
||||
});
|
||||
```
|
||||
|
||||
### Model Events
|
||||
```php
|
||||
// Automatic cleanup on model deletion
|
||||
class Application extends Model
|
||||
{
|
||||
protected static function booted()
|
||||
{
|
||||
static::deleting(function ($application) {
|
||||
$application->environmentVariables()->delete();
|
||||
$application->deployments()->delete();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Backup & Recovery
|
||||
|
||||
### Database Backup Strategy
|
||||
- **Automated PostgreSQL backups** via scheduled tasks
|
||||
- **Point-in-time recovery** capability
|
||||
- **Cross-region backup** replication
|
||||
- **Backup verification** and testing
|
||||
|
||||
### Data Export/Import
|
||||
- **Application configurations** export/import
|
||||
- **Environment variable** bulk operations
|
||||
- **Server configurations** backup and restore
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
### Query Performance
|
||||
- **Laravel Telescope** for development debugging
|
||||
- **Slow query logging** in production
|
||||
- **Database connection** pooling
|
||||
- **Read replica** support for scaling
|
||||
|
||||
### Metrics Collection
|
||||
- **Database size** monitoring
|
||||
- **Connection count** tracking
|
||||
- **Query execution time** analysis
|
||||
- **Cache hit rates** monitoring
|
||||
|
||||
## Multi-Tenancy Pattern
|
||||
|
||||
### Team-Based Isolation
|
||||
```php
|
||||
// Global scope for team-based filtering
|
||||
class Application extends Model
|
||||
{
|
||||
protected static function booted()
|
||||
{
|
||||
static::addGlobalScope('team', function (Builder $builder) {
|
||||
if (auth()->user()) {
|
||||
$builder->whereHas('environment.project', function ($query) {
|
||||
$query->where('team_id', auth()->user()->currentTeam->id);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Data Separation
|
||||
- **Team-scoped queries** by default
|
||||
- **Cross-team access** controls
|
||||
- **Admin access** patterns
|
||||
- **Data isolation** guarantees
|
||||
310
.cursor/rules/deployment-architecture.mdc
Normal file
310
.cursor/rules/deployment-architecture.mdc
Normal file
@@ -0,0 +1,310 @@
|
||||
---
|
||||
description: Docker orchestration, deployment workflows, and containerization patterns
|
||||
globs: app/Jobs/*.php, app/Actions/Application/*.php, app/Actions/Server/*.php, docker/*.*, *.yml, *.yaml
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Deployment Architecture
|
||||
|
||||
## Deployment Philosophy
|
||||
|
||||
Coolify orchestrates **Docker-based deployments** across multiple servers with automated configuration generation, zero-downtime deployments, and comprehensive monitoring.
|
||||
|
||||
## Core Deployment Components
|
||||
|
||||
### Deployment Models
|
||||
- **[Application.php](mdc:app/Models/Application.php)** - Main application entity with deployment configurations
|
||||
- **[ApplicationDeploymentQueue.php](mdc:app/Models/ApplicationDeploymentQueue.php)** - Deployment job orchestration
|
||||
- **[Service.php](mdc:app/Models/Service.php)** - Multi-container service definitions
|
||||
- **[Server.php](mdc:app/Models/Server.php)** - Target deployment infrastructure
|
||||
|
||||
### Infrastructure Management
|
||||
- **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - SSH key management for secure server access
|
||||
- **[StandaloneDocker.php](mdc:app/Models/StandaloneDocker.php)** - Single container deployments
|
||||
- **[SwarmDocker.php](mdc:app/Models/SwarmDocker.php)** - Docker Swarm orchestration
|
||||
|
||||
## Deployment Workflow
|
||||
|
||||
### 1. Source Code Integration
|
||||
```
|
||||
Git Repository → Webhook → Coolify → Build & Deploy
|
||||
```
|
||||
|
||||
#### Source Control Models
|
||||
- **[GithubApp.php](mdc:app/Models/GithubApp.php)** - GitHub integration and webhooks
|
||||
- **[GitlabApp.php](mdc:app/Models/GitlabApp.php)** - GitLab CI/CD integration
|
||||
|
||||
#### Deployment Triggers
|
||||
- **Git push** to configured branches
|
||||
- **Manual deployment** via UI
|
||||
- **Scheduled deployments** via cron
|
||||
- **API-triggered** deployments
|
||||
|
||||
### 2. Build Process
|
||||
```
|
||||
Source Code → Docker Build → Image Registry → Deployment
|
||||
```
|
||||
|
||||
#### Build Configurations
|
||||
- **Dockerfile detection** and custom Dockerfile support
|
||||
- **Buildpack integration** for framework detection
|
||||
- **Multi-stage builds** for optimization
|
||||
- **Cache layer** management for faster builds
|
||||
|
||||
### 3. Deployment Orchestration
|
||||
```
|
||||
Queue Job → Configuration Generation → Container Deployment → Health Checks
|
||||
```
|
||||
|
||||
## Deployment Actions
|
||||
|
||||
### Location: [app/Actions/](mdc:app/Actions)
|
||||
|
||||
#### Application Deployment Actions
|
||||
- **Application/** - Core application deployment logic
|
||||
- **Docker/** - Docker container management
|
||||
- **Service/** - Multi-container service orchestration
|
||||
- **Proxy/** - Reverse proxy configuration
|
||||
|
||||
#### Database Actions
|
||||
- **Database/** - Database deployment and management
|
||||
- Automated backup scheduling
|
||||
- Connection management and health checks
|
||||
|
||||
#### Server Management Actions
|
||||
- **Server/** - Server provisioning and configuration
|
||||
- SSH connection establishment
|
||||
- Docker daemon management
|
||||
|
||||
## Configuration Generation
|
||||
|
||||
### Dynamic Configuration
|
||||
- **[ConfigurationGenerator.php](mdc:app/Services/ConfigurationGenerator.php)** - Generates deployment configurations
|
||||
- **[ConfigurationRepository.php](mdc:app/Services/ConfigurationRepository.php)** - Configuration management
|
||||
|
||||
### Generated Configurations
|
||||
#### Docker Compose Files
|
||||
```yaml
|
||||
# Generated docker-compose.yml structure
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: ${APP_IMAGE}
|
||||
environment:
|
||||
- ${ENV_VARIABLES}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.app.rule=Host(`${FQDN}`)
|
||||
volumes:
|
||||
- ${VOLUME_MAPPINGS}
|
||||
networks:
|
||||
- coolify
|
||||
```
|
||||
|
||||
#### Nginx Configurations
|
||||
- **Reverse proxy** setup
|
||||
- **SSL termination** with automatic certificates
|
||||
- **Load balancing** for multiple instances
|
||||
- **Custom headers** and routing rules
|
||||
|
||||
## Container Orchestration
|
||||
|
||||
### Docker Integration
|
||||
- **[DockerImageParser.php](mdc:app/Services/DockerImageParser.php)** - Parse and validate Docker images
|
||||
- **Container lifecycle** management
|
||||
- **Resource allocation** and limits
|
||||
- **Network isolation** and communication
|
||||
|
||||
### Volume Management
|
||||
- **[LocalFileVolume.php](mdc:app/Models/LocalFileVolume.php)** - Persistent file storage
|
||||
- **[LocalPersistentVolume.php](mdc:app/Models/LocalPersistentVolume.php)** - Data persistence
|
||||
- **Backup integration** for volume data
|
||||
|
||||
### Network Configuration
|
||||
- **Custom Docker networks** for isolation
|
||||
- **Service discovery** between containers
|
||||
- **Port mapping** and exposure
|
||||
- **SSL/TLS termination**
|
||||
|
||||
## Environment Management
|
||||
|
||||
### Environment Isolation
|
||||
- **[Environment.php](mdc:app/Models/Environment.php)** - Development, staging, production environments
|
||||
- **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** - Application-specific variables
|
||||
- **[SharedEnvironmentVariable.php](mdc:app/Models/SharedEnvironmentVariable.php)** - Cross-application variables
|
||||
|
||||
### Configuration Hierarchy
|
||||
```
|
||||
Instance Settings → Server Settings → Project Settings → Application Settings
|
||||
```
|
||||
|
||||
## Preview Environments
|
||||
|
||||
### Git-Based Previews
|
||||
- **[ApplicationPreview.php](mdc:app/Models/ApplicationPreview.php)** - Preview environment management
|
||||
- **Automatic PR/MR previews** for feature branches
|
||||
- **Isolated environments** for testing
|
||||
- **Automatic cleanup** after merge/close
|
||||
|
||||
### Preview Workflow
|
||||
```
|
||||
Feature Branch → Auto-Deploy → Preview URL → Review → Cleanup
|
||||
```
|
||||
|
||||
## SSL & Security
|
||||
|
||||
### Certificate Management
|
||||
- **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate automation
|
||||
- **Let's Encrypt** integration for free certificates
|
||||
- **Custom certificate** upload support
|
||||
- **Automatic renewal** and monitoring
|
||||
|
||||
### Security Patterns
|
||||
- **Private Docker networks** for container isolation
|
||||
- **SSH key-based** server authentication
|
||||
- **Environment variable** encryption
|
||||
- **Access control** via team permissions
|
||||
|
||||
## Backup & Recovery
|
||||
|
||||
### Database Backups
|
||||
- **[ScheduledDatabaseBackup.php](mdc:app/Models/ScheduledDatabaseBackup.php)** - Automated database backups
|
||||
- **[ScheduledDatabaseBackupExecution.php](mdc:app/Models/ScheduledDatabaseBackupExecution.php)** - Backup execution tracking
|
||||
- **S3-compatible storage** for backup destinations
|
||||
|
||||
### Application Backups
|
||||
- **Volume snapshots** for persistent data
|
||||
- **Configuration export** for disaster recovery
|
||||
- **Cross-region replication** for high availability
|
||||
|
||||
## Monitoring & Logging
|
||||
|
||||
### Real-Time Monitoring
|
||||
- **[ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php)** - Live deployment monitoring
|
||||
- **WebSocket-based** log streaming
|
||||
- **Container health checks** and alerts
|
||||
- **Resource usage** tracking
|
||||
|
||||
### Deployment Logs
|
||||
- **Build process** logging
|
||||
- **Container startup** logs
|
||||
- **Application runtime** logs
|
||||
- **Error tracking** and alerting
|
||||
|
||||
## Queue System
|
||||
|
||||
### Background Jobs
|
||||
Location: [app/Jobs/](mdc:app/Jobs)
|
||||
- **Deployment jobs** for async processing
|
||||
- **Server monitoring** jobs
|
||||
- **Backup scheduling** jobs
|
||||
- **Notification delivery** jobs
|
||||
|
||||
### Queue Processing
|
||||
- **Redis-backed** job queues
|
||||
- **Laravel Horizon** for queue monitoring
|
||||
- **Failed job** retry mechanisms
|
||||
- **Queue worker** auto-scaling
|
||||
|
||||
## Multi-Server Deployment
|
||||
|
||||
### Server Types
|
||||
- **Standalone servers** - Single Docker host
|
||||
- **Docker Swarm** - Multi-node orchestration
|
||||
- **Remote servers** - SSH-based deployment
|
||||
- **Local development** - Docker Desktop integration
|
||||
|
||||
### Load Balancing
|
||||
- **Traefik integration** for automatic load balancing
|
||||
- **Health check** based routing
|
||||
- **Blue-green deployments** for zero downtime
|
||||
- **Rolling updates** with configurable strategies
|
||||
|
||||
## Deployment Strategies
|
||||
|
||||
### Zero-Downtime Deployment
|
||||
```
|
||||
Old Container → New Container Build → Health Check → Traffic Switch → Old Container Cleanup
|
||||
```
|
||||
|
||||
### Blue-Green Deployment
|
||||
- **Parallel environments** for safe deployments
|
||||
- **Instant rollback** capability
|
||||
- **Database migration** handling
|
||||
- **Configuration synchronization**
|
||||
|
||||
### Rolling Updates
|
||||
- **Gradual instance** replacement
|
||||
- **Configurable update** strategy
|
||||
- **Automatic rollback** on failure
|
||||
- **Health check** validation
|
||||
|
||||
## API Integration
|
||||
|
||||
### Deployment API
|
||||
Routes: [routes/api.php](mdc:routes/api.php)
|
||||
- **RESTful endpoints** for deployment management
|
||||
- **Webhook receivers** for CI/CD integration
|
||||
- **Status reporting** endpoints
|
||||
- **Deployment triggering** via API
|
||||
|
||||
### Authentication
|
||||
- **Laravel Sanctum** API tokens
|
||||
- **Team-based** access control
|
||||
- **Rate limiting** for API calls
|
||||
- **Audit logging** for API usage
|
||||
|
||||
## Error Handling & Recovery
|
||||
|
||||
### Deployment Failure Recovery
|
||||
- **Automatic rollback** on deployment failure
|
||||
- **Health check** failure handling
|
||||
- **Container crash** recovery
|
||||
- **Resource exhaustion** protection
|
||||
|
||||
### Monitoring & Alerting
|
||||
- **Failed deployment** notifications
|
||||
- **Resource threshold** alerts
|
||||
- **SSL certificate** expiry warnings
|
||||
- **Backup failure** notifications
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Build Optimization
|
||||
- **Docker layer** caching
|
||||
- **Multi-stage builds** for smaller images
|
||||
- **Build artifact** reuse
|
||||
- **Parallel build** processing
|
||||
|
||||
### Runtime Optimization
|
||||
- **Container resource** limits
|
||||
- **Auto-scaling** based on metrics
|
||||
- **Connection pooling** for databases
|
||||
- **CDN integration** for static assets
|
||||
|
||||
## Compliance & Governance
|
||||
|
||||
### Audit Trail
|
||||
- **Deployment history** tracking
|
||||
- **Configuration changes** logging
|
||||
- **User action** auditing
|
||||
- **Resource access** monitoring
|
||||
|
||||
### Backup Compliance
|
||||
- **Retention policies** for backups
|
||||
- **Encryption at rest** for sensitive data
|
||||
- **Cross-region** backup replication
|
||||
- **Recovery testing** automation
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### CI/CD Integration
|
||||
- **GitHub Actions** compatibility
|
||||
- **GitLab CI** pipeline integration
|
||||
- **Custom webhook** endpoints
|
||||
- **Build status** reporting
|
||||
|
||||
### External Services
|
||||
- **S3-compatible** storage integration
|
||||
- **External database** connections
|
||||
- **Third-party monitoring** tools
|
||||
- **Custom notification** channels
|
||||
219
.cursor/rules/dev_workflow.mdc
Normal file
219
.cursor/rules/dev_workflow.mdc
Normal file
@@ -0,0 +1,219 @@
|
||||
---
|
||||
description: Guide for using Task Master to manage task-driven development workflows
|
||||
globs: **/*
|
||||
alwaysApply: true
|
||||
---
|
||||
# Task Master Development Workflow
|
||||
|
||||
This guide outlines the typical process for using Task Master to manage software development projects.
|
||||
|
||||
## Primary Interaction: MCP Server vs. CLI
|
||||
|
||||
Task Master offers two primary ways to interact:
|
||||
|
||||
1. **MCP Server (Recommended for Integrated Tools)**:
|
||||
- For AI agents and integrated development environments (like Cursor), interacting via the **MCP server is the preferred method**.
|
||||
- The MCP server exposes Task Master functionality through a set of tools (e.g., `get_tasks`, `add_subtask`).
|
||||
- This method offers better performance, structured data exchange, and richer error handling compared to CLI parsing.
|
||||
- Refer to [`mcp.mdc`](mdc:.cursor/rules/mcp.mdc) for details on the MCP architecture and available tools.
|
||||
- A comprehensive list and description of MCP tools and their corresponding CLI commands can be found in [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc).
|
||||
- **Restart the MCP server** if core logic in `scripts/modules` or MCP tool/direct function definitions change.
|
||||
|
||||
2. **`task-master` CLI (For Users & Fallback)**:
|
||||
- The global `task-master` command provides a user-friendly interface for direct terminal interaction.
|
||||
- It can also serve as a fallback if the MCP server is inaccessible or a specific function isn't exposed via MCP.
|
||||
- Install globally with `npm install -g task-master-ai` or use locally via `npx task-master-ai ...`.
|
||||
- The CLI commands often mirror the MCP tools (e.g., `task-master list` corresponds to `get_tasks`).
|
||||
- Refer to [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc) for a detailed command reference.
|
||||
|
||||
## Standard Development Workflow Process
|
||||
|
||||
- Start new projects by running `initialize_project` tool / `task-master init` or `parse_prd` / `task-master parse-prd --input='<prd-file.txt>'` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to generate initial tasks.json
|
||||
- Begin coding sessions with `get_tasks` / `task-master list` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to see current tasks, status, and IDs
|
||||
- Determine the next task to work on using `next_task` / `task-master next` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
||||
- Analyze task complexity with `analyze_project_complexity` / `task-master analyze-complexity --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) before breaking down tasks
|
||||
- Review complexity report using `complexity_report` / `task-master complexity-report` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
||||
- Select tasks based on dependencies (all marked 'done'), priority level, and ID order
|
||||
- Clarify tasks by checking task files in tasks/ directory or asking for user input
|
||||
- View specific task details using `get_task` / `task-master show <id>` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to understand implementation requirements
|
||||
- Break down complex tasks using `expand_task` / `task-master expand --id=<id> --force --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) with appropriate flags like `--force` (to replace existing subtasks) and `--research`.
|
||||
- Clear existing subtasks if needed using `clear_subtasks` / `task-master clear-subtasks --id=<id>` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) before regenerating
|
||||
- Implement code following task details, dependencies, and project standards
|
||||
- Verify tasks according to test strategies before marking as complete (See [`tests.mdc`](mdc:.cursor/rules/tests.mdc))
|
||||
- Mark completed tasks with `set_task_status` / `task-master set-status --id=<id> --status=done` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc))
|
||||
- Update dependent tasks when implementation differs from original plan using `update` / `task-master update --from=<id> --prompt="..."` or `update_task` / `task-master update-task --id=<id> --prompt="..."` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc))
|
||||
- Add new tasks discovered during implementation using `add_task` / `task-master add-task --prompt="..." --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
||||
- Add new subtasks as needed using `add_subtask` / `task-master add-subtask --parent=<id> --title="..."` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
||||
- Append notes or details to subtasks using `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='Add implementation notes here...\nMore details...'` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
||||
- Generate task files with `generate` / `task-master generate` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) after updating tasks.json
|
||||
- Maintain valid dependency structure with `add_dependency`/`remove_dependency` tools or `task-master add-dependency`/`remove-dependency` commands, `validate_dependencies` / `task-master validate-dependencies`, and `fix_dependencies` / `task-master fix-dependencies` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) when needed
|
||||
- Respect dependency chains and task priorities when selecting work
|
||||
- Report progress regularly using `get_tasks` / `task-master list`
|
||||
|
||||
## Task Complexity Analysis
|
||||
|
||||
- Run `analyze_project_complexity` / `task-master analyze-complexity --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) for comprehensive analysis
|
||||
- Review complexity report via `complexity_report` / `task-master complexity-report` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) for a formatted, readable version.
|
||||
- Focus on tasks with highest complexity scores (8-10) for detailed breakdown
|
||||
- Use analysis results to determine appropriate subtask allocation
|
||||
- Note that reports are automatically used by the `expand_task` tool/command
|
||||
|
||||
## Task Breakdown Process
|
||||
|
||||
- Use `expand_task` / `task-master expand --id=<id>`. It automatically uses the complexity report if found, otherwise generates default number of subtasks.
|
||||
- Use `--num=<number>` to specify an explicit number of subtasks, overriding defaults or complexity report recommendations.
|
||||
- Add `--research` flag to leverage Perplexity AI for research-backed expansion.
|
||||
- Add `--force` flag to clear existing subtasks before generating new ones (default is to append).
|
||||
- Use `--prompt="<context>"` to provide additional context when needed.
|
||||
- Review and adjust generated subtasks as necessary.
|
||||
- Use `expand_all` tool or `task-master expand --all` to expand multiple pending tasks at once, respecting flags like `--force` and `--research`.
|
||||
- If subtasks need complete replacement (regardless of the `--force` flag on `expand`), clear them first with `clear_subtasks` / `task-master clear-subtasks --id=<id>`.
|
||||
|
||||
## Implementation Drift Handling
|
||||
|
||||
- When implementation differs significantly from planned approach
|
||||
- When future tasks need modification due to current implementation choices
|
||||
- When new dependencies or requirements emerge
|
||||
- Use `update` / `task-master update --from=<futureTaskId> --prompt='<explanation>\nUpdate context...' --research` to update multiple future tasks.
|
||||
- Use `update_task` / `task-master update-task --id=<taskId> --prompt='<explanation>\nUpdate context...' --research` to update a single specific task.
|
||||
|
||||
## Task Status Management
|
||||
|
||||
- Use 'pending' for tasks ready to be worked on
|
||||
- Use 'done' for completed and verified tasks
|
||||
- Use 'deferred' for postponed tasks
|
||||
- Add custom status values as needed for project-specific workflows
|
||||
|
||||
## Task Structure Fields
|
||||
|
||||
- **id**: Unique identifier for the task (Example: `1`, `1.1`)
|
||||
- **title**: Brief, descriptive title (Example: `"Initialize Repo"`)
|
||||
- **description**: Concise summary of what the task involves (Example: `"Create a new repository, set up initial structure."`)
|
||||
- **status**: Current state of the task (Example: `"pending"`, `"done"`, `"deferred"`)
|
||||
- **dependencies**: IDs of prerequisite tasks (Example: `[1, 2.1]`)
|
||||
- Dependencies are displayed with status indicators (✅ for completed, ⏱️ for pending)
|
||||
- This helps quickly identify which prerequisite tasks are blocking work
|
||||
- **priority**: Importance level (Example: `"high"`, `"medium"`, `"low"`)
|
||||
- **details**: In-depth implementation instructions (Example: `"Use GitHub client ID/secret, handle callback, set session token."`)
|
||||
- **testStrategy**: Verification approach (Example: `"Deploy and call endpoint to confirm 'Hello World' response."`)
|
||||
- **subtasks**: List of smaller, more specific tasks (Example: `[{"id": 1, "title": "Configure OAuth", ...}]`)
|
||||
- Refer to task structure details (previously linked to `tasks.mdc`).
|
||||
|
||||
## Configuration Management (Updated)
|
||||
|
||||
Taskmaster configuration is managed through two main mechanisms:
|
||||
|
||||
1. **`.taskmasterconfig` File (Primary):**
|
||||
* Located in the project root directory.
|
||||
* Stores most configuration settings: AI model selections (main, research, fallback), parameters (max tokens, temperature), logging level, default subtasks/priority, project name, etc.
|
||||
* **Managed via `task-master models --setup` command.** Do not edit manually unless you know what you are doing.
|
||||
* **View/Set specific models via `task-master models` command or `models` MCP tool.**
|
||||
* Created automatically when you run `task-master models --setup` for the first time.
|
||||
|
||||
2. **Environment Variables (`.env` / `mcp.json`):**
|
||||
* Used **only** for sensitive API keys and specific endpoint URLs.
|
||||
* Place API keys (one per provider) in a `.env` file in the project root for CLI usage.
|
||||
* For MCP/Cursor integration, configure these keys in the `env` section of `.cursor/mcp.json`.
|
||||
* Available keys/variables: See `assets/env.example` or the Configuration section in the command reference (previously linked to `taskmaster.mdc`).
|
||||
|
||||
**Important:** Non-API key settings (like model selections, `MAX_TOKENS`, `TASKMASTER_LOG_LEVEL`) are **no longer configured via environment variables**. Use the `task-master models` command (or `--setup` for interactive configuration) or the `models` MCP tool.
|
||||
**If AI commands FAIL in MCP** verify that the API key for the selected provider is present in the `env` section of `.cursor/mcp.json`.
|
||||
**If AI commands FAIL in CLI** verify that the API key for the selected provider is present in the `.env` file in the root of the project.
|
||||
|
||||
## Determining the Next Task
|
||||
|
||||
- Run `next_task` / `task-master next` to show the next task to work on.
|
||||
- The command identifies tasks with all dependencies satisfied
|
||||
- Tasks are prioritized by priority level, dependency count, and ID
|
||||
- The command shows comprehensive task information including:
|
||||
- Basic task details and description
|
||||
- Implementation details
|
||||
- Subtasks (if they exist)
|
||||
- Contextual suggested actions
|
||||
- Recommended before starting any new development work
|
||||
- Respects your project's dependency structure
|
||||
- Ensures tasks are completed in the appropriate sequence
|
||||
- Provides ready-to-use commands for common task actions
|
||||
|
||||
## Viewing Specific Task Details
|
||||
|
||||
- Run `get_task` / `task-master show <id>` to view a specific task.
|
||||
- Use dot notation for subtasks: `task-master show 1.2` (shows subtask 2 of task 1)
|
||||
- Displays comprehensive information similar to the next command, but for a specific task
|
||||
- For parent tasks, shows all subtasks and their current status
|
||||
- For subtasks, shows parent task information and relationship
|
||||
- Provides contextual suggested actions appropriate for the specific task
|
||||
- Useful for examining task details before implementation or checking status
|
||||
|
||||
## Managing Task Dependencies
|
||||
|
||||
- Use `add_dependency` / `task-master add-dependency --id=<id> --depends-on=<id>` to add a dependency.
|
||||
- Use `remove_dependency` / `task-master remove-dependency --id=<id> --depends-on=<id>` to remove a dependency.
|
||||
- The system prevents circular dependencies and duplicate dependency entries
|
||||
- Dependencies are checked for existence before being added or removed
|
||||
- Task files are automatically regenerated after dependency changes
|
||||
- Dependencies are visualized with status indicators in task listings and files
|
||||
|
||||
## Iterative Subtask Implementation
|
||||
|
||||
Once a task has been broken down into subtasks using `expand_task` or similar methods, follow this iterative process for implementation:
|
||||
|
||||
1. **Understand the Goal (Preparation):**
|
||||
* Use `get_task` / `task-master show <subtaskId>` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to thoroughly understand the specific goals and requirements of the subtask.
|
||||
|
||||
2. **Initial Exploration & Planning (Iteration 1):**
|
||||
* This is the first attempt at creating a concrete implementation plan.
|
||||
* Explore the codebase to identify the precise files, functions, and even specific lines of code that will need modification.
|
||||
* Determine the intended code changes (diffs) and their locations.
|
||||
* Gather *all* relevant details from this exploration phase.
|
||||
|
||||
3. **Log the Plan:**
|
||||
* Run `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='<detailed plan>'`.
|
||||
* Provide the *complete and detailed* findings from the exploration phase in the prompt. Include file paths, line numbers, proposed diffs, reasoning, and any potential challenges identified. Do not omit details. The goal is to create a rich, timestamped log within the subtask's `details`.
|
||||
|
||||
4. **Verify the Plan:**
|
||||
* Run `get_task` / `task-master show <subtaskId>` again to confirm that the detailed implementation plan has been successfully appended to the subtask's details.
|
||||
|
||||
5. **Begin Implementation:**
|
||||
* Set the subtask status using `set_task_status` / `task-master set-status --id=<subtaskId> --status=in-progress`.
|
||||
* Start coding based on the logged plan.
|
||||
|
||||
6. **Refine and Log Progress (Iteration 2+):**
|
||||
* As implementation progresses, you will encounter challenges, discover nuances, or confirm successful approaches.
|
||||
* **Before appending new information**: Briefly review the *existing* details logged in the subtask (using `get_task` or recalling from context) to ensure the update adds fresh insights and avoids redundancy.
|
||||
* **Regularly** use `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='<update details>\n- What worked...\n- What didn't work...'` to append new findings.
|
||||
* **Crucially, log:**
|
||||
* What worked ("fundamental truths" discovered).
|
||||
* What didn't work and why (to avoid repeating mistakes).
|
||||
* Specific code snippets or configurations that were successful.
|
||||
* Decisions made, especially if confirmed with user input.
|
||||
* Any deviations from the initial plan and the reasoning.
|
||||
* The objective is to continuously enrich the subtask's details, creating a log of the implementation journey that helps the AI (and human developers) learn, adapt, and avoid repeating errors.
|
||||
|
||||
7. **Review & Update Rules (Post-Implementation):**
|
||||
* Once the implementation for the subtask is functionally complete, review all code changes and the relevant chat history.
|
||||
* Identify any new or modified code patterns, conventions, or best practices established during the implementation.
|
||||
* Create new or update existing rules following internal guidelines (previously linked to `cursor_rules.mdc` and `self_improve.mdc`).
|
||||
|
||||
8. **Mark Task Complete:**
|
||||
* After verifying the implementation and updating any necessary rules, mark the subtask as completed: `set_task_status` / `task-master set-status --id=<subtaskId> --status=done`.
|
||||
|
||||
9. **Commit Changes (If using Git):**
|
||||
* Stage the relevant code changes and any updated/new rule files (`git add .`).
|
||||
* Craft a comprehensive Git commit message summarizing the work done for the subtask, including both code implementation and any rule adjustments.
|
||||
* Execute the commit command directly in the terminal (e.g., `git commit -m 'feat(module): Implement feature X for subtask <subtaskId>\n\n- Details about changes...\n- Updated rule Y for pattern Z'`).
|
||||
* Consider if a Changeset is needed according to internal versioning guidelines (previously linked to `changeset.mdc`). If so, run `npm run changeset`, stage the generated file, and amend the commit or create a new one.
|
||||
|
||||
10. **Proceed to Next Subtask:**
|
||||
* Identify the next subtask (e.g., using `next_task` / `task-master next`).
|
||||
|
||||
## Code Analysis & Refactoring Techniques
|
||||
|
||||
- **Top-Level Function Search**:
|
||||
- Useful for understanding module structure or planning refactors.
|
||||
- Use grep/ripgrep to find exported functions/constants:
|
||||
`rg "export (async function|function|const) \w+"` or similar patterns.
|
||||
- Can help compare functions between files during migrations or identify potential naming conflicts.
|
||||
|
||||
---
|
||||
*This workflow provides a general guideline. Adapt it based on your specific project needs and team practices.*
|
||||
653
.cursor/rules/development-workflow.mdc
Normal file
653
.cursor/rules/development-workflow.mdc
Normal file
@@ -0,0 +1,653 @@
|
||||
---
|
||||
description: Development setup, coding standards, contribution guidelines, and best practices
|
||||
globs: **/*.php, composer.json, package.json, *.md, .env.example
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Development Workflow
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
### Prerequisites
|
||||
- **PHP 8.4+** - Latest PHP version for modern features
|
||||
- **Node.js 18+** - For frontend asset compilation
|
||||
- **Docker & Docker Compose** - Container orchestration
|
||||
- **PostgreSQL 15** - Primary database
|
||||
- **Redis 7** - Caching and queues
|
||||
|
||||
### Local Development Setup
|
||||
|
||||
#### Using Docker (Recommended)
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/coollabsio/coolify.git
|
||||
cd coolify
|
||||
|
||||
# Copy environment configuration
|
||||
cp .env.example .env
|
||||
|
||||
# Start development environment
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# Install PHP dependencies
|
||||
docker-compose exec app composer install
|
||||
|
||||
# Install Node.js dependencies
|
||||
docker-compose exec app npm install
|
||||
|
||||
# Generate application key
|
||||
docker-compose exec app php artisan key:generate
|
||||
|
||||
# Run database migrations
|
||||
docker-compose exec app php artisan migrate
|
||||
|
||||
# Seed development data
|
||||
docker-compose exec app php artisan db:seed
|
||||
```
|
||||
|
||||
#### Native Development
|
||||
```bash
|
||||
# Install PHP dependencies
|
||||
composer install
|
||||
|
||||
# Install Node.js dependencies
|
||||
npm install
|
||||
|
||||
# Setup environment
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
|
||||
# Setup database
|
||||
createdb coolify_dev
|
||||
php artisan migrate
|
||||
php artisan db:seed
|
||||
|
||||
# Start development servers
|
||||
php artisan serve &
|
||||
npm run dev &
|
||||
php artisan queue:work &
|
||||
```
|
||||
|
||||
## Development Tools & Configuration
|
||||
|
||||
### Code Quality Tools
|
||||
- **[Laravel Pint](mdc:pint.json)** - PHP code style fixer
|
||||
- **[Rector](mdc:rector.php)** - PHP automated refactoring (989B, 35 lines)
|
||||
- **PHPStan** - Static analysis for type safety
|
||||
- **ESLint** - JavaScript code quality
|
||||
|
||||
### Development Configuration Files
|
||||
- **[docker-compose.dev.yml](mdc:docker-compose.dev.yml)** - Development Docker setup (3.4KB, 126 lines)
|
||||
- **[vite.config.js](mdc:vite.config.js)** - Frontend build configuration (1.0KB, 42 lines)
|
||||
- **[.editorconfig](mdc:.editorconfig)** - Code formatting standards (258B, 19 lines)
|
||||
|
||||
### Git Configuration
|
||||
- **[.gitignore](mdc:.gitignore)** - Version control exclusions (522B, 40 lines)
|
||||
- **[.gitattributes](mdc:.gitattributes)** - Git file handling (185B, 11 lines)
|
||||
|
||||
## Development Workflow Process
|
||||
|
||||
### 1. Feature Development
|
||||
```bash
|
||||
# Create feature branch
|
||||
git checkout -b feature/new-deployment-strategy
|
||||
|
||||
# Make changes following coding standards
|
||||
# Run code quality checks
|
||||
./vendor/bin/pint
|
||||
./vendor/bin/rector process --dry-run
|
||||
./vendor/bin/phpstan analyse
|
||||
|
||||
# Run tests
|
||||
./vendor/bin/pest
|
||||
./vendor/bin/pest --coverage
|
||||
|
||||
# Commit changes
|
||||
git add .
|
||||
git commit -m "feat: implement blue-green deployment strategy"
|
||||
```
|
||||
|
||||
### 2. Code Review Process
|
||||
```bash
|
||||
# Push feature branch
|
||||
git push origin feature/new-deployment-strategy
|
||||
|
||||
# Create pull request with:
|
||||
# - Clear description of changes
|
||||
# - Screenshots for UI changes
|
||||
# - Test coverage information
|
||||
# - Breaking change documentation
|
||||
```
|
||||
|
||||
### 3. Testing Requirements
|
||||
- **Unit tests** for new models and services
|
||||
- **Feature tests** for API endpoints
|
||||
- **Browser tests** for UI changes
|
||||
- **Integration tests** for deployment workflows
|
||||
|
||||
## Coding Standards & Conventions
|
||||
|
||||
### PHP Coding Standards
|
||||
```php
|
||||
// Follow PSR-12 coding standards
|
||||
class ApplicationDeploymentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DockerService $dockerService,
|
||||
private readonly ConfigurationGenerator $configGenerator
|
||||
) {}
|
||||
|
||||
public function deploy(Application $application): ApplicationDeploymentQueue
|
||||
{
|
||||
return DB::transaction(function () use ($application) {
|
||||
$deployment = $application->deployments()->create([
|
||||
'status' => 'queued',
|
||||
'commit_sha' => $application->getLatestCommitSha(),
|
||||
]);
|
||||
|
||||
DeployApplicationJob::dispatch($deployment);
|
||||
|
||||
return $deployment;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Laravel Best Practices
|
||||
```php
|
||||
// Use Laravel conventions
|
||||
class Application extends Model
|
||||
{
|
||||
// Mass assignment protection
|
||||
protected $fillable = [
|
||||
'name', 'git_repository', 'git_branch', 'fqdn'
|
||||
];
|
||||
|
||||
// Type casting
|
||||
protected $casts = [
|
||||
'environment_variables' => 'array',
|
||||
'build_pack' => BuildPack::class,
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function deployments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApplicationDeploymentQueue::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Standards
|
||||
```javascript
|
||||
// Alpine.js component structure
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('deploymentMonitor', () => ({
|
||||
status: 'idle',
|
||||
logs: [],
|
||||
|
||||
init() {
|
||||
this.connectWebSocket();
|
||||
},
|
||||
|
||||
connectWebSocket() {
|
||||
Echo.private(`application.${this.applicationId}`)
|
||||
.listen('DeploymentStarted', (e) => {
|
||||
this.status = 'deploying';
|
||||
})
|
||||
.listen('DeploymentCompleted', (e) => {
|
||||
this.status = 'completed';
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
```
|
||||
|
||||
### CSS/Tailwind Standards
|
||||
```html
|
||||
<!-- Use semantic class names and consistent spacing -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Application Status
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<!-- Content with consistent spacing -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Database Development
|
||||
|
||||
### Migration Best Practices
|
||||
```php
|
||||
// Create descriptive migration files
|
||||
class CreateApplicationDeploymentQueuesTable extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('application_deployment_queues', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('application_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('status')->default('queued');
|
||||
$table->string('commit_sha')->nullable();
|
||||
$table->text('build_logs')->nullable();
|
||||
$table->text('deployment_logs')->nullable();
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('finished_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['application_id', 'status']);
|
||||
$table->index('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('application_deployment_queues');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Model Factory Development
|
||||
```php
|
||||
// Create comprehensive factories for testing
|
||||
class ApplicationFactory extends Factory
|
||||
{
|
||||
protected $model = Application::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->words(2, true),
|
||||
'fqdn' => $this->faker->domainName,
|
||||
'git_repository' => 'https://github.com/' . $this->faker->userName . '/' . $this->faker->word . '.git',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => BuildPack::NIXPACKS,
|
||||
'server_id' => Server::factory(),
|
||||
'environment_id' => Environment::factory(),
|
||||
];
|
||||
}
|
||||
|
||||
public function withCustomDomain(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'fqdn' => $this->faker->domainName,
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Development
|
||||
|
||||
### Controller Standards
|
||||
```php
|
||||
class ApplicationController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth:sanctum');
|
||||
$this->middleware('team.access');
|
||||
}
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$applications = $request->user()
|
||||
->currentTeam
|
||||
->applications()
|
||||
->with(['server', 'environment', 'latestDeployment'])
|
||||
->paginate();
|
||||
|
||||
return ApplicationResource::collection($applications);
|
||||
}
|
||||
|
||||
public function store(StoreApplicationRequest $request): ApplicationResource
|
||||
{
|
||||
$application = $request->user()
|
||||
->currentTeam
|
||||
->applications()
|
||||
->create($request->validated());
|
||||
|
||||
return new ApplicationResource($application);
|
||||
}
|
||||
|
||||
public function deploy(Application $application): JsonResponse
|
||||
{
|
||||
$this->authorize('deploy', $application);
|
||||
|
||||
$deployment = app(ApplicationDeploymentService::class)
|
||||
->deploy($application);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Deployment started successfully',
|
||||
'deployment_id' => $deployment->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Resource Development
|
||||
```php
|
||||
class ApplicationResource extends JsonResource
|
||||
{
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'fqdn' => $this->fqdn,
|
||||
'status' => $this->status,
|
||||
'git_repository' => $this->git_repository,
|
||||
'git_branch' => $this->git_branch,
|
||||
'build_pack' => $this->build_pack,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
|
||||
// Conditional relationships
|
||||
'server' => new ServerResource($this->whenLoaded('server')),
|
||||
'environment' => new EnvironmentResource($this->whenLoaded('environment')),
|
||||
'latest_deployment' => new DeploymentResource($this->whenLoaded('latestDeployment')),
|
||||
|
||||
// Computed attributes
|
||||
'deployment_url' => $this->getDeploymentUrl(),
|
||||
'can_deploy' => $this->canDeploy(),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Livewire Component Development
|
||||
|
||||
### Component Structure
|
||||
```php
|
||||
class ApplicationShow extends Component
|
||||
{
|
||||
public Application $application;
|
||||
public bool $showLogs = false;
|
||||
|
||||
protected $listeners = [
|
||||
'deployment.started' => 'refreshDeploymentStatus',
|
||||
'deployment.completed' => 'refreshDeploymentStatus',
|
||||
];
|
||||
|
||||
public function mount(Application $application): void
|
||||
{
|
||||
$this->authorize('view', $application);
|
||||
$this->application = $application;
|
||||
}
|
||||
|
||||
public function deploy(): void
|
||||
{
|
||||
$this->authorize('deploy', $this->application);
|
||||
|
||||
try {
|
||||
app(ApplicationDeploymentService::class)->deploy($this->application);
|
||||
|
||||
$this->dispatch('deployment.started', [
|
||||
'application_id' => $this->application->id
|
||||
]);
|
||||
|
||||
session()->flash('success', 'Deployment started successfully');
|
||||
} catch (Exception $e) {
|
||||
session()->flash('error', 'Failed to start deployment: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshDeploymentStatus(): void
|
||||
{
|
||||
$this->application->refresh();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.application.show', [
|
||||
'deployments' => $this->application
|
||||
->deployments()
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get()
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Queue Job Development
|
||||
|
||||
### Job Structure
|
||||
```php
|
||||
class DeployApplicationJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $maxExceptions = 1;
|
||||
|
||||
public function __construct(
|
||||
public ApplicationDeploymentQueue $deployment
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
DockerService $dockerService,
|
||||
ConfigurationGenerator $configGenerator
|
||||
): void {
|
||||
$this->deployment->update(['status' => 'running', 'started_at' => now()]);
|
||||
|
||||
try {
|
||||
// Generate configuration
|
||||
$config = $configGenerator->generateDockerCompose($this->deployment->application);
|
||||
|
||||
// Build and deploy
|
||||
$imageTag = $dockerService->buildImage($this->deployment->application);
|
||||
$dockerService->deployContainer($this->deployment->application, $imageTag);
|
||||
|
||||
$this->deployment->update([
|
||||
'status' => 'success',
|
||||
'finished_at' => now()
|
||||
]);
|
||||
|
||||
// Broadcast success
|
||||
broadcast(new DeploymentCompleted($this->deployment));
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->deployment->update([
|
||||
'status' => 'failed',
|
||||
'error_message' => $e->getMessage(),
|
||||
'finished_at' => now()
|
||||
]);
|
||||
|
||||
broadcast(new DeploymentFailed($this->deployment));
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function backoff(): array
|
||||
{
|
||||
return [1, 5, 10];
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
$this->deployment->update([
|
||||
'status' => 'failed',
|
||||
'error_message' => $exception->getMessage(),
|
||||
'finished_at' => now()
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Development
|
||||
|
||||
### Test Structure
|
||||
```php
|
||||
// Feature test example
|
||||
test('user can deploy application via API', function () {
|
||||
$user = User::factory()->create();
|
||||
$application = Application::factory()->create([
|
||||
'team_id' => $user->currentTeam->id
|
||||
]);
|
||||
|
||||
// Mock external services
|
||||
$this->mock(DockerService::class, function ($mock) {
|
||||
$mock->shouldReceive('buildImage')->andReturn('app:latest');
|
||||
$mock->shouldReceive('deployContainer')->andReturn(true);
|
||||
});
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson("/api/v1/applications/{$application->id}/deploy");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'message' => 'Deployment started successfully'
|
||||
]);
|
||||
|
||||
expect($application->deployments()->count())->toBe(1);
|
||||
expect($application->deployments()->first()->status)->toBe('queued');
|
||||
});
|
||||
```
|
||||
|
||||
## Documentation Standards
|
||||
|
||||
### Code Documentation
|
||||
```php
|
||||
/**
|
||||
* Deploy an application to the specified server.
|
||||
*
|
||||
* This method creates a new deployment queue entry and dispatches
|
||||
* a background job to handle the actual deployment process.
|
||||
*
|
||||
* @param Application $application The application to deploy
|
||||
* @param array $options Additional deployment options
|
||||
* @return ApplicationDeploymentQueue The created deployment queue entry
|
||||
*
|
||||
* @throws DeploymentException When deployment cannot be started
|
||||
* @throws ServerConnectionException When server is unreachable
|
||||
*/
|
||||
public function deploy(Application $application, array $options = []): ApplicationDeploymentQueue
|
||||
{
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
### API Documentation
|
||||
```php
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/applications/{application}/deploy",
|
||||
* summary="Deploy an application",
|
||||
* description="Triggers a new deployment for the specified application",
|
||||
* operationId="deployApplication",
|
||||
* tags={"Applications"},
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(
|
||||
* name="application",
|
||||
* in="path",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer"),
|
||||
* description="Application ID"
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Deployment started successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="message", type="string"),
|
||||
* @OA\Property(property="deployment_id", type="integer")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Database Optimization
|
||||
```php
|
||||
// Use eager loading to prevent N+1 queries
|
||||
$applications = Application::with([
|
||||
'server:id,name,ip',
|
||||
'environment:id,name',
|
||||
'latestDeployment:id,application_id,status,created_at'
|
||||
])->get();
|
||||
|
||||
// Use database transactions for consistency
|
||||
DB::transaction(function () use ($application) {
|
||||
$deployment = $application->deployments()->create(['status' => 'queued']);
|
||||
$application->update(['last_deployment_at' => now()]);
|
||||
DeployApplicationJob::dispatch($deployment);
|
||||
});
|
||||
```
|
||||
|
||||
### Caching Strategies
|
||||
```php
|
||||
// Cache expensive operations
|
||||
public function getServerMetrics(Server $server): array
|
||||
{
|
||||
return Cache::remember(
|
||||
"server.{$server->id}.metrics",
|
||||
now()->addMinutes(5),
|
||||
fn () => $this->fetchServerMetrics($server)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment & Release Process
|
||||
|
||||
### Version Management
|
||||
- **[versions.json](mdc:versions.json)** - Version tracking (355B, 19 lines)
|
||||
- **[CHANGELOG.md](mdc:CHANGELOG.md)** - Release notes (187KB, 7411 lines)
|
||||
- **[cliff.toml](mdc:cliff.toml)** - Changelog generation (3.2KB, 85 lines)
|
||||
|
||||
### Release Workflow
|
||||
```bash
|
||||
# Create release branch
|
||||
git checkout -b release/v4.1.0
|
||||
|
||||
# Update version numbers
|
||||
# Update CHANGELOG.md
|
||||
# Run full test suite
|
||||
./vendor/bin/pest
|
||||
npm run test
|
||||
|
||||
# Create release commit
|
||||
git commit -m "chore: release v4.1.0"
|
||||
|
||||
# Create and push tag
|
||||
git tag v4.1.0
|
||||
git push origin v4.1.0
|
||||
|
||||
# Merge to main
|
||||
git checkout main
|
||||
git merge release/v4.1.0
|
||||
```
|
||||
|
||||
## Contributing Guidelines
|
||||
|
||||
### Pull Request Process
|
||||
1. **Fork** the repository
|
||||
2. **Create** feature branch from `main`
|
||||
3. **Implement** changes with tests
|
||||
4. **Run** code quality checks
|
||||
5. **Submit** pull request with clear description
|
||||
6. **Address** review feedback
|
||||
7. **Merge** after approval
|
||||
|
||||
### Code Review Checklist
|
||||
- [ ] Code follows project standards
|
||||
- [ ] Tests cover new functionality
|
||||
- [ ] Documentation is updated
|
||||
- [ ] No breaking changes without migration
|
||||
- [ ] Performance impact considered
|
||||
- [ ] Security implications reviewed
|
||||
|
||||
### Issue Reporting
|
||||
- Use issue templates
|
||||
- Provide reproduction steps
|
||||
- Include environment details
|
||||
- Add relevant logs/screenshots
|
||||
- Label appropriately
|
||||
452
.cursor/rules/form-components.mdc
Normal file
452
.cursor/rules/form-components.mdc
Normal 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>
|
||||
```
|
||||
354
.cursor/rules/frontend-patterns.mdc
Normal file
354
.cursor/rules/frontend-patterns.mdc
Normal file
@@ -0,0 +1,354 @@
|
||||
---
|
||||
description: Livewire components, Alpine.js patterns, Tailwind CSS, and enhanced form components
|
||||
globs: app/Livewire/**/*.php, resources/views/**/*.blade.php, resources/js/**/*.js, resources/css/**/*.css
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Frontend Architecture & Patterns
|
||||
|
||||
## Frontend Philosophy
|
||||
|
||||
Coolify uses a **server-side first** approach with minimal JavaScript, leveraging Livewire for reactivity and Alpine.js for lightweight client-side interactions.
|
||||
|
||||
## Core Frontend Stack
|
||||
|
||||
### Livewire 3.5+ (Primary Framework)
|
||||
- **Server-side rendering** with reactive components
|
||||
- **Real-time updates** without page refreshes
|
||||
- **State management** handled on the server
|
||||
- **WebSocket integration** for live updates
|
||||
|
||||
### Alpine.js (Client-Side Interactivity)
|
||||
- **Lightweight JavaScript** for DOM manipulation
|
||||
- **Declarative directives** in HTML
|
||||
- **Component-like behavior** without build steps
|
||||
- **Perfect companion** to Livewire
|
||||
|
||||
### Tailwind CSS 4.1+ (Styling)
|
||||
- **Utility-first** CSS framework
|
||||
- **Custom design system** for deployment platform
|
||||
- **Responsive design** built-in
|
||||
- **Dark mode support**
|
||||
|
||||
## Livewire Component Structure
|
||||
|
||||
### Location: [app/Livewire/](mdc:app/Livewire)
|
||||
|
||||
#### Core Application Components
|
||||
- **[Dashboard.php](mdc:app/Livewire/Dashboard.php)** - Main dashboard interface
|
||||
- **[ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php)** - Real-time activity tracking
|
||||
- **[MonacoEditor.php](mdc:app/Livewire/MonacoEditor.php)** - Code editor component
|
||||
|
||||
#### Server Management
|
||||
- **Server/** directory - Server configuration and monitoring
|
||||
- Real-time server status updates
|
||||
- SSH connection management
|
||||
- Resource monitoring
|
||||
|
||||
#### Project & Application Management
|
||||
- **Project/** directory - Project organization
|
||||
- Application deployment interfaces
|
||||
- Environment variable management
|
||||
- Service configuration
|
||||
|
||||
#### Settings & Configuration
|
||||
- **Settings/** directory - System configuration
|
||||
- **[SettingsEmail.php](mdc:app/Livewire/SettingsEmail.php)** - Email notification setup
|
||||
- **[SettingsOauth.php](mdc:app/Livewire/SettingsOauth.php)** - OAuth provider configuration
|
||||
- **[SettingsBackup.php](mdc:app/Livewire/SettingsBackup.php)** - Backup configuration
|
||||
|
||||
#### User & Team Management
|
||||
- **Team/** directory - Team collaboration features
|
||||
- **Profile/** directory - User profile management
|
||||
- **Security/** directory - Security settings
|
||||
|
||||
## Blade Template Organization
|
||||
|
||||
### Location: [resources/views/](mdc:resources/views)
|
||||
|
||||
#### Layout Structure
|
||||
- **layouts/** - Base layout templates
|
||||
- **components/** - Reusable UI components
|
||||
- **livewire/** - Livewire component views
|
||||
|
||||
#### Feature-Specific Views
|
||||
- **server/** - Server management interfaces
|
||||
- **auth/** - Authentication pages
|
||||
- **emails/** - Email templates
|
||||
- **errors/** - Error pages
|
||||
|
||||
## Interactive Components
|
||||
|
||||
### Monaco Editor Integration
|
||||
- **Code editing** for configuration files
|
||||
- **Syntax highlighting** for multiple languages
|
||||
- **Live validation** and error detection
|
||||
- **Integration** with deployment process
|
||||
|
||||
### Terminal Emulation (XTerm.js)
|
||||
- **Real-time terminal** access to servers
|
||||
- **WebSocket-based** communication
|
||||
- **Multi-session** support
|
||||
- **Secure connection** through SSH
|
||||
|
||||
### Real-Time Updates
|
||||
- **WebSocket connections** via Laravel Echo
|
||||
- **Live deployment logs** streaming
|
||||
- **Server monitoring** with live metrics
|
||||
- **Activity notifications** in real-time
|
||||
|
||||
## Alpine.js Patterns
|
||||
|
||||
### Common Directives Used
|
||||
```html
|
||||
<!-- State management -->
|
||||
<div x-data="{ open: false }">
|
||||
|
||||
<!-- Event handling -->
|
||||
<button x-on:click="open = !open">
|
||||
|
||||
<!-- Conditional rendering -->
|
||||
<div x-show="open">
|
||||
|
||||
<!-- Data binding -->
|
||||
<input x-model="searchTerm">
|
||||
|
||||
<!-- Component initialization -->
|
||||
<div x-init="initializeComponent()">
|
||||
```
|
||||
|
||||
### Integration with Livewire
|
||||
```html
|
||||
<!-- Livewire actions with Alpine state -->
|
||||
<button
|
||||
x-data="{ loading: false }"
|
||||
x-on:click="loading = true"
|
||||
wire:click="deploy"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="deploy"
|
||||
>
|
||||
<span x-show="!loading">Deploy</span>
|
||||
<span x-show="loading">Deploying...</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
## Tailwind CSS Patterns
|
||||
|
||||
### Design System
|
||||
- **Consistent spacing** using Tailwind scale
|
||||
- **Color palette** optimized for deployment platform
|
||||
- **Typography** hierarchy for technical content
|
||||
- **Component classes** for reusable elements
|
||||
|
||||
### Responsive Design
|
||||
```html
|
||||
<!-- Mobile-first responsive design -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Content adapts to screen size -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Dark Mode Support
|
||||
```html
|
||||
<!-- Dark mode variants -->
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
<!-- Automatic dark mode switching -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## Build Process
|
||||
|
||||
### Vite Configuration ([vite.config.js](mdc:vite.config.js))
|
||||
- **Fast development** with hot module replacement
|
||||
- **Optimized production** builds
|
||||
- **Asset versioning** for cache busting
|
||||
- **CSS processing** with PostCSS
|
||||
|
||||
### Asset Compilation
|
||||
```bash
|
||||
# Development
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
```
|
||||
|
||||
## State Management Patterns
|
||||
|
||||
### Server-Side State (Livewire)
|
||||
- **Component properties** for persistent state
|
||||
- **Session storage** for user preferences
|
||||
- **Database models** for application state
|
||||
- **Cache layer** for performance
|
||||
|
||||
### Client-Side State (Alpine.js)
|
||||
- **Local component state** for UI interactions
|
||||
- **Form validation** and user feedback
|
||||
- **Modal and dropdown** state management
|
||||
- **Temporary UI states** (loading, hover, etc.)
|
||||
|
||||
## Real-Time Features
|
||||
|
||||
### WebSocket Integration
|
||||
```php
|
||||
// Livewire component with real-time updates
|
||||
class ActivityMonitor extends Component
|
||||
{
|
||||
public function getListeners()
|
||||
{
|
||||
return [
|
||||
'deployment.started' => 'refresh',
|
||||
'deployment.finished' => 'refresh',
|
||||
'server.status.changed' => 'updateServerStatus',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Broadcasting
|
||||
- **Laravel Echo** for client-side WebSocket handling
|
||||
- **Pusher protocol** for real-time communication
|
||||
- **Private channels** for user-specific events
|
||||
- **Presence channels** for collaborative features
|
||||
|
||||
## Performance Patterns
|
||||
|
||||
### Lazy Loading
|
||||
```php
|
||||
// Livewire lazy loading
|
||||
class ServerList extends Component
|
||||
{
|
||||
public function placeholder()
|
||||
{
|
||||
return view('components.loading-skeleton');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Caching Strategies
|
||||
- **Fragment caching** for expensive operations
|
||||
- **Image optimization** with lazy loading
|
||||
- **Asset bundling** and compression
|
||||
- **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
|
||||
|
||||
### Livewire Forms
|
||||
```php
|
||||
class ServerCreateForm extends Component
|
||||
{
|
||||
public $name;
|
||||
public $ip;
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|min:3',
|
||||
'ip' => 'required|ip',
|
||||
];
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->validate();
|
||||
// Save logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Real-Time Validation
|
||||
- **Live validation** as user types
|
||||
- **Server-side validation** rules
|
||||
- **Error message** display
|
||||
- **Success feedback** patterns
|
||||
|
||||
## Component Communication
|
||||
|
||||
### Parent-Child Communication
|
||||
```php
|
||||
// Parent component
|
||||
$this->emit('serverCreated', $server->id);
|
||||
|
||||
// Child component
|
||||
protected $listeners = ['serverCreated' => 'refresh'];
|
||||
```
|
||||
|
||||
### Cross-Component Events
|
||||
- **Global events** for application-wide updates
|
||||
- **Scoped events** for feature-specific communication
|
||||
- **Browser events** for JavaScript integration
|
||||
|
||||
## Error Handling & UX
|
||||
|
||||
### Loading States
|
||||
- **Skeleton screens** during data loading
|
||||
- **Progress indicators** for long operations
|
||||
- **Optimistic updates** with rollback capability
|
||||
|
||||
### Error Display
|
||||
- **Toast notifications** for user feedback
|
||||
- **Inline validation** errors
|
||||
- **Global error** handling
|
||||
- **Retry mechanisms** for failed operations
|
||||
|
||||
## Accessibility Patterns
|
||||
|
||||
### ARIA Labels and Roles
|
||||
```html
|
||||
<button
|
||||
aria-label="Deploy application"
|
||||
aria-describedby="deploy-help"
|
||||
wire:click="deploy"
|
||||
>
|
||||
Deploy
|
||||
</button>
|
||||
```
|
||||
|
||||
### Keyboard Navigation
|
||||
- **Tab order** management
|
||||
- **Keyboard shortcuts** for power users
|
||||
- **Focus management** in modals and forms
|
||||
- **Screen reader** compatibility
|
||||
|
||||
## Mobile Optimization
|
||||
|
||||
### Touch-Friendly Interface
|
||||
- **Larger tap targets** for mobile devices
|
||||
- **Swipe gestures** where appropriate
|
||||
- **Mobile-optimized** forms and navigation
|
||||
|
||||
### Progressive Enhancement
|
||||
- **Core functionality** works without JavaScript
|
||||
- **Enhanced experience** with JavaScript enabled
|
||||
- **Offline capabilities** where possible
|
||||
405
.cursor/rules/laravel-boost.mdc
Normal file
405
.cursor/rules/laravel-boost.mdc
Normal 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>
|
||||
161
.cursor/rules/project-overview.mdc
Normal file
161
.cursor/rules/project-overview.mdc
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
description: High-level project mission, core concepts, and architectural overview
|
||||
globs: README.md, CONTRIBUTING.md, CHANGELOG.md, *.md
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Project Overview
|
||||
|
||||
## What is Coolify?
|
||||
|
||||
Coolify is an **open-source & self-hostable alternative to Heroku / Netlify / Vercel**. It's a comprehensive deployment platform that helps you manage servers, applications, and databases on your own hardware with just an SSH connection.
|
||||
|
||||
## Core Mission
|
||||
|
||||
**"Imagine having the ease of a cloud but with your own servers. That is Coolify."**
|
||||
|
||||
- **No vendor lock-in** - All configurations saved to your servers
|
||||
- **Self-hosted** - Complete control over your infrastructure
|
||||
- **SSH-only requirement** - Works with VPS, Bare Metal, Raspberry PIs, anything
|
||||
- **Docker-first** - Container-based deployment architecture
|
||||
|
||||
## Key Features
|
||||
|
||||
### 🚀 **Application Deployment**
|
||||
- Git-based deployments (GitHub, GitLab, Bitbucket, Gitea)
|
||||
- Docker & Docker Compose support
|
||||
- Preview deployments for pull requests
|
||||
- Zero-downtime deployments
|
||||
- Build cache optimization
|
||||
|
||||
### 🖥️ **Server Management**
|
||||
- Multi-server orchestration
|
||||
- Real-time monitoring and logs
|
||||
- SSH key management
|
||||
- Proxy configuration (Traefik/Caddy)
|
||||
- Resource usage tracking
|
||||
|
||||
### 🗄️ **Database Management**
|
||||
- PostgreSQL, MySQL, MariaDB, MongoDB
|
||||
- Redis, KeyDB, Dragonfly, ClickHouse
|
||||
- Automated backups with S3 integration
|
||||
- Database clustering support
|
||||
|
||||
### 🔧 **Infrastructure as Code**
|
||||
- Docker Compose generation
|
||||
- Environment variable management
|
||||
- SSL certificate automation
|
||||
- Custom domain configuration
|
||||
|
||||
### 👥 **Team Collaboration**
|
||||
- Multi-tenant team organization
|
||||
- Role-based access control
|
||||
- Project and environment isolation
|
||||
- Team-wide resource sharing
|
||||
|
||||
### 📊 **Monitoring & Observability**
|
||||
- Real-time application logs
|
||||
- Server resource monitoring
|
||||
- Deployment status tracking
|
||||
- Webhook integrations
|
||||
- Notification systems (Email, Discord, Slack, Telegram)
|
||||
|
||||
## Target Users
|
||||
|
||||
### **DevOps Engineers**
|
||||
- Infrastructure automation
|
||||
- Multi-environment management
|
||||
- CI/CD pipeline integration
|
||||
|
||||
### **Developers**
|
||||
- Easy application deployment
|
||||
- Development environment provisioning
|
||||
- Preview deployments for testing
|
||||
|
||||
### **Small to Medium Businesses**
|
||||
- Cost-effective Heroku alternative
|
||||
- Self-hosted control and privacy
|
||||
- Scalable infrastructure management
|
||||
|
||||
### **Agencies & Consultants**
|
||||
- Client project isolation
|
||||
- Multi-tenant management
|
||||
- White-label deployment solutions
|
||||
|
||||
## Business Model
|
||||
|
||||
### **Open Source (Free)**
|
||||
- Complete feature set
|
||||
- Self-hosted deployment
|
||||
- Community support
|
||||
- No feature restrictions
|
||||
|
||||
### **Cloud Version (Paid)**
|
||||
- Managed Coolify instance
|
||||
- High availability
|
||||
- Premium support
|
||||
- Email notifications included
|
||||
- Same price as self-hosted server (~$4-5/month)
|
||||
|
||||
## Architecture Philosophy
|
||||
|
||||
### **Server-Side First**
|
||||
- Laravel backend with Livewire frontend
|
||||
- Minimal JavaScript footprint
|
||||
- Real-time updates via WebSockets
|
||||
- Progressive enhancement approach
|
||||
|
||||
### **Docker-Native**
|
||||
- Container-first deployment strategy
|
||||
- Docker Compose orchestration
|
||||
- Image building and registry integration
|
||||
- Volume and network management
|
||||
|
||||
### **Security-Focused**
|
||||
- SSH-based server communication
|
||||
- Environment variable encryption
|
||||
- Team-based access isolation
|
||||
- Audit logging and activity tracking
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
coolify/
|
||||
├── app/ # Laravel application core
|
||||
│ ├── Models/ # Domain models (Application, Server, Service)
|
||||
│ ├── Livewire/ # Frontend components
|
||||
│ ├── Actions/ # Business logic actions
|
||||
│ └── Jobs/ # Background job processing
|
||||
├── resources/ # Frontend assets and views
|
||||
├── database/ # Migrations and seeders
|
||||
├── docker/ # Docker configuration
|
||||
├── scripts/ # Installation and utility scripts
|
||||
└── tests/ # Test suites (Pest, Dusk)
|
||||
```
|
||||
|
||||
## Key Differentiators
|
||||
|
||||
### **vs. Heroku**
|
||||
- ✅ Self-hosted (no vendor lock-in)
|
||||
- ✅ Multi-server support
|
||||
- ✅ No usage-based pricing
|
||||
- ✅ Full infrastructure control
|
||||
|
||||
### **vs. Vercel/Netlify**
|
||||
- ✅ Backend application support
|
||||
- ✅ Database management included
|
||||
- ✅ Multi-environment workflows
|
||||
- ✅ Custom server infrastructure
|
||||
|
||||
### **vs. Docker Swarm/Kubernetes**
|
||||
- ✅ User-friendly web interface
|
||||
- ✅ Git-based deployment workflows
|
||||
- ✅ Integrated monitoring and logging
|
||||
- ✅ No complex YAML configuration
|
||||
|
||||
## Development Principles
|
||||
|
||||
- **Simplicity over complexity**
|
||||
- **Convention over configuration**
|
||||
- **Security by default**
|
||||
- **Developer experience focused**
|
||||
- **Community-driven development**
|
||||
1105
.cursor/rules/security-patterns.mdc
Normal file
1105
.cursor/rules/security-patterns.mdc
Normal file
File diff suppressed because it is too large
Load Diff
59
.cursor/rules/self_improve.mdc
Normal file
59
.cursor/rules/self_improve.mdc
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices.
|
||||
globs: **/*
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
- **Rule Improvement Triggers:**
|
||||
- New code patterns not covered by existing rules
|
||||
- Repeated similar implementations across files
|
||||
- Common error patterns that could be prevented
|
||||
- New libraries or tools being used consistently
|
||||
- Emerging best practices in the codebase
|
||||
|
||||
- **Analysis Process:**
|
||||
- Compare new code with existing rules
|
||||
- Identify patterns that should be standardized
|
||||
- Look for references to external documentation
|
||||
- Check for consistent error handling patterns
|
||||
- Monitor test patterns and coverage
|
||||
|
||||
- **Rule Updates:**
|
||||
- **Add New Rules When:**
|
||||
- A new technology/pattern is used in 3+ files
|
||||
- Common bugs could be prevented by a rule
|
||||
- Code reviews repeatedly mention the same feedback
|
||||
- New security or performance patterns emerge
|
||||
|
||||
- **Modify Existing Rules When:**
|
||||
- Better examples exist in the codebase
|
||||
- Additional edge cases are discovered
|
||||
- Related rules have been updated
|
||||
- Implementation details have changed
|
||||
|
||||
|
||||
- **Rule Quality Checks:**
|
||||
- Rules should be actionable and specific
|
||||
- Examples should come from actual code
|
||||
- References should be up to date
|
||||
- Patterns should be consistently enforced
|
||||
|
||||
- **Continuous Improvement:**
|
||||
- Monitor code review comments
|
||||
- Track common development questions
|
||||
- Update rules after major refactors
|
||||
- Add links to relevant documentation
|
||||
- Cross-reference related rules
|
||||
|
||||
- **Rule Deprecation:**
|
||||
- Mark outdated patterns as deprecated
|
||||
- Remove rules that no longer apply
|
||||
- Update references to deprecated rules
|
||||
- Document migration paths for old patterns
|
||||
|
||||
- **Documentation Updates:**
|
||||
- Keep examples synchronized with code
|
||||
- Update references to external docs
|
||||
- Maintain links between related rules
|
||||
- Document breaking changes
|
||||
Follow [cursor_rules.mdc](mdc:.cursor/rules/cursor_rules.mdc) for proper rule formatting and structure.
|
||||
250
.cursor/rules/technology-stack.mdc
Normal file
250
.cursor/rules/technology-stack.mdc
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
description: Complete technology stack, dependencies, and infrastructure components
|
||||
globs: composer.json, package.json, docker-compose*.yml, config/*.php
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Technology Stack
|
||||
|
||||
## Backend Framework
|
||||
|
||||
### **Laravel 12.4.1** (PHP Framework)
|
||||
- **Location**: [composer.json](mdc:composer.json)
|
||||
- **Purpose**: Core application framework
|
||||
- **Key Features**:
|
||||
- Eloquent ORM for database interactions
|
||||
- Artisan CLI for development tasks
|
||||
- Queue system for background jobs
|
||||
- Event-driven architecture
|
||||
|
||||
### **PHP 8.4**
|
||||
- **Requirement**: `^8.4` in [composer.json](mdc:composer.json)
|
||||
- **Features Used**:
|
||||
- Typed properties and return types
|
||||
- Attributes for validation and configuration
|
||||
- Match expressions
|
||||
- Constructor property promotion
|
||||
|
||||
## Frontend Stack
|
||||
|
||||
### **Livewire 3.5.20** (Primary Frontend Framework)
|
||||
- **Purpose**: Server-side rendering with reactive components
|
||||
- **Location**: [app/Livewire/](mdc:app/Livewire/)
|
||||
- **Key Components**:
|
||||
- [Dashboard.php](mdc:app/Livewire/Dashboard.php) - Main interface
|
||||
- [ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php) - Real-time monitoring
|
||||
- [MonacoEditor.php](mdc:app/Livewire/MonacoEditor.php) - Code editor
|
||||
|
||||
### **Alpine.js** (Client-Side Interactivity)
|
||||
- **Purpose**: Lightweight JavaScript for DOM manipulation
|
||||
- **Integration**: Works seamlessly with Livewire components
|
||||
- **Usage**: Declarative directives in Blade templates
|
||||
|
||||
### **Tailwind CSS 4.1.4** (Styling Framework)
|
||||
- **Location**: [package.json](mdc:package.json)
|
||||
- **Configuration**: [postcss.config.cjs](mdc:postcss.config.cjs)
|
||||
- **Extensions**:
|
||||
- `@tailwindcss/forms` - Form styling
|
||||
- `@tailwindcss/typography` - Content typography
|
||||
- `tailwind-scrollbar` - Custom scrollbars
|
||||
|
||||
### **Vue.js 3.5.13** (Component Framework)
|
||||
- **Purpose**: Enhanced interactive components
|
||||
- **Integration**: Used alongside Livewire for complex UI
|
||||
- **Build Tool**: Vite with Vue plugin
|
||||
|
||||
## Database & Caching
|
||||
|
||||
### **PostgreSQL 15** (Primary Database)
|
||||
- **Purpose**: Main application data storage
|
||||
- **Features**: JSONB support, advanced indexing
|
||||
- **Models**: [app/Models/](mdc:app/Models/)
|
||||
|
||||
### **Redis 7** (Caching & Real-time)
|
||||
- **Purpose**:
|
||||
- Session storage
|
||||
- Queue backend
|
||||
- Real-time data caching
|
||||
- WebSocket session management
|
||||
|
||||
### **Supported Databases** (For User Applications)
|
||||
- **PostgreSQL**: [StandalonePostgresql.php](mdc:app/Models/StandalonePostgresql.php)
|
||||
- **MySQL**: [StandaloneMysql.php](mdc:app/Models/StandaloneMysql.php)
|
||||
- **MariaDB**: [StandaloneMariadb.php](mdc:app/Models/StandaloneMariadb.php)
|
||||
- **MongoDB**: [StandaloneMongodb.php](mdc:app/Models/StandaloneMongodb.php)
|
||||
- **Redis**: [StandaloneRedis.php](mdc:app/Models/StandaloneRedis.php)
|
||||
- **KeyDB**: [StandaloneKeydb.php](mdc:app/Models/StandaloneKeydb.php)
|
||||
- **Dragonfly**: [StandaloneDragonfly.php](mdc:app/Models/StandaloneDragonfly.php)
|
||||
- **ClickHouse**: [StandaloneClickhouse.php](mdc:app/Models/StandaloneClickhouse.php)
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
### **Laravel Sanctum 4.0.8**
|
||||
- **Purpose**: API token authentication
|
||||
- **Usage**: Secure API access for external integrations
|
||||
|
||||
### **Laravel Fortify 1.25.4**
|
||||
- **Purpose**: Authentication scaffolding
|
||||
- **Features**: Login, registration, password reset
|
||||
|
||||
### **Laravel Socialite 5.18.0**
|
||||
- **Purpose**: OAuth provider integration
|
||||
- **Providers**:
|
||||
- GitHub, GitLab, Google
|
||||
- Microsoft Azure, Authentik, Discord, Clerk
|
||||
- Custom OAuth implementations
|
||||
|
||||
## Background Processing
|
||||
|
||||
### **Laravel Horizon 5.30.3**
|
||||
- **Purpose**: Queue monitoring and management
|
||||
- **Features**: Real-time queue metrics, failed job handling
|
||||
|
||||
### **Queue System**
|
||||
- **Backend**: Redis-based queues
|
||||
- **Jobs**: [app/Jobs/](mdc:app/Jobs/)
|
||||
- **Processing**: Background deployment and monitoring tasks
|
||||
|
||||
## Development Tools
|
||||
|
||||
### **Build Tools**
|
||||
- **Vite 6.2.6**: Modern build tool and dev server
|
||||
- **Laravel Vite Plugin**: Laravel integration
|
||||
- **PostCSS**: CSS processing pipeline
|
||||
|
||||
### **Code Quality**
|
||||
- **Laravel Pint**: PHP code style fixer
|
||||
- **Rector**: PHP automated refactoring
|
||||
- **PHPStan**: Static analysis tool
|
||||
|
||||
### **Testing Framework**
|
||||
- **Pest 3.8.0**: Modern PHP testing framework
|
||||
- **Laravel Dusk**: Browser automation testing
|
||||
- **PHPUnit**: Unit testing foundation
|
||||
|
||||
## External Integrations
|
||||
|
||||
### **Git Providers**
|
||||
- **GitHub**: Repository integration and webhooks
|
||||
- **GitLab**: Self-hosted and cloud GitLab support
|
||||
- **Bitbucket**: Atlassian integration
|
||||
- **Gitea**: Self-hosted Git service
|
||||
|
||||
### **Cloud Storage**
|
||||
- **AWS S3**: [league/flysystem-aws-s3-v3](mdc:composer.json)
|
||||
- **SFTP**: [league/flysystem-sftp-v3](mdc:composer.json)
|
||||
- **Local Storage**: File system integration
|
||||
|
||||
### **Notification Services**
|
||||
- **Email**: [resend/resend-laravel](mdc:composer.json)
|
||||
- **Discord**: Custom webhook integration
|
||||
- **Slack**: Webhook notifications
|
||||
- **Telegram**: Bot API integration
|
||||
- **Pushover**: Push notifications
|
||||
|
||||
### **Monitoring & Logging**
|
||||
- **Sentry**: [sentry/sentry-laravel](mdc:composer.json) - Error tracking
|
||||
- **Laravel Ray**: [spatie/laravel-ray](mdc:composer.json) - Debug tool
|
||||
- **Activity Log**: [spatie/laravel-activitylog](mdc:composer.json)
|
||||
|
||||
## DevOps & Infrastructure
|
||||
|
||||
### **Docker & Containerization**
|
||||
- **Docker**: Container runtime
|
||||
- **Docker Compose**: Multi-container orchestration
|
||||
- **Docker Swarm**: Container clustering (optional)
|
||||
|
||||
### **Web Servers & Proxies**
|
||||
- **Nginx**: Primary web server
|
||||
- **Traefik**: Reverse proxy and load balancer
|
||||
- **Caddy**: Alternative reverse proxy
|
||||
|
||||
### **Process Management**
|
||||
- **S6 Overlay**: Process supervisor
|
||||
- **Supervisor**: Alternative process manager
|
||||
|
||||
### **SSL/TLS**
|
||||
- **Let's Encrypt**: Automatic SSL certificates
|
||||
- **Custom Certificates**: Manual SSL management
|
||||
|
||||
## Terminal & Code Editing
|
||||
|
||||
### **XTerm.js 5.5.0**
|
||||
- **Purpose**: Web-based terminal emulator
|
||||
- **Features**: SSH session management, real-time command execution
|
||||
- **Addons**: Fit addon for responsive terminals
|
||||
|
||||
### **Monaco Editor**
|
||||
- **Purpose**: Code editor component
|
||||
- **Features**: Syntax highlighting, auto-completion
|
||||
- **Integration**: Environment variable editing, configuration files
|
||||
|
||||
## API & Documentation
|
||||
|
||||
### **OpenAPI/Swagger**
|
||||
- **Documentation**: [openapi.json](mdc:openapi.json) (373KB)
|
||||
- **Generator**: [zircote/swagger-php](mdc:composer.json)
|
||||
- **API Routes**: [routes/api.php](mdc:routes/api.php)
|
||||
|
||||
### **WebSocket Communication**
|
||||
- **Laravel Echo**: Real-time event broadcasting
|
||||
- **Pusher**: WebSocket service integration
|
||||
- **Soketi**: Self-hosted WebSocket server
|
||||
|
||||
## Package Management
|
||||
|
||||
### **PHP Dependencies** ([composer.json](mdc:composer.json))
|
||||
```json
|
||||
{
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"laravel/framework": "12.4.1",
|
||||
"livewire/livewire": "^3.5.20",
|
||||
"spatie/laravel-data": "^4.13.1",
|
||||
"lorisleiva/laravel-actions": "^2.8.6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **JavaScript Dependencies** ([package.json](mdc:package.json))
|
||||
```json
|
||||
{
|
||||
"devDependencies": {
|
||||
"vite": "^6.2.6",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"@vitejs/plugin-vue": "5.2.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ioredis": "5.6.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### **Build Configuration**
|
||||
- **[vite.config.js](mdc:vite.config.js)**: Frontend build setup
|
||||
- **[postcss.config.cjs](mdc:postcss.config.cjs)**: CSS processing
|
||||
- **[rector.php](mdc:rector.php)**: PHP refactoring rules
|
||||
- **[pint.json](mdc:pint.json)**: Code style configuration
|
||||
|
||||
### **Testing Configuration**
|
||||
- **[phpunit.xml](mdc:phpunit.xml)**: Unit test configuration
|
||||
- **[phpunit.dusk.xml](mdc:phpunit.dusk.xml)**: Browser test configuration
|
||||
- **[tests/Pest.php](mdc:tests/Pest.php)**: Pest testing setup
|
||||
|
||||
## Version Requirements
|
||||
|
||||
### **Minimum Requirements**
|
||||
- **PHP**: 8.4+
|
||||
- **Node.js**: 18+ (for build tools)
|
||||
- **PostgreSQL**: 15+
|
||||
- **Redis**: 7+
|
||||
- **Docker**: 20.10+
|
||||
- **Docker Compose**: 2.0+
|
||||
|
||||
### **Recommended Versions**
|
||||
- **Ubuntu**: 22.04 LTS or 24.04 LTS
|
||||
- **Memory**: 2GB+ RAM
|
||||
- **Storage**: 20GB+ available space
|
||||
- **Network**: Stable internet connection for deployments
|
||||
608
.cursor/rules/testing-patterns.mdc
Normal file
608
.cursor/rules/testing-patterns.mdc
Normal file
@@ -0,0 +1,608 @@
|
||||
---
|
||||
description: Testing strategies with Pest PHP, Laravel Dusk, and quality assurance patterns
|
||||
globs: tests/**/*.php, database/factories/*.php
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Testing Architecture & Patterns
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
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
|
||||
|
||||
### Core Testing Tools
|
||||
- **Pest PHP 3.8+** - Primary testing framework with expressive syntax
|
||||
- **Laravel Dusk** - Browser automation and end-to-end testing
|
||||
- **PHPUnit** - Underlying unit testing framework
|
||||
- **Mockery** - Mocking and stubbing for isolated tests
|
||||
|
||||
### Testing Configuration
|
||||
- **[tests/Pest.php](mdc:tests/Pest.php)** - Pest configuration and global setup (1.5KB, 45 lines)
|
||||
- **[tests/TestCase.php](mdc:tests/TestCase.php)** - Base test case class (163B, 11 lines)
|
||||
- **[tests/CreatesApplication.php](mdc:tests/CreatesApplication.php)** - Application factory trait (375B, 22 lines)
|
||||
- **[tests/DuskTestCase.php](mdc:tests/DuskTestCase.php)** - Browser testing setup (1.4KB, 58 lines)
|
||||
|
||||
## Test Directory Structure
|
||||
|
||||
### Test Organization
|
||||
- **[tests/Feature/](mdc:tests/Feature)** - Feature and integration tests
|
||||
- **[tests/Unit/](mdc:tests/Unit)** - Unit tests for isolated components
|
||||
- **[tests/Browser/](mdc:tests/Browser)** - Laravel Dusk browser tests
|
||||
- **[tests/Traits/](mdc:tests/Traits)** - Shared testing utilities
|
||||
|
||||
## Unit Testing Patterns
|
||||
|
||||
### Model Testing
|
||||
```php
|
||||
// Testing Eloquent models
|
||||
test('application model has correct relationships', function () {
|
||||
$application = Application::factory()->create();
|
||||
|
||||
expect($application->server)->toBeInstanceOf(Server::class);
|
||||
expect($application->environment)->toBeInstanceOf(Environment::class);
|
||||
expect($application->deployments)->toBeInstanceOf(Collection::class);
|
||||
});
|
||||
|
||||
test('application can generate deployment configuration', function () {
|
||||
$application = Application::factory()->create([
|
||||
'name' => 'test-app',
|
||||
'git_repository' => 'https://github.com/user/repo.git'
|
||||
]);
|
||||
|
||||
$config = $application->generateDockerCompose();
|
||||
|
||||
expect($config)->toContain('test-app');
|
||||
expect($config)->toContain('image:');
|
||||
expect($config)->toContain('networks:');
|
||||
});
|
||||
```
|
||||
|
||||
### Service Layer Testing
|
||||
```php
|
||||
// Testing service classes
|
||||
test('configuration generator creates valid docker compose', function () {
|
||||
$generator = new ConfigurationGenerator();
|
||||
$application = Application::factory()->create();
|
||||
|
||||
$compose = $generator->generateDockerCompose($application);
|
||||
|
||||
expect($compose)->toBeString();
|
||||
expect(yaml_parse($compose))->toBeArray();
|
||||
expect($compose)->toContain('version: "3.8"');
|
||||
});
|
||||
|
||||
test('docker image parser validates image names', function () {
|
||||
$parser = new DockerImageParser();
|
||||
|
||||
expect($parser->isValid('nginx:latest'))->toBeTrue();
|
||||
expect($parser->isValid('invalid-image-name'))->toBeFalse();
|
||||
expect($parser->parse('nginx:1.21'))->toEqual([
|
||||
'registry' => 'docker.io',
|
||||
'namespace' => 'library',
|
||||
'repository' => 'nginx',
|
||||
'tag' => '1.21'
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
### Action Testing
|
||||
```php
|
||||
// Testing Laravel Actions
|
||||
test('deploy application action creates deployment queue', function () {
|
||||
$application = Application::factory()->create();
|
||||
$action = new DeployApplicationAction();
|
||||
|
||||
$deployment = $action->handle($application);
|
||||
|
||||
expect($deployment)->toBeInstanceOf(ApplicationDeploymentQueue::class);
|
||||
expect($deployment->status)->toBe('queued');
|
||||
expect($deployment->application_id)->toBe($application->id);
|
||||
});
|
||||
|
||||
test('server validation action checks ssh connectivity', function () {
|
||||
$server = Server::factory()->create([
|
||||
'ip' => '192.168.1.100',
|
||||
'port' => 22
|
||||
]);
|
||||
|
||||
$action = new ValidateServerAction();
|
||||
|
||||
// Mock SSH connection
|
||||
$this->mock(SshConnection::class, function ($mock) {
|
||||
$mock->shouldReceive('connect')->andReturn(true);
|
||||
$mock->shouldReceive('execute')->with('docker --version')->andReturn('Docker version 20.10.0');
|
||||
});
|
||||
|
||||
$result = $action->handle($server);
|
||||
|
||||
expect($result['ssh_connection'])->toBeTrue();
|
||||
expect($result['docker_installed'])->toBeTrue();
|
||||
});
|
||||
```
|
||||
|
||||
## Feature Testing Patterns
|
||||
|
||||
### API Testing
|
||||
```php
|
||||
// Testing API endpoints
|
||||
test('authenticated user can list applications', function () {
|
||||
$user = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$user->teams()->attach($team);
|
||||
|
||||
$applications = Application::factory(3)->create([
|
||||
'team_id' => $team->id
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/v1/applications');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(3, 'data')
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'*' => ['id', 'name', 'fqdn', 'status', 'created_at']
|
||||
]
|
||||
]);
|
||||
});
|
||||
|
||||
test('user cannot access applications from other teams', function () {
|
||||
$user = User::factory()->create();
|
||||
$otherTeam = Team::factory()->create();
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'team_id' => $otherTeam->id
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson("/api/v1/applications/{$application->id}");
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
```
|
||||
|
||||
### Deployment Testing
|
||||
```php
|
||||
// Testing deployment workflows
|
||||
test('application deployment creates docker containers', function () {
|
||||
$application = Application::factory()->create([
|
||||
'git_repository' => 'https://github.com/laravel/laravel.git',
|
||||
'git_branch' => 'main'
|
||||
]);
|
||||
|
||||
// Mock Docker operations
|
||||
$this->mock(DockerService::class, function ($mock) {
|
||||
$mock->shouldReceive('buildImage')->andReturn('app:latest');
|
||||
$mock->shouldReceive('createContainer')->andReturn('container_id');
|
||||
$mock->shouldReceive('startContainer')->andReturn(true);
|
||||
});
|
||||
|
||||
$deployment = $application->deploy();
|
||||
|
||||
expect($deployment->status)->toBe('queued');
|
||||
|
||||
// Process the deployment job
|
||||
$this->artisan('queue:work --once');
|
||||
|
||||
$deployment->refresh();
|
||||
expect($deployment->status)->toBe('success');
|
||||
});
|
||||
|
||||
test('failed deployment triggers rollback', function () {
|
||||
$application = Application::factory()->create();
|
||||
|
||||
// Mock failed deployment
|
||||
$this->mock(DockerService::class, function ($mock) {
|
||||
$mock->shouldReceive('buildImage')->andThrow(new DeploymentException('Build failed'));
|
||||
});
|
||||
|
||||
$deployment = $application->deploy();
|
||||
|
||||
$this->artisan('queue:work --once');
|
||||
|
||||
$deployment->refresh();
|
||||
expect($deployment->status)->toBe('failed');
|
||||
expect($deployment->error_message)->toContain('Build failed');
|
||||
});
|
||||
```
|
||||
|
||||
### Webhook Testing
|
||||
```php
|
||||
// Testing webhook endpoints
|
||||
test('github webhook triggers deployment', function () {
|
||||
$application = Application::factory()->create([
|
||||
'git_repository' => 'https://github.com/user/repo.git',
|
||||
'git_branch' => 'main'
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => [
|
||||
'clone_url' => 'https://github.com/user/repo.git'
|
||||
],
|
||||
'head_commit' => [
|
||||
'id' => 'abc123',
|
||||
'message' => 'Update application'
|
||||
]
|
||||
];
|
||||
|
||||
$response = $this->postJson("/webhooks/github/{$application->id}", $payload);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
expect($application->deployments()->count())->toBe(1);
|
||||
expect($application->deployments()->first()->commit_sha)->toBe('abc123');
|
||||
});
|
||||
|
||||
test('webhook validates payload signature', function () {
|
||||
$application = Application::factory()->create();
|
||||
|
||||
$payload = ['invalid' => 'payload'];
|
||||
|
||||
$response = $this->postJson("/webhooks/github/{$application->id}", $payload);
|
||||
|
||||
$response->assertStatus(400);
|
||||
});
|
||||
```
|
||||
|
||||
## Browser Testing (Laravel Dusk)
|
||||
|
||||
### End-to-End Testing
|
||||
```php
|
||||
// Testing complete user workflows
|
||||
test('user can create and deploy application', function () {
|
||||
$user = User::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $user->currentTeam->id]);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user, $server) {
|
||||
$browser->loginAs($user)
|
||||
->visit('/applications/create')
|
||||
->type('name', 'Test Application')
|
||||
->type('git_repository', 'https://github.com/laravel/laravel.git')
|
||||
->type('git_branch', 'main')
|
||||
->select('server_id', $server->id)
|
||||
->press('Create Application')
|
||||
->assertPathIs('/applications/*')
|
||||
->assertSee('Test Application')
|
||||
->press('Deploy')
|
||||
->waitForText('Deployment started', 10)
|
||||
->assertSee('Deployment started');
|
||||
});
|
||||
});
|
||||
|
||||
test('user can monitor deployment logs in real-time', function () {
|
||||
$user = User::factory()->create();
|
||||
$application = Application::factory()->create(['team_id' => $user->currentTeam->id]);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user, $application) {
|
||||
$browser->loginAs($user)
|
||||
->visit("/applications/{$application->id}")
|
||||
->press('Deploy')
|
||||
->waitForText('Deployment started')
|
||||
->click('@logs-tab')
|
||||
->waitFor('@deployment-logs')
|
||||
->assertSee('Building Docker image')
|
||||
->waitForText('Deployment completed', 30);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### UI Component Testing
|
||||
```php
|
||||
// Testing Livewire components
|
||||
test('server status component updates in real-time', function () {
|
||||
$user = User::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $user->currentTeam->id]);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($user, $server) {
|
||||
$browser->loginAs($user)
|
||||
->visit("/servers/{$server->id}")
|
||||
->assertSee('Status: Online')
|
||||
->waitFor('@server-metrics')
|
||||
->assertSee('CPU Usage')
|
||||
->assertSee('Memory Usage')
|
||||
->assertSee('Disk Usage');
|
||||
|
||||
// Simulate server going offline
|
||||
$server->update(['status' => 'offline']);
|
||||
|
||||
$browser->waitForText('Status: Offline', 5)
|
||||
->assertSee('Status: Offline');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Database Testing Patterns
|
||||
|
||||
### Migration Testing
|
||||
```php
|
||||
// Testing database migrations
|
||||
test('applications table has correct structure', function () {
|
||||
expect(Schema::hasTable('applications'))->toBeTrue();
|
||||
expect(Schema::hasColumns('applications', [
|
||||
'id', 'name', 'fqdn', 'git_repository', 'git_branch',
|
||||
'server_id', 'environment_id', 'created_at', 'updated_at'
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
test('foreign key constraints are properly set', function () {
|
||||
$application = Application::factory()->create();
|
||||
|
||||
expect($application->server)->toBeInstanceOf(Server::class);
|
||||
expect($application->environment)->toBeInstanceOf(Environment::class);
|
||||
|
||||
// Test cascade deletion
|
||||
$application->server->delete();
|
||||
expect(Application::find($application->id))->toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
### Factory Testing
|
||||
```php
|
||||
// Testing model factories
|
||||
test('application factory creates valid models', function () {
|
||||
$application = Application::factory()->create();
|
||||
|
||||
expect($application->name)->toBeString();
|
||||
expect($application->git_repository)->toStartWith('https://');
|
||||
expect($application->server_id)->toBeInt();
|
||||
expect($application->environment_id)->toBeInt();
|
||||
});
|
||||
|
||||
test('application factory can create with custom attributes', function () {
|
||||
$application = Application::factory()->create([
|
||||
'name' => 'Custom App',
|
||||
'git_branch' => 'develop'
|
||||
]);
|
||||
|
||||
expect($application->name)->toBe('Custom App');
|
||||
expect($application->git_branch)->toBe('develop');
|
||||
});
|
||||
```
|
||||
|
||||
## Queue Testing
|
||||
|
||||
### Job Testing
|
||||
```php
|
||||
// Testing background jobs
|
||||
test('deploy application job processes successfully', function () {
|
||||
$application = Application::factory()->create();
|
||||
$deployment = ApplicationDeploymentQueue::factory()->create([
|
||||
'application_id' => $application->id,
|
||||
'status' => 'queued'
|
||||
]);
|
||||
|
||||
$job = new DeployApplicationJob($deployment);
|
||||
|
||||
// Mock external dependencies
|
||||
$this->mock(DockerService::class, function ($mock) {
|
||||
$mock->shouldReceive('buildImage')->andReturn('app:latest');
|
||||
$mock->shouldReceive('deployContainer')->andReturn(true);
|
||||
});
|
||||
|
||||
$job->handle();
|
||||
|
||||
$deployment->refresh();
|
||||
expect($deployment->status)->toBe('success');
|
||||
});
|
||||
|
||||
test('failed job is retried with exponential backoff', function () {
|
||||
$application = Application::factory()->create();
|
||||
$deployment = ApplicationDeploymentQueue::factory()->create([
|
||||
'application_id' => $application->id
|
||||
]);
|
||||
|
||||
$job = new DeployApplicationJob($deployment);
|
||||
|
||||
// Mock failure
|
||||
$this->mock(DockerService::class, function ($mock) {
|
||||
$mock->shouldReceive('buildImage')->andThrow(new Exception('Network error'));
|
||||
});
|
||||
|
||||
expect(fn() => $job->handle())->toThrow(Exception::class);
|
||||
|
||||
// Job should be retried
|
||||
expect($job->tries)->toBe(3);
|
||||
expect($job->backoff())->toBe([1, 5, 10]);
|
||||
});
|
||||
```
|
||||
|
||||
## Security Testing
|
||||
|
||||
### Authentication Testing
|
||||
```php
|
||||
// Testing authentication and authorization
|
||||
test('unauthenticated users cannot access protected routes', function () {
|
||||
$response = $this->get('/dashboard');
|
||||
$response->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('users can only access their team resources', function () {
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$team1 = Team::factory()->create();
|
||||
$team2 = Team::factory()->create();
|
||||
|
||||
$user1->teams()->attach($team1);
|
||||
$user2->teams()->attach($team2);
|
||||
|
||||
$application = Application::factory()->create(['team_id' => $team1->id]);
|
||||
|
||||
$response = $this->actingAs($user2)
|
||||
->get("/applications/{$application->id}");
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
```
|
||||
|
||||
### Input Validation Testing
|
||||
```php
|
||||
// Testing input validation and sanitization
|
||||
test('application creation validates required fields', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson('/api/v1/applications', []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['name', 'git_repository', 'server_id']);
|
||||
});
|
||||
|
||||
test('malicious input is properly sanitized', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson('/api/v1/applications', [
|
||||
'name' => '<script>alert("xss")</script>',
|
||||
'git_repository' => 'javascript:alert("xss")',
|
||||
'server_id' => 'invalid'
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
### Load Testing
|
||||
```php
|
||||
// Testing application performance under load
|
||||
test('application list endpoint handles concurrent requests', function () {
|
||||
$user = User::factory()->create();
|
||||
$applications = Application::factory(100)->create(['team_id' => $user->currentTeam->id]);
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/v1/applications');
|
||||
|
||||
$endTime = microtime(true);
|
||||
$responseTime = ($endTime - $startTime) * 1000; // Convert to milliseconds
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect($responseTime)->toBeLessThan(500); // Should respond within 500ms
|
||||
});
|
||||
```
|
||||
|
||||
### Memory Usage Testing
|
||||
```php
|
||||
// Testing memory efficiency
|
||||
test('deployment process does not exceed memory limits', function () {
|
||||
$initialMemory = memory_get_usage();
|
||||
|
||||
$application = Application::factory()->create();
|
||||
$deployment = $application->deploy();
|
||||
|
||||
// Process deployment
|
||||
$this->artisan('queue:work --once');
|
||||
|
||||
$finalMemory = memory_get_usage();
|
||||
$memoryIncrease = $finalMemory - $initialMemory;
|
||||
|
||||
expect($memoryIncrease)->toBeLessThan(50 * 1024 * 1024); // Less than 50MB
|
||||
});
|
||||
```
|
||||
|
||||
## Test Utilities and Helpers
|
||||
|
||||
### Custom Assertions
|
||||
```php
|
||||
// Custom test assertions
|
||||
expect()->extend('toBeValidDockerCompose', function () {
|
||||
$yaml = yaml_parse($this->value);
|
||||
|
||||
return $yaml !== false &&
|
||||
isset($yaml['version']) &&
|
||||
isset($yaml['services']) &&
|
||||
is_array($yaml['services']);
|
||||
});
|
||||
|
||||
expect()->extend('toHaveValidSshConnection', function () {
|
||||
$server = $this->value;
|
||||
|
||||
try {
|
||||
$connection = new SshConnection($server);
|
||||
return $connection->test();
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Test Traits
|
||||
```php
|
||||
// Shared testing functionality
|
||||
trait CreatesTestServers
|
||||
{
|
||||
protected function createTestServer(array $attributes = []): Server
|
||||
{
|
||||
return Server::factory()->create(array_merge([
|
||||
'name' => 'Test Server',
|
||||
'ip' => '127.0.0.1',
|
||||
'port' => 22,
|
||||
'team_id' => $this->user->currentTeam->id
|
||||
], $attributes));
|
||||
}
|
||||
}
|
||||
|
||||
trait MocksDockerOperations
|
||||
{
|
||||
protected function mockDockerService(): void
|
||||
{
|
||||
$this->mock(DockerService::class, function ($mock) {
|
||||
$mock->shouldReceive('buildImage')->andReturn('test:latest');
|
||||
$mock->shouldReceive('createContainer')->andReturn('container_123');
|
||||
$mock->shouldReceive('startContainer')->andReturn(true);
|
||||
$mock->shouldReceive('stopContainer')->andReturn(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Continuous Integration Testing
|
||||
|
||||
### GitHub Actions Integration
|
||||
```yaml
|
||||
# .github/workflows/tests.yml
|
||||
name: Tests
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_PASSWORD: password
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
- name: Install dependencies
|
||||
run: composer install
|
||||
- name: Run tests
|
||||
run: ./vendor/bin/pest
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
```php
|
||||
// Generate test coverage reports
|
||||
test('application has adequate test coverage', function () {
|
||||
$coverage = $this->getCoverageData();
|
||||
|
||||
expect($coverage['application'])->toBeGreaterThan(80);
|
||||
expect($coverage['models'])->toBeGreaterThan(90);
|
||||
expect($coverage['actions'])->toBeGreaterThan(85);
|
||||
});
|
||||
```
|
||||
79
.github/workflows/claude-code-review.yml
vendored
Normal file
79
.github/workflows/claude-code-review.yml
vendored
Normal 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
64
.github/workflows/claude.yml
vendored
Normal 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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,4 +36,4 @@ scripts/load-test/*
|
||||
.env.dusk.local
|
||||
docker/coolify-realtime/node_modules
|
||||
.DS_Store
|
||||
Changelog.md
|
||||
CHANGELOG.md
|
||||
|
||||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
4
.phpactor.json
Normal file
4
.phpactor.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "/phpactor.schema.json",
|
||||
"language_server_phpstan.enabled": true
|
||||
}
|
||||
1019
CHANGELOG.md
1019
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
658
CLAUDE.md
Normal file
658
CLAUDE.md
Normal file
@@ -0,0 +1,658 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Coolify is an open-source, self-hostable platform for deploying applications and managing servers - an alternative to Heroku/Netlify/Vercel. It's built with Laravel (PHP) and uses Docker for containerization.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Frontend Development
|
||||
- `npm run dev` - Start Vite development server for frontend assets
|
||||
- `npm run build` - Build frontend assets for production
|
||||
|
||||
### Backend Development
|
||||
Only run artisan commands inside "coolify" container when in development.
|
||||
- `php artisan serve` - Start Laravel development server
|
||||
- `php artisan migrate` - Run database migrations
|
||||
- `php artisan queue:work` - Start queue worker for background jobs
|
||||
- `php artisan horizon` - Start Laravel Horizon for queue monitoring
|
||||
- `php artisan tinker` - Start interactive PHP REPL
|
||||
|
||||
### Code Quality
|
||||
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
|
||||
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
|
||||
- `./vendor/bin/pest` - Run Pest tests
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Technology Stack
|
||||
- **Backend**: Laravel 12 (PHP 8.4)
|
||||
- **Frontend**: Livewire 3.5+ with Alpine.js and Tailwind CSS 4.1+
|
||||
- **Database**: PostgreSQL 15 (primary), Redis 7 (cache/queues)
|
||||
- **Real-time**: Soketi (WebSocket server)
|
||||
- **Containerization**: Docker & Docker Compose
|
||||
- **Queue Management**: Laravel Horizon
|
||||
|
||||
### Key Components
|
||||
|
||||
#### Core Models
|
||||
- `Application` - Deployed applications with Git integration (74KB, highly complex)
|
||||
- `Server` - Remote servers managed by Coolify (46KB, complex)
|
||||
- `Service` - Docker Compose services (58KB, complex)
|
||||
- `Database` - Standalone database instances (PostgreSQL, MySQL, MongoDB, Redis, etc.)
|
||||
- `Team` - Multi-tenancy support
|
||||
- `Project` - Grouping of environments and resources
|
||||
- `Environment` - Environment isolation (staging, production, etc.)
|
||||
|
||||
#### Job System
|
||||
- Uses Laravel Horizon for queue management
|
||||
- Key jobs: `ApplicationDeploymentJob`, `ServerCheckJob`, `DatabaseBackupJob`
|
||||
- `ServerManagerJob` and `ServerConnectionCheckJob` handle job scheduling
|
||||
|
||||
#### Deployment Flow
|
||||
1. Git webhook triggers deployment
|
||||
2. `ApplicationDeploymentJob` handles build and deployment
|
||||
3. Docker containers are managed on target servers
|
||||
4. Proxy configuration (Nginx/Traefik) is updated
|
||||
|
||||
#### Server Management
|
||||
- SSH-based server communication via `ExecuteRemoteCommand` trait
|
||||
- Docker installation and management
|
||||
- Proxy configuration generation
|
||||
- Resource monitoring and cleanup
|
||||
|
||||
### Directory Structure
|
||||
- `app/Actions/` - Domain-specific actions (Application, Database, Server, etc.)
|
||||
- `app/Jobs/` - Background queue jobs
|
||||
- `app/Livewire/` - Frontend components (full-stack with Livewire)
|
||||
- `app/Models/` - Eloquent models
|
||||
- `app/Rules/` - Custom validation rules
|
||||
- `app/Http/Middleware/` - HTTP middleware
|
||||
- `bootstrap/helpers/` - Helper functions for various domains
|
||||
- `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
|
||||
|
||||
### Frontend Philosophy
|
||||
Coolify uses a **server-side first** approach with minimal JavaScript:
|
||||
- **Livewire** for server-side rendering with reactive components
|
||||
- **Alpine.js** for lightweight client-side interactions
|
||||
- **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
|
||||
- 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>
|
||||
|
||||
|
||||
Random other things you should remember:
|
||||
- App\Models\Application::team must return a relationship instance., always use team()
|
||||
@@ -53,6 +53,7 @@ Thank you so much!
|
||||
|
||||
## 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
|
||||
* [Algora](https://algora.io?ref=coolify.io) - Open source contribution 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
|
||||
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
|
||||
|
||||
|
||||
## 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://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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
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:
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce the issue
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Application;
|
||||
|
||||
use App\Actions\Server\CleanupDocker;
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Models\Application;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
@@ -14,30 +15,46 @@ class StopApplication
|
||||
|
||||
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true)
|
||||
{
|
||||
try {
|
||||
$server = $application->destination->server;
|
||||
if (! $server->isFunctional()) {
|
||||
return 'Server is not functional';
|
||||
}
|
||||
|
||||
if ($server->isSwarm()) {
|
||||
instant_remote_process(["docker stack rm {$application->uuid}"], $server);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$containersToStop = $application->getContainersToStop($previewDeployments);
|
||||
$application->stopContainers($containersToStop, $server);
|
||||
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$application->deleteConnectedNetworks();
|
||||
}
|
||||
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, true);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return $e->getMessage();
|
||||
$servers = collect([$application->destination->server]);
|
||||
if ($application?->additional_servers?->count() > 0) {
|
||||
$servers = $servers->merge($application->additional_servers);
|
||||
}
|
||||
foreach ($servers as $server) {
|
||||
try {
|
||||
if (! $server->isFunctional()) {
|
||||
return 'Server is not functional';
|
||||
}
|
||||
|
||||
if ($server->isSwarm()) {
|
||||
instant_remote_process(["docker stack rm {$application->uuid}"], $server);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$containers = $previewDeployments
|
||||
? getCurrentApplicationContainerStatus($server, $application->id, includePullrequests: true)
|
||||
: getCurrentApplicationContainerStatus($server, $application->id, 0);
|
||||
|
||||
$containersToStop = $containers->pluck('Names')->toArray();
|
||||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=30 $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$application->deleteConnectedNetworks();
|
||||
}
|
||||
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, false, false);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
ServiceStatusChanged::dispatch($application->environment->project->team->id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ class StopApplicationOneServer
|
||||
$containerName = data_get($container, 'Names');
|
||||
if ($containerName) {
|
||||
instant_remote_process(
|
||||
["docker rm -f {$containerName}"],
|
||||
[
|
||||
"docker stop --time=30 $containerName",
|
||||
"docker rm -f $containerName",
|
||||
],
|
||||
$server
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,14 +104,11 @@ class RunRemoteProcess
|
||||
$this->activity->save();
|
||||
if ($this->call_event_on_finish) {
|
||||
try {
|
||||
if ($this->call_event_data) {
|
||||
event(resolve("App\\Events\\$this->call_event_on_finish", [
|
||||
'data' => $this->call_event_data,
|
||||
]));
|
||||
$eventClass = "App\\Events\\$this->call_event_on_finish";
|
||||
if (! is_null($this->call_event_data)) {
|
||||
event(new $eventClass($this->call_event_data));
|
||||
} else {
|
||||
event(resolve("App\\Events\\$this->call_event_on_finish", [
|
||||
'userId' => $this->activity->causer_id,
|
||||
]));
|
||||
event(new $eventClass($this->activity->causer_id));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Error calling event: '.$e->getMessage());
|
||||
|
||||
@@ -27,6 +27,8 @@ class StartDatabaseProxy
|
||||
$server = data_get($database, 'destination.server');
|
||||
$containerName = data_get($database, 'uuid');
|
||||
$proxyContainerName = "{$database->uuid}-proxy";
|
||||
$isSSLEnabled = $database->enable_ssl ?? false;
|
||||
|
||||
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
$databaseType = $database->databaseType();
|
||||
$network = $database->service->uuid;
|
||||
@@ -42,8 +44,17 @@ class StartDatabaseProxy
|
||||
'standalone-mongodb' => 27017,
|
||||
default => throw new \Exception("Unsupported database type: $databaseType"),
|
||||
};
|
||||
if ($isSSLEnabled) {
|
||||
$internalPort = match ($databaseType) {
|
||||
'standalone-redis', 'standalone-keydb', 'standalone-dragonfly' => 6380,
|
||||
default => $internalPort,
|
||||
};
|
||||
}
|
||||
|
||||
$configuration_dir = database_proxy_dir($database->uuid);
|
||||
if (isDev()) {
|
||||
$configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy';
|
||||
}
|
||||
$nginxconf = <<<EOF
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
@@ -59,19 +70,10 @@ class StartDatabaseProxy
|
||||
proxy_pass $containerName:$internalPort;
|
||||
}
|
||||
}
|
||||
EOF;
|
||||
$dockerfile = <<< 'EOF'
|
||||
FROM nginx:stable-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
EOF;
|
||||
$docker_compose = [
|
||||
'services' => [
|
||||
$proxyContainerName => [
|
||||
'build' => [
|
||||
'context' => $configuration_dir,
|
||||
'dockerfile' => 'Dockerfile',
|
||||
],
|
||||
'image' => 'nginx:stable-alpine',
|
||||
'container_name' => $proxyContainerName,
|
||||
'restart' => RESTART_MODE,
|
||||
@@ -81,6 +83,13 @@ class StartDatabaseProxy
|
||||
'networks' => [
|
||||
$network,
|
||||
],
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => "$configuration_dir/nginx.conf",
|
||||
'target' => '/etc/nginx/nginx.conf',
|
||||
],
|
||||
],
|
||||
'healthcheck' => [
|
||||
'test' => [
|
||||
'CMD-SHELL',
|
||||
@@ -103,15 +112,13 @@ class StartDatabaseProxy
|
||||
];
|
||||
$dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2));
|
||||
$nginxconf_base64 = base64_encode($nginxconf);
|
||||
$dockerfile_base64 = base64_encode($dockerfile);
|
||||
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
|
||||
instant_remote_process([
|
||||
"mkdir -p $configuration_dir",
|
||||
"echo '{$dockerfile_base64}' | base64 -d | tee $configuration_dir/Dockerfile > /dev/null",
|
||||
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
|
||||
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
|
||||
"docker compose --project-directory {$configuration_dir} pull",
|
||||
"docker compose --project-directory {$configuration_dir} up --build -d",
|
||||
"docker compose --project-directory {$configuration_dir} up -d",
|
||||
], $server);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +185,8 @@ class StartPostgresql
|
||||
}
|
||||
}
|
||||
|
||||
$command = ['postgres'];
|
||||
|
||||
if (filled($this->database->postgres_conf)) {
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'],
|
||||
@@ -195,29 +197,25 @@ class StartPostgresql
|
||||
'read_only' => true,
|
||||
]]
|
||||
);
|
||||
$docker_compose['services'][$container_name]['command'] = [
|
||||
'postgres',
|
||||
'-c',
|
||||
'config_file=/etc/postgresql/postgresql.conf',
|
||||
];
|
||||
$command = array_merge($command, ['-c', 'config_file=/etc/postgresql/postgresql.conf']);
|
||||
}
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
$docker_compose['services'][$container_name]['command'] = [
|
||||
'postgres',
|
||||
'-c',
|
||||
'ssl=on',
|
||||
'-c',
|
||||
'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
|
||||
'-c',
|
||||
'ssl_key_file=/var/lib/postgresql/certs/server.key',
|
||||
];
|
||||
$command = array_merge($command, [
|
||||
'-c', 'ssl=on',
|
||||
'-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
|
||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||
$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_base64 = base64_encode($docker_compose);
|
||||
$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.'";
|
||||
|
||||
ray($this->commands);
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Actions\Server\CleanupDocker;
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
@@ -17,25 +18,31 @@ class StopDatabase
|
||||
{
|
||||
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)
|
||||
{
|
||||
$server = $database->destination->server;
|
||||
if (! $server->isFunctional()) {
|
||||
return 'Server is not functional';
|
||||
}
|
||||
|
||||
$this->stopContainer($database, $database->uuid, 30);
|
||||
if ($isDeleteOperation) {
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, true);
|
||||
try {
|
||||
$server = $database->destination->server;
|
||||
if (! $server->isFunctional()) {
|
||||
return 'Server is not functional';
|
||||
}
|
||||
|
||||
$this->stopContainer($database, $database->uuid, 30);
|
||||
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, false, false);
|
||||
}
|
||||
|
||||
if ($database->is_public) {
|
||||
StopDatabaseProxy::run($database);
|
||||
}
|
||||
|
||||
return 'Database stopped successfully';
|
||||
} catch (\Exception $e) {
|
||||
return 'Database stop failed: '.$e->getMessage();
|
||||
} finally {
|
||||
ServiceStatusChanged::dispatch($database->environment->project->team->id);
|
||||
}
|
||||
|
||||
if ($database->is_public) {
|
||||
StopDatabaseProxy::run($database);
|
||||
}
|
||||
|
||||
return 'Database stopped successfully';
|
||||
}
|
||||
|
||||
private function stopContainer($database, string $containerName, int $timeout = 30): void
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Actions\Docker;
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Shared\ComplexStatusCheck;
|
||||
use App\Events\ServiceChecked;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
@@ -25,6 +26,8 @@ class GetContainersStatus
|
||||
|
||||
public $server;
|
||||
|
||||
protected ?Collection $applicationContainerStatuses;
|
||||
|
||||
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
||||
{
|
||||
$this->containers = $containers;
|
||||
@@ -93,7 +96,11 @@ class GetContainersStatus
|
||||
}
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$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));
|
||||
$applicationId = data_get($labels, 'coolify.applicationId');
|
||||
if ($applicationId) {
|
||||
@@ -118,11 +125,16 @@ class GetContainersStatus
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
if ($application) {
|
||||
$foundApplications[] = $application->id;
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$application->update(['status' => $containerStatus]);
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
// Store container status for aggregation
|
||||
if (! isset($this->applicationContainerStatuses)) {
|
||||
$this->applicationContainerStatuses = collect();
|
||||
}
|
||||
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 {
|
||||
// Notify user that this container should not be there.
|
||||
@@ -273,24 +285,13 @@ class GetContainersStatus
|
||||
if (str($application->status)->startsWith('exited')) {
|
||||
continue;
|
||||
}
|
||||
$application->update(['status' => 'exited']);
|
||||
|
||||
$name = data_get($application, 'name');
|
||||
$fqdn = data_get($application, 'fqdn');
|
||||
|
||||
$containerName = $name ? "$name ($fqdn)" : $fqdn;
|
||||
|
||||
$projectUuid = data_get($application, 'environment.project.uuid');
|
||||
$applicationUuid = data_get($application, 'uuid');
|
||||
$environment = data_get($application, 'environment.name');
|
||||
|
||||
if ($projectUuid && $applicationUuid && $environment) {
|
||||
$url = base_url().'/project/'.$projectUuid.'/'.$environment.'/application/'.$applicationUuid;
|
||||
} else {
|
||||
$url = null;
|
||||
// Only protection: If no containers at all, Docker query might have failed
|
||||
if ($this->containers->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
|
||||
$application->update(['status' => 'exited']);
|
||||
}
|
||||
$notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews);
|
||||
foreach ($notRunningApplicationPreviews as $previewId) {
|
||||
@@ -298,24 +299,13 @@ class GetContainersStatus
|
||||
if (str($preview->status)->startsWith('exited')) {
|
||||
continue;
|
||||
}
|
||||
$preview->update(['status' => 'exited']);
|
||||
|
||||
$name = data_get($preview, 'name');
|
||||
$fqdn = data_get($preview, 'fqdn');
|
||||
|
||||
$containerName = $name ? "$name ($fqdn)" : $fqdn;
|
||||
|
||||
$projectUuid = data_get($preview, 'application.environment.project.uuid');
|
||||
$environmentName = data_get($preview, 'application.environment.name');
|
||||
$applicationUuid = data_get($preview, 'application.uuid');
|
||||
|
||||
if ($projectUuid && $applicationUuid && $environmentName) {
|
||||
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
|
||||
} else {
|
||||
$url = null;
|
||||
// Only protection: If no containers at all, Docker query might have failed
|
||||
if ($this->containers->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
|
||||
$preview->update(['status' => 'exited']);
|
||||
}
|
||||
$notRunningDatabases = $databases->pluck('id')->diff($foundDatabases);
|
||||
foreach ($notRunningDatabases as $database) {
|
||||
@@ -341,5 +331,97 @@ class GetContainersStatus
|
||||
}
|
||||
// $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);
|
||||
}
|
||||
|
||||
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)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
$user = User::create([
|
||||
'id' => 0,
|
||||
'name' => $input['name'],
|
||||
'email' => strtolower($input['email']),
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
$team = $user->teams()->first();
|
||||
@@ -52,7 +52,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
} else {
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => strtolower($input['email']),
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
$team = $user->teams()->first();
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Proxy;
|
||||
|
||||
use App\Models\Server;
|
||||
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');
|
||||
}
|
||||
|
||||
return $proxy_configuration;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Actions\Proxy;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
@@ -65,24 +66,14 @@ class CheckProxy
|
||||
if ($server->id === 0) {
|
||||
$ip = 'host.docker.internal';
|
||||
}
|
||||
$portsToCheck = ['80', '443'];
|
||||
$portsToCheck = [];
|
||||
|
||||
foreach ($portsToCheck as $port) {
|
||||
// Use the smart port checker that handles dual-stack properly
|
||||
if ($this->isPortConflict($server, $port, $proxyContainerName)) {
|
||||
if ($fromUI) {
|
||||
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br><br>Docs: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/docs'>https://coolify.io/docs</a><br>Discord: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/discord'>https://coolify.io/discord</a>");
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
if ($server->proxyType() !== ProxyTypes::NONE->value) {
|
||||
$proxyCompose = CheckConfiguration::run($server);
|
||||
$proxyCompose = GetProxyConfiguration::run($server);
|
||||
if (isset($proxyCompose)) {
|
||||
$yaml = Yaml::parse($proxyCompose);
|
||||
$portsToCheck = [];
|
||||
$configPorts = [];
|
||||
if ($server->proxyType() === ProxyTypes::TRAEFIK->value) {
|
||||
$ports = data_get($yaml, 'services.traefik.ports');
|
||||
} elseif ($server->proxyType() === ProxyTypes::CADDY->value) {
|
||||
@@ -90,9 +81,11 @@ class CheckProxy
|
||||
}
|
||||
if (isset($ports)) {
|
||||
foreach ($ports as $port) {
|
||||
$portsToCheck[] = str($port)->before(':')->value();
|
||||
$configPorts[] = str($port)->before(':')->value();
|
||||
}
|
||||
}
|
||||
// Combine default ports with config ports
|
||||
$portsToCheck = array_merge($portsToCheck, $configPorts);
|
||||
}
|
||||
} else {
|
||||
$portsToCheck = [];
|
||||
@@ -103,11 +96,188 @@ class CheckProxy
|
||||
if (count($portsToCheck) === 0) {
|
||||
return false;
|
||||
}
|
||||
$portsToCheck = array_values(array_unique($portsToCheck));
|
||||
// Check port conflicts in parallel
|
||||
$conflicts = $this->checkPortConflictsInParallel($server, $portsToCheck, $proxyContainerName);
|
||||
foreach ($conflicts as $port => $conflict) {
|
||||
if ($conflict) {
|
||||
if ($fromUI) {
|
||||
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br><br>Docs: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/docs'>https://coolify.io/docs</a><br>Discord: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/discord'>https://coolify.io/discord</a>");
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check multiple ports for conflicts in parallel
|
||||
* Returns an array with port => conflict_status mapping
|
||||
*/
|
||||
private function checkPortConflictsInParallel(Server $server, array $ports, string $proxyContainerName): array
|
||||
{
|
||||
if (empty($ports)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Build concurrent port check commands
|
||||
$results = Process::concurrently(function ($pool) use ($server, $ports, $proxyContainerName) {
|
||||
foreach ($ports as $port) {
|
||||
$commands = $this->buildPortCheckCommands($server, $port, $proxyContainerName);
|
||||
$pool->command($commands['ssh_command'])->timeout(10);
|
||||
}
|
||||
});
|
||||
|
||||
// Process results
|
||||
$conflicts = [];
|
||||
|
||||
foreach ($ports as $index => $port) {
|
||||
$result = $results[$index] ?? null;
|
||||
|
||||
if ($result) {
|
||||
$conflicts[$port] = $this->parsePortCheckResult($result, $port, $proxyContainerName);
|
||||
} else {
|
||||
// If process failed, assume no conflict to avoid false positives
|
||||
$conflicts[$port] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $conflicts;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Parallel port checking failed: '.$e->getMessage().'. Falling back to sequential checking.');
|
||||
|
||||
// Fallback to sequential checking if parallel fails
|
||||
$conflicts = [];
|
||||
foreach ($ports as $port) {
|
||||
$conflicts[$port] = $this->isPortConflict($server, $port, $proxyContainerName);
|
||||
}
|
||||
|
||||
return $conflicts;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SSH command for checking a specific port
|
||||
*/
|
||||
private function buildPortCheckCommands(Server $server, string $port, string $proxyContainerName): array
|
||||
{
|
||||
// First check if our own proxy is using this port (which is fine)
|
||||
$getProxyContainerId = "docker ps -a --filter name=$proxyContainerName --format '{{.ID}}'";
|
||||
$checkProxyPortScript = "
|
||||
CONTAINER_ID=\$($getProxyContainerId);
|
||||
if [ ! -z \"\$CONTAINER_ID\" ]; then
|
||||
if docker inspect \$CONTAINER_ID --format '{{json .NetworkSettings.Ports}}' | grep -q '\"$port/tcp\"'; then
|
||||
echo 'proxy_using_port';
|
||||
exit 0;
|
||||
fi;
|
||||
fi;
|
||||
";
|
||||
|
||||
// Command sets for different ways to check ports, ordered by preference
|
||||
$portCheckScript = "
|
||||
$checkProxyPortScript
|
||||
|
||||
# Try ss command first
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null);
|
||||
if [ -z \"\$ss_output\" ]; then
|
||||
echo 'port_free';
|
||||
exit 0;
|
||||
fi;
|
||||
count=\$(echo \"\$ss_output\" | grep -c ':$port ');
|
||||
if [ \$count -eq 0 ]; then
|
||||
echo 'port_free';
|
||||
exit 0;
|
||||
fi;
|
||||
# Check for dual-stack or docker processes
|
||||
if [ \$count -le 2 ] && (echo \"\$ss_output\" | grep -q 'docker\\|coolify'); then
|
||||
echo 'port_free';
|
||||
exit 0;
|
||||
fi;
|
||||
echo \"port_conflict|\$ss_output\";
|
||||
exit 0;
|
||||
fi;
|
||||
|
||||
# Try netstat as fallback
|
||||
if command -v netstat >/dev/null 2>&1; then
|
||||
netstat_output=\$(netstat -tuln 2>/dev/null | grep ':$port ');
|
||||
if [ -z \"\$netstat_output\" ]; then
|
||||
echo 'port_free';
|
||||
exit 0;
|
||||
fi;
|
||||
count=\$(echo \"\$netstat_output\" | grep -c 'LISTEN');
|
||||
if [ \$count -eq 0 ]; then
|
||||
echo 'port_free';
|
||||
exit 0;
|
||||
fi;
|
||||
if [ \$count -le 2 ] && (echo \"\$netstat_output\" | grep -q 'docker\\|coolify'); then
|
||||
echo 'port_free';
|
||||
exit 0;
|
||||
fi;
|
||||
echo \"port_conflict|\$netstat_output\";
|
||||
exit 0;
|
||||
fi;
|
||||
|
||||
# Final fallback using nc
|
||||
if nc -z -w1 127.0.0.1 $port >/dev/null 2>&1; then
|
||||
echo 'port_conflict|nc_detected';
|
||||
else
|
||||
echo 'port_free';
|
||||
fi;
|
||||
";
|
||||
|
||||
$sshCommand = \App\Helpers\SshMultiplexingHelper::generateSshCommand($server, $portCheckScript);
|
||||
|
||||
return [
|
||||
'ssh_command' => $sshCommand,
|
||||
'script' => $portCheckScript,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the result from port check command
|
||||
*/
|
||||
private function parsePortCheckResult($processResult, string $port, string $proxyContainerName): bool
|
||||
{
|
||||
$exitCode = $processResult->exitCode();
|
||||
$output = trim($processResult->output());
|
||||
$errorOutput = trim($processResult->errorOutput());
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($output === 'proxy_using_port' || $output === 'port_free') {
|
||||
return false; // No conflict
|
||||
}
|
||||
|
||||
if (str_starts_with($output, 'port_conflict|')) {
|
||||
$details = substr($output, strlen('port_conflict|'));
|
||||
|
||||
// Additional logic to detect dual-stack scenarios
|
||||
if ($details !== 'nc_detected') {
|
||||
// Check for dual-stack scenario - typically 1-2 listeners (IPv4+IPv6)
|
||||
$lines = explode("\n", $details);
|
||||
if (count($lines) <= 2) {
|
||||
// Look for IPv4 and IPv6 in the listing
|
||||
if ((strpos($details, '0.0.0.0:'.$port) !== false && strpos($details, ':::'.$port) !== false) ||
|
||||
(strpos($details, '*:'.$port) !== false && preg_match('/\*:'.$port.'.*IPv[46]/', $details))) {
|
||||
|
||||
return false; // This is just a normal dual-stack setup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true; // Real port conflict
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart port checker that handles dual-stack configurations
|
||||
* Returns true only if there's a real port conflict (not just dual-stack)
|
||||
@@ -176,14 +346,11 @@ class CheckProxy
|
||||
|
||||
// Run the actual check commands
|
||||
$output = instant_remote_process($set['check'], $server, true);
|
||||
|
||||
// Parse the output lines
|
||||
$lines = explode("\n", trim($output));
|
||||
|
||||
// Get the detailed output and listener count
|
||||
$details = trim($lines[0] ?? '');
|
||||
$count = intval(trim($lines[1] ?? '0'));
|
||||
|
||||
$details = trim(implode("\n", array_slice($lines, 0, -1)));
|
||||
$count = intval(trim($lines[count($lines) - 1] ?? '0'));
|
||||
// If no listeners or empty result, port is free
|
||||
if ($count == 0 || empty($details)) {
|
||||
return false;
|
||||
|
||||
47
app/Actions/Proxy/GetProxyConfiguration.php
Normal file
47
app/Actions/Proxy/GetProxyConfiguration.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,21 @@ namespace App\Actions\Proxy;
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class SaveConfiguration
|
||||
class SaveProxyConfiguration
|
||||
{
|
||||
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();
|
||||
$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->save();
|
||||
|
||||
return instant_remote_process([
|
||||
// Transfer the configuration file to the server
|
||||
instant_remote_process([
|
||||
"mkdir -p $proxy_path",
|
||||
"echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
|
||||
], $server);
|
||||
@@ -3,7 +3,8 @@
|
||||
namespace App\Actions\Proxy;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Events\ProxyStarted;
|
||||
use App\Events\ProxyStatusChanged;
|
||||
use App\Events\ProxyStatusChangedUI;
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
@@ -20,14 +21,15 @@ class StartProxy
|
||||
}
|
||||
$commands = collect([]);
|
||||
$proxy_path = $server->proxyPath();
|
||||
$configuration = CheckConfiguration::run($server);
|
||||
$configuration = GetProxyConfiguration::run($server);
|
||||
if (! $configuration) {
|
||||
throw new \Exception('Configuration is not synced');
|
||||
}
|
||||
SaveConfiguration::run($server, $configuration);
|
||||
SaveProxyConfiguration::run($server, $configuration);
|
||||
$docker_compose_yml_base64 = base64_encode($configuration);
|
||||
$server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
|
||||
$server->save();
|
||||
|
||||
if ($server->isSwarmManager()) {
|
||||
$commands = $commands->merge([
|
||||
"mkdir -p $proxy_path/dynamic",
|
||||
@@ -57,20 +59,22 @@ class StartProxy
|
||||
" echo 'Successfully stopped and removed existing coolify-proxy.'",
|
||||
'fi',
|
||||
"echo 'Starting coolify-proxy.'",
|
||||
'docker compose up -d',
|
||||
'docker compose up -d --wait --remove-orphans',
|
||||
"echo 'Successfully started coolify-proxy.'",
|
||||
]);
|
||||
$commands = $commands->merge(connectProxyToNetworks($server));
|
||||
}
|
||||
$server->proxy->set('status', 'starting');
|
||||
$server->save();
|
||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||
|
||||
if ($async) {
|
||||
return remote_process($commands, $server, callEventOnFinish: 'ProxyStarted', callEventData: $server);
|
||||
return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id);
|
||||
} else {
|
||||
instant_remote_process($commands, $server);
|
||||
$server->proxy->set('status', 'running');
|
||||
$server->proxy->set('type', $proxyType);
|
||||
$server->save();
|
||||
ProxyStarted::dispatch($server);
|
||||
ProxyStatusChanged::dispatch($server->id);
|
||||
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Actions\Proxy;
|
||||
|
||||
use App\Events\ProxyStatusChanged;
|
||||
use App\Events\ProxyStatusChangedUI;
|
||||
use App\Models\Server;
|
||||
use App\Services\ProxyDashboardCacheService;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StopProxy
|
||||
@@ -13,6 +16,9 @@ class StopProxy
|
||||
{
|
||||
try {
|
||||
$containerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
|
||||
$server->proxy->status = 'stopping';
|
||||
$server->save();
|
||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=$timeout $containerName",
|
||||
@@ -24,6 +30,9 @@ class StopProxy
|
||||
$server->save();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
} finally {
|
||||
ProxyDashboardCacheService::clearCache($server);
|
||||
ProxyStatusChanged::dispatch($server->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
222
app/Actions/Server/CheckUpdates.php
Normal file
222
app/Actions/Server/CheckUpdates.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class CheckUpdates
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(Server $server)
|
||||
{
|
||||
try {
|
||||
if ($server->serverStatus() === false) {
|
||||
return [
|
||||
'error' => 'Server is not reachable or not ready.',
|
||||
];
|
||||
}
|
||||
|
||||
// Try first method - using instant_remote_process
|
||||
$output = instant_remote_process(['cat /etc/os-release'], $server);
|
||||
|
||||
// Parse os-release into an associative array
|
||||
$osInfo = [];
|
||||
foreach (explode("\n", $output) as $line) {
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
if (strpos($line, '=') === false) {
|
||||
continue;
|
||||
}
|
||||
[$key, $value] = explode('=', $line, 2);
|
||||
$osInfo[$key] = trim($value, '"');
|
||||
}
|
||||
|
||||
// Get the main OS identifier
|
||||
$osId = $osInfo['ID'] ?? '';
|
||||
// $osIdLike = $osInfo['ID_LIKE'] ?? '';
|
||||
// $versionId = $osInfo['VERSION_ID'] ?? '';
|
||||
|
||||
// Normalize OS types based on install.sh logic
|
||||
switch ($osId) {
|
||||
case 'manjaro':
|
||||
case 'manjaro-arm':
|
||||
case 'endeavouros':
|
||||
$osType = 'arch';
|
||||
break;
|
||||
case 'pop':
|
||||
case 'linuxmint':
|
||||
case 'zorin':
|
||||
$osType = 'ubuntu';
|
||||
break;
|
||||
case 'fedora-asahi-remix':
|
||||
$osType = 'fedora';
|
||||
break;
|
||||
default:
|
||||
$osType = $osId;
|
||||
}
|
||||
|
||||
// Determine package manager based on OS type
|
||||
$packageManager = match ($osType) {
|
||||
'arch' => 'pacman',
|
||||
'alpine' => 'apk',
|
||||
'ubuntu', 'debian', 'raspbian' => 'apt',
|
||||
'centos', 'fedora', 'rhel', 'ol', 'rocky', 'almalinux', 'amzn' => 'dnf',
|
||||
'sles', 'opensuse-leap', 'opensuse-tumbleweed' => 'zypper',
|
||||
default => null
|
||||
};
|
||||
|
||||
switch ($packageManager) {
|
||||
case 'zypper':
|
||||
$output = instant_remote_process(['LANG=C zypper -tx list-updates'], $server);
|
||||
$out = $this->parseZypperOutput($output);
|
||||
$out['osId'] = $osId;
|
||||
$out['package_manager'] = $packageManager;
|
||||
|
||||
return $out;
|
||||
case 'dnf':
|
||||
$output = instant_remote_process(['LANG=C dnf list -q --updates --refresh'], $server);
|
||||
$out = $this->parseDnfOutput($output);
|
||||
$out['osId'] = $osId;
|
||||
$out['package_manager'] = $packageManager;
|
||||
|
||||
return $out;
|
||||
case 'apt':
|
||||
instant_remote_process(['apt-get update -qq'], $server);
|
||||
$output = instant_remote_process(['LANG=C apt list --upgradable 2>/dev/null'], $server);
|
||||
|
||||
$out = $this->parseAptOutput($output);
|
||||
$out['osId'] = $osId;
|
||||
$out['package_manager'] = $packageManager;
|
||||
|
||||
return $out;
|
||||
default:
|
||||
return [
|
||||
'osId' => $osId,
|
||||
'error' => 'Unsupported package manager',
|
||||
'package_manager' => $packageManager,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
return [
|
||||
'osId' => $osId,
|
||||
'package_manager' => $packageManager,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function parseZypperOutput(string $output): array
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
try {
|
||||
$xml = simplexml_load_string($output);
|
||||
if ($xml === false) {
|
||||
return [
|
||||
'total_updates' => 0,
|
||||
'updates' => [],
|
||||
'error' => 'Failed to parse XML output',
|
||||
];
|
||||
}
|
||||
|
||||
// Navigate to the update-list node
|
||||
$updateList = $xml->xpath('//update-list/update');
|
||||
|
||||
foreach ($updateList as $update) {
|
||||
$updates[] = [
|
||||
'package' => (string) $update['name'],
|
||||
'new_version' => (string) $update['edition'],
|
||||
'current_version' => (string) $update['edition-old'],
|
||||
'architecture' => (string) $update['arch'],
|
||||
'repository' => (string) $update->source['alias'],
|
||||
'summary' => (string) $update->summary,
|
||||
'description' => (string) $update->description,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total_updates' => count($updates),
|
||||
'updates' => $updates,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'total_updates' => 0,
|
||||
'updates' => [],
|
||||
'error' => 'Error parsing zypper output: '.$e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function parseDnfOutput(string $output): array
|
||||
{
|
||||
$updates = [];
|
||||
$lines = explode("\n", $output);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split by multiple spaces/tabs and filter out empty elements
|
||||
$parts = array_values(array_filter(preg_split('/\s+/', $line)));
|
||||
|
||||
if (count($parts) >= 3) {
|
||||
$package = $parts[0];
|
||||
$new_version = $parts[1];
|
||||
$repository = $parts[2];
|
||||
|
||||
// Extract architecture from package name (e.g., "cloud-init.noarch" -> "noarch")
|
||||
$architecture = str_contains($package, '.') ? explode('.', $package)[1] : 'noarch';
|
||||
|
||||
$updates[] = [
|
||||
'package' => $package,
|
||||
'new_version' => $new_version,
|
||||
'repository' => $repository,
|
||||
'architecture' => $architecture,
|
||||
'current_version' => 'unknown', // DNF doesn't show current version in check-update output
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_updates' => count($updates),
|
||||
'updates' => $updates,
|
||||
];
|
||||
}
|
||||
|
||||
private function parseAptOutput(string $output): array
|
||||
{
|
||||
$updates = [];
|
||||
$lines = explode("\n", $output);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Skip the "Listing... Done" line and empty lines
|
||||
if (empty($line) || str_contains($line, 'Listing...')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Example line: package/stable 2.0-1 amd64 [upgradable from: 1.0-1]
|
||||
if (preg_match('/^(.+?)\/(\S+)\s+(\S+)\s+(\S+)\s+\[upgradable from: (.+?)\]/', $line, $matches)) {
|
||||
$updates[] = [
|
||||
'package' => $matches[1],
|
||||
'repository' => $matches[2],
|
||||
'new_version' => $matches[3],
|
||||
'architecture' => $matches[4],
|
||||
'current_version' => $matches[5],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_updates' => count($updates),
|
||||
'updates' => $updates,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class CleanupDocker
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(Server $server)
|
||||
public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false)
|
||||
{
|
||||
$settings = instanceSettings();
|
||||
$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",
|
||||
];
|
||||
|
||||
if ($server->settings->delete_unused_volumes) {
|
||||
if ($deleteUnusedVolumes) {
|
||||
$commands[] = 'docker volume prune -af';
|
||||
}
|
||||
|
||||
if ($server->settings->delete_unused_networks) {
|
||||
if ($deleteUnusedNetworks) {
|
||||
$commands[] = 'docker network prune -f';
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Events\CloudflareTunnelConfigured;
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ConfigureCloudflared
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, string $cloudflare_token)
|
||||
public function handle(Server $server, string $cloudflare_token, string $ssh_domain): Activity
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
@@ -24,6 +24,13 @@ class ConfigureCloudflared
|
||||
'command' => 'tunnel run',
|
||||
'environment' => [
|
||||
"TUNNEL_TOKEN={$cloudflare_token}",
|
||||
'TUNNEL_METRICS=127.0.0.1:60123',
|
||||
],
|
||||
'healthcheck' => [
|
||||
'test' => ['CMD', 'cloudflared', 'tunnel', '--metrics', '127.0.0.1:60123', 'ready'],
|
||||
'interval' => '5s',
|
||||
'timeout' => '30s',
|
||||
'retries' => 5,
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -34,22 +41,20 @@ class ConfigureCloudflared
|
||||
'mkdir -p /tmp/cloudflared',
|
||||
'cd /tmp/cloudflared',
|
||||
"echo '$docker_compose_yml_base64' | base64 -d | tee docker-compose.yml > /dev/null",
|
||||
'echo Pulling latest Cloudflare Tunnel image.',
|
||||
'docker compose pull',
|
||||
'docker compose down -v --remove-orphans > /dev/null 2>&1',
|
||||
'docker compose up -d --remove-orphans',
|
||||
'echo Stopping existing Cloudflare Tunnel container.',
|
||||
'docker rm -f coolify-cloudflared || true',
|
||||
'echo Starting new Cloudflare Tunnel container.',
|
||||
'docker compose up --wait --wait-timeout 15 --remove-orphans || docker logs coolify-cloudflared',
|
||||
]);
|
||||
instant_remote_process($commands, $server);
|
||||
} catch (\Throwable $e) {
|
||||
$server->settings->is_cloudflare_tunnel = false;
|
||||
$server->settings->save();
|
||||
throw $e;
|
||||
} finally {
|
||||
CloudflareTunnelConfigured::dispatch($server->team_id);
|
||||
|
||||
$commands = collect([
|
||||
'rm -fr /tmp/cloudflared',
|
||||
return remote_process($commands, $server, callEventOnFinish: 'CloudflareTunnelChanged', callEventData: [
|
||||
'server_id' => $server->id,
|
||||
'ssh_domain' => $ssh_domain,
|
||||
]);
|
||||
instant_remote_process($commands, $server);
|
||||
} catch (\Throwable $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Events\SentinelRestarted;
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
@@ -9,7 +10,7 @@ class StartSentinel
|
||||
{
|
||||
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()) {
|
||||
return;
|
||||
@@ -27,7 +28,7 @@ class StartSentinel
|
||||
$mountDir = '/data/coolify/sentinel';
|
||||
$image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version;
|
||||
if (! $endpoint) {
|
||||
throw new \Exception('You should set FQDN in Instance Settings.');
|
||||
throw new \RuntimeException('You should set FQDN in Instance Settings.');
|
||||
}
|
||||
$environments = [
|
||||
'TOKEN' => $token,
|
||||
@@ -43,7 +44,9 @@ class StartSentinel
|
||||
];
|
||||
if (isDev()) {
|
||||
// data_set($environments, 'DEBUG', 'true');
|
||||
// $image = 'sentinel';
|
||||
if ($customImage && ! empty($customImage)) {
|
||||
$image = $customImage;
|
||||
}
|
||||
$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)).'"';
|
||||
@@ -61,5 +64,8 @@ class StartSentinel
|
||||
$server->settings->is_sentinel_enabled = true;
|
||||
$server->settings->save();
|
||||
$server->sentinelHeartbeat();
|
||||
|
||||
// Dispatch event to notify UI components
|
||||
SentinelRestarted::dispatch($server, $version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class UpdateCoolify
|
||||
if (! $this->server) {
|
||||
return;
|
||||
}
|
||||
CleanupDocker::dispatch($this->server);
|
||||
CleanupDocker::dispatch($this->server, false, false);
|
||||
$this->latestVersion = get_latest_version_of_coolify();
|
||||
$this->currentVersion = config('constants.coolify.version');
|
||||
if (! $manual_update) {
|
||||
|
||||
52
app/Actions/Server/UpdatePackage.php
Normal file
52
app/Actions/Server/UpdatePackage.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Contracts\Activity;
|
||||
|
||||
class UpdatePackage
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(Server $server, string $osId, ?string $package = null, ?string $packageManager = null, bool $all = false): Activity|array
|
||||
{
|
||||
try {
|
||||
if ($server->serverStatus() === false) {
|
||||
return [
|
||||
'error' => 'Server is not reachable or not ready.',
|
||||
];
|
||||
}
|
||||
switch ($packageManager) {
|
||||
case 'zypper':
|
||||
$commandAll = 'zypper update -y';
|
||||
$commandInstall = 'zypper install -y '.$package;
|
||||
break;
|
||||
case 'dnf':
|
||||
$commandAll = 'dnf update -y';
|
||||
$commandInstall = 'dnf update -y '.$package;
|
||||
break;
|
||||
case 'apt':
|
||||
$commandAll = 'apt update && apt upgrade -y';
|
||||
$commandInstall = 'apt install -y '.$package;
|
||||
break;
|
||||
default:
|
||||
return [
|
||||
'error' => 'OS not supported',
|
||||
];
|
||||
}
|
||||
if ($all) {
|
||||
return remote_process([$commandAll], $server);
|
||||
}
|
||||
|
||||
return remote_process([$commandInstall], $server);
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class DeleteService
|
||||
{
|
||||
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 {
|
||||
$server = data_get($service, 'server');
|
||||
@@ -53,7 +53,7 @@ class DeleteService
|
||||
|
||||
instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false);
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception($e->getMessage());
|
||||
throw new \RuntimeException($e->getMessage());
|
||||
} finally {
|
||||
if ($deleteConfigurations) {
|
||||
$service->deleteConfigurations();
|
||||
@@ -71,7 +71,7 @@ class DeleteService
|
||||
$service->forceDelete();
|
||||
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, true);
|
||||
CleanupDocker::dispatch($server, false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ class RestartService
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(Service $service)
|
||||
public function handle(Service $service, bool $pullLatestImages)
|
||||
{
|
||||
StopService::run($service);
|
||||
|
||||
return StartService::run($service);
|
||||
return StartService::run($service, $pullLatestImages);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class StartService
|
||||
StopService::run(service: $service, dockerCleanup: false);
|
||||
}
|
||||
$service->saveComposeConfigs();
|
||||
$service->isConfigurationChanged(save: true);
|
||||
$commands[] = 'cd '.$service->workdir();
|
||||
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
|
||||
if ($pullLatestImages) {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Actions\Server\CleanupDocker;
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
@@ -12,7 +14,7 @@ class StopService
|
||||
|
||||
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 {
|
||||
$server = $service->destination->server;
|
||||
@@ -20,17 +22,44 @@ class StopService
|
||||
return 'Server is not functional';
|
||||
}
|
||||
|
||||
$containersToStop = $service->getContainersToStop();
|
||||
$service->stopContainers($containersToStop, $server);
|
||||
$containersToStop = [];
|
||||
$applications = $service->applications()->get();
|
||||
foreach ($applications as $application) {
|
||||
$containersToStop[] = "{$application->name}-{$service->uuid}";
|
||||
}
|
||||
$dbs = $service->databases()->get();
|
||||
foreach ($dbs as $db) {
|
||||
$containersToStop[] = "{$db->name}-{$service->uuid}";
|
||||
}
|
||||
|
||||
if ($isDeleteOperation) {
|
||||
if (! empty($containersToStop)) {
|
||||
$this->stopContainersInParallel($containersToStop, $server);
|
||||
}
|
||||
|
||||
if ($deleteConnectedNetworks) {
|
||||
$service->deleteConnectedNetworks();
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, true);
|
||||
}
|
||||
}
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, false, false);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return $e->getMessage();
|
||||
} finally {
|
||||
ServiceStatusChanged::dispatch($service->environment->project->team->id);
|
||||
}
|
||||
}
|
||||
|
||||
private function stopContainersInParallel(array $containersToStop, Server $server): void
|
||||
{
|
||||
$timeout = count($containersToStop) > 5 ? 10 : 30;
|
||||
$commands = [];
|
||||
$containerList = implode(' ', $containersToStop);
|
||||
$commands[] = "docker stop --time=$timeout $containerList";
|
||||
$commands[] = "docker rm -f $containerList";
|
||||
instant_remote_process(
|
||||
command: $commands,
|
||||
server: $server,
|
||||
throwError: false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,22 +26,22 @@ class ComplexStatusCheck
|
||||
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);
|
||||
$container = format_docker_command_output_to_json($container);
|
||||
if ($container->count() === 1) {
|
||||
$container = $container->first();
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
$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);
|
||||
$containers = format_docker_command_output_to_json($containers);
|
||||
|
||||
if ($containers->count() > 0) {
|
||||
$statusToSet = $this->aggregateContainerStatuses($application, $containers);
|
||||
|
||||
if ($is_main_server) {
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$application->update(['status' => "$containerStatus:$containerHealth"]);
|
||||
if ($statusFromDb !== $statusToSet) {
|
||||
$application->update(['status' => $statusToSet]);
|
||||
}
|
||||
} else {
|
||||
$additional_server = $application->additional_servers()->wherePivot('server_id', $server->id);
|
||||
$statusFromDb = $additional_server->first()->pivot->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]);
|
||||
if ($statusFromDb !== $statusToSet) {
|
||||
$additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]);
|
||||
}
|
||||
}
|
||||
} 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';
|
||||
}
|
||||
}
|
||||
|
||||
151
app/Actions/Stripe/CancelSubscription.php
Normal file
151
app/Actions/Stripe/CancelSubscription.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
125
app/Actions/User/DeleteUserResources.php
Normal file
125
app/Actions/User/DeleteUserResources.php
Normal 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;
|
||||
}
|
||||
}
|
||||
77
app/Actions/User/DeleteUserServers.php
Normal file
77
app/Actions/User/DeleteUserServers.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
202
app/Actions/User/DeleteUserTeams.php
Normal file
202
app/Actions/User/DeleteUserTeams.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -64,13 +64,5 @@ class CleanupDatabase extends Command
|
||||
if ($this->option('yes')) {
|
||||
$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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
248
app/Console/Commands/CleanupNames.php
Normal file
248
app/Console/Commands/CleanupNames.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -7,26 +7,270 @@ use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class CleanupRedis extends Command
|
||||
{
|
||||
protected $signature = 'cleanup:redis';
|
||||
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup}';
|
||||
|
||||
protected $description = 'Cleanup Redis';
|
||||
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, and related data)';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$redis = Redis::connection('horizon');
|
||||
$keys = $redis->keys('*');
|
||||
$prefix = config('horizon.prefix');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$skipOverlapping = $this->option('skip-overlapping');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('DRY RUN MODE - No data will be deleted');
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
$totalKeys = 0;
|
||||
|
||||
// Get all keys with the horizon prefix
|
||||
$keys = $redis->keys('*');
|
||||
$totalKeys = count($keys);
|
||||
|
||||
$this->info("Scanning {$totalKeys} keys for cleanup...");
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
||||
$type = $redis->command('type', [$keyWithoutPrefix]);
|
||||
|
||||
// Handle hash-type keys (individual jobs)
|
||||
if ($type === 5) {
|
||||
$data = $redis->command('hgetall', [$keyWithoutPrefix]);
|
||||
$status = data_get($data, 'status');
|
||||
if ($status === 'completed') {
|
||||
$redis->command('del', [$keyWithoutPrefix]);
|
||||
if ($this->shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)) {
|
||||
$deletedCount++;
|
||||
}
|
||||
}
|
||||
// Handle other key types (metrics, lists, etc.)
|
||||
else {
|
||||
if ($this->shouldDeleteOtherKey($redis, $keyWithoutPrefix, $key, $dryRun)) {
|
||||
$deletedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up overlapping queues if not skipped
|
||||
if (! $skipOverlapping) {
|
||||
$this->info('Cleaning up overlapping queues...');
|
||||
$overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
|
||||
$deletedCount += $overlappingCleaned;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
|
||||
} else {
|
||||
$this->info("Deleted {$deletedCount} out of {$totalKeys} keys");
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)
|
||||
{
|
||||
$data = $redis->command('hgetall', [$keyWithoutPrefix]);
|
||||
$status = data_get($data, 'status');
|
||||
|
||||
// Delete completed and failed jobs
|
||||
if (in_array($status, ['completed', 'failed'])) {
|
||||
if ($dryRun) {
|
||||
$this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})");
|
||||
} else {
|
||||
$redis->command('del', [$keyWithoutPrefix]);
|
||||
$this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryRun)
|
||||
{
|
||||
// Clean up various Horizon data structures
|
||||
$patterns = [
|
||||
'recent_jobs' => 'Recent jobs list',
|
||||
'failed_jobs' => 'Failed jobs list',
|
||||
'completed_jobs' => 'Completed jobs list',
|
||||
'job_classes' => 'Job classes metrics',
|
||||
'queues' => 'Queue metrics',
|
||||
'processes' => 'Process metrics',
|
||||
'supervisors' => 'Supervisor data',
|
||||
'metrics' => 'General metrics',
|
||||
'workload' => 'Workload data',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern => $description) {
|
||||
if (str_contains($keyWithoutPrefix, $pattern)) {
|
||||
if ($dryRun) {
|
||||
$this->line("Would delete {$description}: {$keyWithoutPrefix}");
|
||||
} else {
|
||||
$redis->command('del', [$keyWithoutPrefix]);
|
||||
$this->line("Deleted {$description}: {$keyWithoutPrefix}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old timestamped data (older than 7 days)
|
||||
if (preg_match('/(\d{10})/', $keyWithoutPrefix, $matches)) {
|
||||
$timestamp = (int) $matches[1];
|
||||
$weekAgo = now()->subDays(7)->timestamp;
|
||||
|
||||
if ($timestamp < $weekAgo) {
|
||||
if ($dryRun) {
|
||||
$this->line("Would delete old timestamped data: {$keyWithoutPrefix}");
|
||||
} else {
|
||||
$redis->command('del', [$keyWithoutPrefix]);
|
||||
$this->line("Deleted old timestamped data: {$keyWithoutPrefix}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
|
||||
{
|
||||
$cleanedCount = 0;
|
||||
$queueKeys = [];
|
||||
|
||||
// Find all queue-related keys
|
||||
$allKeys = $redis->keys('*');
|
||||
foreach ($allKeys as $key) {
|
||||
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
||||
if (str_contains($keyWithoutPrefix, 'queue:') || preg_match('/queues?[:\-]/', $keyWithoutPrefix)) {
|
||||
$queueKeys[] = $keyWithoutPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Found '.count($queueKeys).' queue-related keys');
|
||||
|
||||
// Group queues by name pattern to find duplicates
|
||||
$queueGroups = [];
|
||||
foreach ($queueKeys as $queueKey) {
|
||||
// Extract queue name (remove timestamps, suffixes)
|
||||
$baseName = preg_replace('/[:\-]\d+$/', '', $queueKey);
|
||||
$baseName = preg_replace('/[:\-](pending|reserved|delayed|processing)$/', '', $baseName);
|
||||
|
||||
if (! isset($queueGroups[$baseName])) {
|
||||
$queueGroups[$baseName] = [];
|
||||
}
|
||||
$queueGroups[$baseName][] = $queueKey;
|
||||
}
|
||||
|
||||
// Process each group for overlaps
|
||||
foreach ($queueGroups as $baseName => $keys) {
|
||||
if (count($keys) > 1) {
|
||||
$cleanedCount += $this->deduplicateQueueGroup($redis, $baseName, $keys, $dryRun);
|
||||
}
|
||||
|
||||
// Also check for duplicate jobs within individual queues
|
||||
foreach ($keys as $queueKey) {
|
||||
$cleanedCount += $this->deduplicateQueueContents($redis, $queueKey, $dryRun);
|
||||
}
|
||||
}
|
||||
|
||||
return $cleanedCount;
|
||||
}
|
||||
|
||||
private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
|
||||
{
|
||||
$cleanedCount = 0;
|
||||
$this->line("Processing queue group: {$baseName} (".count($keys).' keys)');
|
||||
|
||||
// Sort keys to keep the most recent one
|
||||
usort($keys, function ($a, $b) {
|
||||
// Prefer keys without timestamps (they're usually the main queue)
|
||||
$aHasTimestamp = preg_match('/\d{10}/', $a);
|
||||
$bHasTimestamp = preg_match('/\d{10}/', $b);
|
||||
|
||||
if ($aHasTimestamp && ! $bHasTimestamp) {
|
||||
return 1;
|
||||
}
|
||||
if (! $aHasTimestamp && $bHasTimestamp) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// If both have timestamps, prefer the newer one
|
||||
if ($aHasTimestamp && $bHasTimestamp) {
|
||||
preg_match('/(\d{10})/', $a, $aMatches);
|
||||
preg_match('/(\d{10})/', $b, $bMatches);
|
||||
|
||||
return ($bMatches[1] ?? 0) <=> ($aMatches[1] ?? 0);
|
||||
}
|
||||
|
||||
return strcmp($a, $b);
|
||||
});
|
||||
|
||||
// Keep the first (preferred) key, remove others that are empty or redundant
|
||||
$keepKey = array_shift($keys);
|
||||
|
||||
foreach ($keys as $redundantKey) {
|
||||
$type = $redis->command('type', [$redundantKey]);
|
||||
$shouldDelete = false;
|
||||
|
||||
if ($type === 1) { // LIST type
|
||||
$length = $redis->command('llen', [$redundantKey]);
|
||||
if ($length == 0) {
|
||||
$shouldDelete = true;
|
||||
}
|
||||
} elseif ($type === 3) { // SET type
|
||||
$count = $redis->command('scard', [$redundantKey]);
|
||||
if ($count == 0) {
|
||||
$shouldDelete = true;
|
||||
}
|
||||
} elseif ($type === 4) { // ZSET type
|
||||
$count = $redis->command('zcard', [$redundantKey]);
|
||||
if ($count == 0) {
|
||||
$shouldDelete = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldDelete) {
|
||||
if ($dryRun) {
|
||||
$this->line(" Would delete empty queue: {$redundantKey}");
|
||||
} else {
|
||||
$redis->command('del', [$redundantKey]);
|
||||
$this->line(" Deleted empty queue: {$redundantKey}");
|
||||
}
|
||||
$cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $cleanedCount;
|
||||
}
|
||||
|
||||
private function deduplicateQueueContents($redis, $queueKey, $dryRun)
|
||||
{
|
||||
$cleanedCount = 0;
|
||||
$type = $redis->command('type', [$queueKey]);
|
||||
|
||||
if ($type === 1) { // LIST type - common for job queues
|
||||
$length = $redis->command('llen', [$queueKey]);
|
||||
if ($length > 1) {
|
||||
$items = $redis->command('lrange', [$queueKey, 0, -1]);
|
||||
$uniqueItems = array_unique($items);
|
||||
|
||||
if (count($uniqueItems) < count($items)) {
|
||||
$duplicates = count($items) - count($uniqueItems);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}");
|
||||
} else {
|
||||
// Rebuild the list with unique items
|
||||
$redis->command('del', [$queueKey]);
|
||||
foreach (array_reverse($uniqueItems) as $item) {
|
||||
$redis->command('lpush', [$queueKey, $item]);
|
||||
}
|
||||
$this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}");
|
||||
}
|
||||
$cleanedCount += $duplicates;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $cleanedCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CleanupHelperContainersJob;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
@@ -20,6 +21,7 @@ use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CleanupStuckedResources extends Command
|
||||
@@ -36,6 +38,12 @@ class CleanupStuckedResources extends Command
|
||||
private function cleanup_stucked_resources()
|
||||
{
|
||||
try {
|
||||
$teams = Team::all()->filter(function ($team) {
|
||||
return $team->members()->count() === 0 && $team->servers()->count() === 0;
|
||||
});
|
||||
foreach ($teams as $team) {
|
||||
$team->delete();
|
||||
}
|
||||
$servers = Server::all()->filter(function ($server) {
|
||||
return $server->isFunctional();
|
||||
});
|
||||
@@ -65,7 +73,7 @@ class CleanupStuckedResources extends Command
|
||||
$applications = Application::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($applications as $application) {
|
||||
echo "Deleting stuck application: {$application->name}\n";
|
||||
$application->forceDelete();
|
||||
DeleteResourceJob::dispatch($application);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck application: {$e->getMessage()}\n";
|
||||
@@ -75,26 +83,35 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($applicationsPreviews as $applicationPreview) {
|
||||
if (! data_get($applicationPreview, 'application')) {
|
||||
echo "Deleting stuck application preview: {$applicationPreview->uuid}\n";
|
||||
$applicationPreview->delete();
|
||||
DeleteResourceJob::dispatch($applicationPreview);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
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 {
|
||||
$postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($postgresqls as $postgresql) {
|
||||
echo "Deleting stuck postgresql: {$postgresql->name}\n";
|
||||
$postgresql->forceDelete();
|
||||
DeleteResourceJob::dispatch($postgresql);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$redis = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($redis as $redis) {
|
||||
$rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($rediss as $redis) {
|
||||
echo "Deleting stuck redis: {$redis->name}\n";
|
||||
$redis->forceDelete();
|
||||
DeleteResourceJob::dispatch($redis);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck redis: {$e->getMessage()}\n";
|
||||
@@ -103,7 +120,7 @@ class CleanupStuckedResources extends Command
|
||||
$keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($keydbs as $keydb) {
|
||||
echo "Deleting stuck keydb: {$keydb->name}\n";
|
||||
$keydb->forceDelete();
|
||||
DeleteResourceJob::dispatch($keydb);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck keydb: {$e->getMessage()}\n";
|
||||
@@ -112,7 +129,7 @@ class CleanupStuckedResources extends Command
|
||||
$dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($dragonflies as $dragonfly) {
|
||||
echo "Deleting stuck dragonfly: {$dragonfly->name}\n";
|
||||
$dragonfly->forceDelete();
|
||||
DeleteResourceJob::dispatch($dragonfly);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck dragonfly: {$e->getMessage()}\n";
|
||||
@@ -121,7 +138,7 @@ class CleanupStuckedResources extends Command
|
||||
$clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($clickhouses as $clickhouse) {
|
||||
echo "Deleting stuck clickhouse: {$clickhouse->name}\n";
|
||||
$clickhouse->forceDelete();
|
||||
DeleteResourceJob::dispatch($clickhouse);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck clickhouse: {$e->getMessage()}\n";
|
||||
@@ -130,7 +147,7 @@ class CleanupStuckedResources extends Command
|
||||
$mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($mongodbs as $mongodb) {
|
||||
echo "Deleting stuck mongodb: {$mongodb->name}\n";
|
||||
$mongodb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mongodb);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n";
|
||||
@@ -139,7 +156,7 @@ class CleanupStuckedResources extends Command
|
||||
$mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($mysqls as $mysql) {
|
||||
echo "Deleting stuck mysql: {$mysql->name}\n";
|
||||
$mysql->forceDelete();
|
||||
DeleteResourceJob::dispatch($mysql);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck mysql: {$e->getMessage()}\n";
|
||||
@@ -148,7 +165,7 @@ class CleanupStuckedResources extends Command
|
||||
$mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($mariadbs as $mariadb) {
|
||||
echo "Deleting stuck mariadb: {$mariadb->name}\n";
|
||||
$mariadb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mariadb);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n";
|
||||
@@ -157,7 +174,7 @@ class CleanupStuckedResources extends Command
|
||||
$services = Service::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($services as $service) {
|
||||
echo "Deleting stuck service: {$service->name}\n";
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck service: {$e->getMessage()}\n";
|
||||
@@ -210,19 +227,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($applications as $application) {
|
||||
if (! data_get($application, 'environment')) {
|
||||
echo 'Application without environment: '.$application->name.'\n';
|
||||
$application->forceDelete();
|
||||
DeleteResourceJob::dispatch($application);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $application->destination()) {
|
||||
echo 'Application without destination: '.$application->name.'\n';
|
||||
$application->forceDelete();
|
||||
DeleteResourceJob::dispatch($application);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($application, 'destination.server')) {
|
||||
echo 'Application without server: '.$application->name.'\n';
|
||||
$application->forceDelete();
|
||||
DeleteResourceJob::dispatch($application);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -235,19 +252,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($postgresqls as $postgresql) {
|
||||
if (! data_get($postgresql, 'environment')) {
|
||||
echo 'Postgresql without environment: '.$postgresql->name.'\n';
|
||||
$postgresql->forceDelete();
|
||||
DeleteResourceJob::dispatch($postgresql);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $postgresql->destination()) {
|
||||
echo 'Postgresql without destination: '.$postgresql->name.'\n';
|
||||
$postgresql->forceDelete();
|
||||
DeleteResourceJob::dispatch($postgresql);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($postgresql, 'destination.server')) {
|
||||
echo 'Postgresql without server: '.$postgresql->name.'\n';
|
||||
$postgresql->forceDelete();
|
||||
DeleteResourceJob::dispatch($postgresql);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -260,19 +277,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($redis as $redis) {
|
||||
if (! data_get($redis, 'environment')) {
|
||||
echo 'Redis without environment: '.$redis->name.'\n';
|
||||
$redis->forceDelete();
|
||||
DeleteResourceJob::dispatch($redis);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $redis->destination()) {
|
||||
echo 'Redis without destination: '.$redis->name.'\n';
|
||||
$redis->forceDelete();
|
||||
DeleteResourceJob::dispatch($redis);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($redis, 'destination.server')) {
|
||||
echo 'Redis without server: '.$redis->name.'\n';
|
||||
$redis->forceDelete();
|
||||
DeleteResourceJob::dispatch($redis);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -286,19 +303,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($mongodbs as $mongodb) {
|
||||
if (! data_get($mongodb, 'environment')) {
|
||||
echo 'Mongodb without environment: '.$mongodb->name.'\n';
|
||||
$mongodb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mongodb);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $mongodb->destination()) {
|
||||
echo 'Mongodb without destination: '.$mongodb->name.'\n';
|
||||
$mongodb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mongodb);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($mongodb, 'destination.server')) {
|
||||
echo 'Mongodb without server: '.$mongodb->name.'\n';
|
||||
$mongodb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mongodb);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -312,19 +329,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($mysqls as $mysql) {
|
||||
if (! data_get($mysql, 'environment')) {
|
||||
echo 'Mysql without environment: '.$mysql->name.'\n';
|
||||
$mysql->forceDelete();
|
||||
DeleteResourceJob::dispatch($mysql);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $mysql->destination()) {
|
||||
echo 'Mysql without destination: '.$mysql->name.'\n';
|
||||
$mysql->forceDelete();
|
||||
DeleteResourceJob::dispatch($mysql);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($mysql, 'destination.server')) {
|
||||
echo 'Mysql without server: '.$mysql->name.'\n';
|
||||
$mysql->forceDelete();
|
||||
DeleteResourceJob::dispatch($mysql);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -338,19 +355,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($mariadbs as $mariadb) {
|
||||
if (! data_get($mariadb, 'environment')) {
|
||||
echo 'Mariadb without environment: '.$mariadb->name.'\n';
|
||||
$mariadb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mariadb);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $mariadb->destination()) {
|
||||
echo 'Mariadb without destination: '.$mariadb->name.'\n';
|
||||
$mariadb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mariadb);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($mariadb, 'destination.server')) {
|
||||
echo 'Mariadb without server: '.$mariadb->name.'\n';
|
||||
$mariadb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mariadb);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -364,19 +381,19 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($services as $service) {
|
||||
if (! data_get($service, 'environment')) {
|
||||
echo 'Service without environment: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $service->destination()) {
|
||||
echo 'Service without destination: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($service, 'server')) {
|
||||
echo 'Service without server: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -389,7 +406,7 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($serviceApplications as $service) {
|
||||
if (! data_get($service, 'service')) {
|
||||
echo 'ServiceApplication without service: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -402,7 +419,7 @@ class CleanupStuckedResources extends Command
|
||||
foreach ($serviceDatabases as $service) {
|
||||
if (! data_get($service, 'service')) {
|
||||
echo 'ServiceDatabase without service: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
722
app/Console/Commands/CloudDeleteUser.php
Normal file
722
app/Console/Commands/CloudDeleteUser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
@@ -44,5 +45,6 @@ class Dev extends Command
|
||||
} else {
|
||||
echo "Instance already initialized.\n";
|
||||
}
|
||||
CheckHelperImageJob::dispatch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ class Services extends Command
|
||||
/**
|
||||
* {@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
|
||||
{
|
||||
@@ -33,7 +33,10 @@ class Services extends Command
|
||||
];
|
||||
})->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;
|
||||
}
|
||||
@@ -71,6 +74,7 @@ class Services extends Command
|
||||
'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'),
|
||||
];
|
||||
@@ -86,4 +90,145 @@ class Services extends Command
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ namespace App\Console\Commands;
|
||||
use App\Enums\ActivityTypes;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
@@ -18,81 +20,104 @@ use Illuminate\Support\Facades\Http;
|
||||
|
||||
class Init extends Command
|
||||
{
|
||||
protected $signature = 'app:init {--force-cloud}';
|
||||
protected $signature = 'app:init';
|
||||
|
||||
protected $description = 'Cleanup instance related stuffs';
|
||||
|
||||
public $servers = null;
|
||||
|
||||
public InstanceSettings $settings;
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->optimize();
|
||||
Artisan::call('optimize:clear');
|
||||
Artisan::call('optimize');
|
||||
|
||||
if (isCloud() && ! $this->option('force-cloud')) {
|
||||
echo "Skipping init as we are on cloud and --force-cloud option is not set\n";
|
||||
|
||||
return;
|
||||
try {
|
||||
$this->pullTemplatesFromCDN();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
$this->servers = Server::all();
|
||||
if (! isCloud()) {
|
||||
$this->send_alive_signal();
|
||||
get_public_ips();
|
||||
try {
|
||||
$this->pullChangelogFromGitHub();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not changelogs from github: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
// Backward compatibility
|
||||
$this->replace_slash_in_environment_name();
|
||||
$this->restore_coolify_db_backup();
|
||||
$this->update_user_emails();
|
||||
//
|
||||
$this->update_traefik_labels();
|
||||
if (! isCloud() || $this->option('force-cloud')) {
|
||||
$this->cleanup_unused_network_from_coolify_proxy();
|
||||
}
|
||||
if (isCloud()) {
|
||||
$this->cleanup_unnecessary_dynamic_proxy_configuration();
|
||||
} else {
|
||||
$this->cleanup_in_progress_application_deployments();
|
||||
}
|
||||
$this->call('cleanup:redis');
|
||||
|
||||
$this->call('cleanup:stucked-resources');
|
||||
|
||||
try {
|
||||
$this->pullHelperImage();
|
||||
} catch (\Throwable $e) {
|
||||
//
|
||||
echo "Error in pullHelperImage command: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
if (isCloud()) {
|
||||
try {
|
||||
$this->pullTemplatesFromCDN();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (! isCloud()) {
|
||||
try {
|
||||
$this->pullTemplatesFromCDN();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
||||
$this->settings = instanceSettings();
|
||||
$this->servers = Server::all();
|
||||
|
||||
$do_not_track = data_get($this->settings, 'do_not_track', true);
|
||||
if ($do_not_track == false) {
|
||||
$this->sendAliveSignal();
|
||||
}
|
||||
get_public_ips();
|
||||
|
||||
// Backward compatibility
|
||||
$this->replaceSlashInEnvironmentName();
|
||||
$this->restoreCoolifyDbBackup();
|
||||
$this->updateUserEmails();
|
||||
//
|
||||
$this->updateTraefikLabels();
|
||||
$this->cleanupUnusedNetworkFromCoolifyProxy();
|
||||
|
||||
try {
|
||||
$this->call('cleanup:redis');
|
||||
} 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 ($updatedCount > 0) {
|
||||
echo "Marked {$updatedCount} stuck deployments as failed\n";
|
||||
}
|
||||
try {
|
||||
$localhost = $this->servers->where('id', 0)->first();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$localhost = $this->servers->where('id', 0)->first();
|
||||
if ($localhost) {
|
||||
$localhost->setupDynamicProxyConfiguration();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not setup dynamic configuration: {$e->getMessage()}\n";
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
if (! is_null(config('constants.coolify.autoupdate', null))) {
|
||||
if (config('constants.coolify.autoupdate') == true) {
|
||||
echo "Enabling auto-update\n";
|
||||
$settings->update(['is_auto_update_enabled' => true]);
|
||||
} else {
|
||||
echo "Disabling auto-update\n";
|
||||
$settings->update(['is_auto_update_enabled' => false]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not setup dynamic configuration: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
if (! is_null(config('constants.coolify.autoupdate', null))) {
|
||||
if (config('constants.coolify.autoupdate') == true) {
|
||||
echo "Enabling auto-update\n";
|
||||
$this->settings->update(['is_auto_update_enabled' => true]);
|
||||
} else {
|
||||
echo "Disabling auto-update\n";
|
||||
$this->settings->update(['is_auto_update_enabled' => false]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,28 +132,32 @@ class Init extends Command
|
||||
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
||||
if ($response->successful()) {
|
||||
$services = $response->json();
|
||||
File::put(base_path('templates/service-templates.json'), json_encode($services));
|
||||
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
|
||||
}
|
||||
}
|
||||
|
||||
private function optimize()
|
||||
private function pullChangelogFromGitHub()
|
||||
{
|
||||
Artisan::call('optimize:clear');
|
||||
Artisan::call('optimize');
|
||||
try {
|
||||
PullChangelog::dispatch();
|
||||
echo "Changelog fetch initiated\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not fetch changelog from GitHub: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
|
||||
private function update_user_emails()
|
||||
private function updateUserEmails()
|
||||
{
|
||||
try {
|
||||
User::whereRaw('email ~ \'[A-Z]\'')->get()->each(function (User $user) {
|
||||
$user->update(['email' => strtolower($user->email)]);
|
||||
$user->update(['email' => $user->email]);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in updating user emails: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
|
||||
private function update_traefik_labels()
|
||||
private function updateTraefikLabels()
|
||||
{
|
||||
try {
|
||||
Server::where('proxy->type', 'TRAEFIK_V2')->update(['proxy->type' => 'TRAEFIK']);
|
||||
@@ -137,28 +166,7 @@ class Init extends Command
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanup_unnecessary_dynamic_proxy_configuration()
|
||||
{
|
||||
foreach ($this->servers as $server) {
|
||||
try {
|
||||
if (! $server->isFunctional()) {
|
||||
continue;
|
||||
}
|
||||
if ($server->id === 0) {
|
||||
continue;
|
||||
}
|
||||
$file = $server->proxyPath().'/dynamic/coolify.yaml';
|
||||
|
||||
return instant_remote_process([
|
||||
"rm -f $file",
|
||||
], $server, false);
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning up unnecessary dynamic proxy configuration: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanup_unused_network_from_coolify_proxy()
|
||||
private function cleanupUnusedNetworkFromCoolifyProxy()
|
||||
{
|
||||
foreach ($this->servers as $server) {
|
||||
if (! $server->isFunctional()) {
|
||||
@@ -197,7 +205,7 @@ class Init extends Command
|
||||
}
|
||||
}
|
||||
|
||||
private function restore_coolify_db_backup()
|
||||
private function restoreCoolifyDbBackup()
|
||||
{
|
||||
if (version_compare('4.0.0-beta.179', config('constants.coolify.version'), '<=')) {
|
||||
try {
|
||||
@@ -223,17 +231,10 @@ class Init extends Command
|
||||
}
|
||||
}
|
||||
|
||||
private function send_alive_signal()
|
||||
private function sendAliveSignal()
|
||||
{
|
||||
$id = config('app.id');
|
||||
$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 {
|
||||
Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version");
|
||||
} catch (\Throwable $e) {
|
||||
@@ -241,24 +242,7 @@ class Init extends Command
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanup_in_progress_application_deployments()
|
||||
{
|
||||
// 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 replace_slash_in_environment_name()
|
||||
private function replaceSlashInEnvironmentName()
|
||||
{
|
||||
if (version_compare('4.0.0-beta.298', config('constants.coolify.version'), '<=')) {
|
||||
$environments = Environment::all();
|
||||
|
||||
247
app/Console/Commands/RunScheduledJobsManually.php
Normal file
247
app/Console/Commands/RunScheduledJobsManually.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledTask;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RunScheduledJobsManually extends Command
|
||||
{
|
||||
protected $signature = 'schedule:run-manual
|
||||
{--type=all : Type of jobs to run (all, backups, tasks)}
|
||||
{--frequency= : Filter by frequency (daily, hourly, weekly, monthly, yearly, or cron expression)}
|
||||
{--chunk=5 : Number of jobs to process in each batch}
|
||||
{--delay=30 : Delay in seconds between batches}
|
||||
{--max= : Maximum number of jobs to process (useful for testing)}
|
||||
{--dry-run : Show what would be executed without actually running jobs}';
|
||||
|
||||
protected $description = 'Manually run scheduled database backups and tasks when cron fails';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$type = $this->option('type');
|
||||
$frequency = $this->option('frequency');
|
||||
$chunkSize = (int) $this->option('chunk');
|
||||
$delay = (int) $this->option('delay');
|
||||
$maxJobs = $this->option('max') ? (int) $this->option('max') : null;
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('Starting manual execution of scheduled jobs...'.($dryRun ? ' (DRY RUN)' : ''));
|
||||
$this->info("Type: {$type}".($frequency ? ", Frequency: {$frequency}" : '').", Chunk size: {$chunkSize}, Delay: {$delay}s".($maxJobs ? ", Max jobs: {$maxJobs}" : '').($dryRun ? ', Dry run: enabled' : ''));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE: No jobs will actually be dispatched');
|
||||
}
|
||||
|
||||
if ($type === 'all' || $type === 'backups') {
|
||||
$this->runScheduledBackups($chunkSize, $delay, $maxJobs, $dryRun, $frequency);
|
||||
}
|
||||
|
||||
if ($type === 'all' || $type === 'tasks') {
|
||||
$this->runScheduledTasks($chunkSize, $delay, $maxJobs, $dryRun, $frequency);
|
||||
}
|
||||
|
||||
$this->info('Completed manual execution of scheduled jobs.'.($dryRun ? ' (DRY RUN)' : ''));
|
||||
}
|
||||
|
||||
private function runScheduledBackups(int $chunkSize, int $delay, ?int $maxJobs = null, bool $dryRun = false, ?string $frequency = null): void
|
||||
{
|
||||
$this->info('Processing scheduled database backups...');
|
||||
|
||||
$query = ScheduledDatabaseBackup::where('enabled', true);
|
||||
|
||||
if ($frequency) {
|
||||
$query->where(function ($q) use ($frequency) {
|
||||
// Handle human-readable frequency strings
|
||||
if (in_array($frequency, ['daily', 'hourly', 'weekly', 'monthly', 'yearly', 'every_minute'])) {
|
||||
$q->where('frequency', $frequency);
|
||||
} else {
|
||||
// Handle cron expressions
|
||||
$q->where('frequency', $frequency);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$scheduled_backups = $query->get();
|
||||
|
||||
if ($scheduled_backups->isEmpty()) {
|
||||
$this->info('No enabled scheduled backups found'.($frequency ? " with frequency '{$frequency}'" : '').'.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$finalScheduledBackups = collect();
|
||||
|
||||
foreach ($scheduled_backups as $scheduled_backup) {
|
||||
if (blank(data_get($scheduled_backup, 'database'))) {
|
||||
$this->warn("Deleting backup {$scheduled_backup->id} - missing database");
|
||||
$scheduled_backup->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$server = $scheduled_backup->server();
|
||||
if (blank($server)) {
|
||||
$this->warn("Deleting backup {$scheduled_backup->id} - missing server");
|
||||
$scheduled_backup->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($server->isFunctional() === false) {
|
||||
$this->warn("Skipping backup {$scheduled_backup->id} - server not functional");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||
$this->warn("Skipping backup {$scheduled_backup->id} - subscription not paid");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$finalScheduledBackups->push($scheduled_backup);
|
||||
}
|
||||
|
||||
if ($maxJobs && $finalScheduledBackups->count() > $maxJobs) {
|
||||
$finalScheduledBackups = $finalScheduledBackups->take($maxJobs);
|
||||
$this->info("Limited to {$maxJobs} scheduled backups for testing");
|
||||
}
|
||||
|
||||
$this->info("Found {$finalScheduledBackups->count()} valid scheduled backups to process".($frequency ? " with frequency '{$frequency}'" : ''));
|
||||
|
||||
$chunks = $finalScheduledBackups->chunk($chunkSize);
|
||||
foreach ($chunks as $index => $chunk) {
|
||||
$this->info('Processing backup batch '.($index + 1).' of '.$chunks->count()." ({$chunk->count()} items)");
|
||||
|
||||
foreach ($chunk as $scheduled_backup) {
|
||||
try {
|
||||
if ($dryRun) {
|
||||
$this->info("🔍 Would dispatch backup job for: {$scheduled_backup->name} (ID: {$scheduled_backup->id}, Frequency: {$scheduled_backup->frequency})");
|
||||
} else {
|
||||
DatabaseBackupJob::dispatch($scheduled_backup);
|
||||
$this->info("✓ Dispatched backup job for: {$scheduled_backup->name} (ID: {$scheduled_backup->id}, Frequency: {$scheduled_backup->frequency})");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error("✗ Failed to dispatch backup job for {$scheduled_backup->id}: ".$e->getMessage());
|
||||
Log::error('Error dispatching backup job: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ($index < $chunks->count() - 1 && ! $dryRun) {
|
||||
$this->info("Waiting {$delay} seconds before next batch...");
|
||||
sleep($delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function runScheduledTasks(int $chunkSize, int $delay, ?int $maxJobs = null, bool $dryRun = false, ?string $frequency = null): void
|
||||
{
|
||||
$this->info('Processing scheduled tasks...');
|
||||
|
||||
$query = ScheduledTask::where('enabled', true);
|
||||
|
||||
if ($frequency) {
|
||||
$query->where(function ($q) use ($frequency) {
|
||||
// Handle human-readable frequency strings
|
||||
if (in_array($frequency, ['daily', 'hourly', 'weekly', 'monthly', 'yearly', 'every_minute'])) {
|
||||
$q->where('frequency', $frequency);
|
||||
} else {
|
||||
// Handle cron expressions
|
||||
$q->where('frequency', $frequency);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$scheduled_tasks = $query->get();
|
||||
|
||||
if ($scheduled_tasks->isEmpty()) {
|
||||
$this->info('No enabled scheduled tasks found'.($frequency ? " with frequency '{$frequency}'" : '').'.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$finalScheduledTasks = collect();
|
||||
|
||||
foreach ($scheduled_tasks as $scheduled_task) {
|
||||
$service = $scheduled_task->service;
|
||||
$application = $scheduled_task->application;
|
||||
|
||||
$server = $scheduled_task->server();
|
||||
if (blank($server)) {
|
||||
$this->warn("Deleting task {$scheduled_task->id} - missing server");
|
||||
$scheduled_task->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($server->isFunctional() === false) {
|
||||
$this->warn("Skipping task {$scheduled_task->id} - server not functional");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||
$this->warn("Skipping task {$scheduled_task->id} - subscription not paid");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $service && ! $application) {
|
||||
$this->warn("Deleting task {$scheduled_task->id} - missing service and application");
|
||||
$scheduled_task->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($application && str($application->status)->contains('running') === false) {
|
||||
$this->warn("Skipping task {$scheduled_task->id} - application not running");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($service && str($service->status)->contains('running') === false) {
|
||||
$this->warn("Skipping task {$scheduled_task->id} - service not running");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$finalScheduledTasks->push($scheduled_task);
|
||||
}
|
||||
|
||||
if ($maxJobs && $finalScheduledTasks->count() > $maxJobs) {
|
||||
$finalScheduledTasks = $finalScheduledTasks->take($maxJobs);
|
||||
$this->info("Limited to {$maxJobs} scheduled tasks for testing");
|
||||
}
|
||||
|
||||
$this->info("Found {$finalScheduledTasks->count()} valid scheduled tasks to process".($frequency ? " with frequency '{$frequency}'" : ''));
|
||||
|
||||
$chunks = $finalScheduledTasks->chunk($chunkSize);
|
||||
foreach ($chunks as $index => $chunk) {
|
||||
$this->info('Processing task batch '.($index + 1).' of '.$chunks->count()." ({$chunk->count()} items)");
|
||||
|
||||
foreach ($chunk as $scheduled_task) {
|
||||
try {
|
||||
if ($dryRun) {
|
||||
$this->info("🔍 Would dispatch task job for: {$scheduled_task->name} (ID: {$scheduled_task->id}, Frequency: {$scheduled_task->frequency})");
|
||||
} else {
|
||||
ScheduledTaskJob::dispatch($scheduled_task);
|
||||
$this->info("✓ Dispatched task job for: {$scheduled_task->name} (ID: {$scheduled_task->id}, Frequency: {$scheduled_task->frequency})");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error("✗ Failed to dispatch task job for {$scheduled_task->id}: ".$e->getMessage());
|
||||
Log::error('Error dispatching task job: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ($index < $chunks->count() - 1 && ! $dryRun) {
|
||||
$this->info("Waiting {$delay} seconds before next batch...");
|
||||
sleep($delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,14 @@ use App\Jobs\DeleteResourceJob;
|
||||
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\Console\Command;
|
||||
|
||||
use function Laravel\Prompts\confirm;
|
||||
@@ -103,19 +110,79 @@ class ServicesDelete extends Command
|
||||
|
||||
private function deleteDatabase()
|
||||
{
|
||||
$databases = StandalonePostgresql::all();
|
||||
if ($databases->count() === 0) {
|
||||
// Collect all databases from all types with unique identifiers
|
||||
$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.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$databasesToDelete = multiselect(
|
||||
'What database do you want to delete?',
|
||||
$databases->pluck('name', 'id')->sortKeys(),
|
||||
$databaseOptions->sortKeys(),
|
||||
);
|
||||
|
||||
foreach ($databasesToDelete as $database) {
|
||||
$toDelete = $databases->where('id', $database)->first();
|
||||
foreach ($databasesToDelete as $databaseKey) {
|
||||
$toDelete = $allDatabases->get($databaseKey);
|
||||
if ($toDelete) {
|
||||
$this->info($toDelete);
|
||||
$confirmed = confirm('Are you sure you want to delete all selected resources?');
|
||||
|
||||
@@ -16,7 +16,7 @@ class SyncBunny extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'sync:bunny {--templates} {--release} {--nightly}';
|
||||
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -25,6 +25,50 @@ class SyncBunny extends Command
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -33,6 +77,7 @@ class SyncBunny extends Command
|
||||
$that = $this;
|
||||
$only_template = $this->option('templates');
|
||||
$only_version = $this->option('release');
|
||||
$only_github_releases = $this->option('github-releases');
|
||||
$nightly = $this->option('nightly');
|
||||
$bunny_cdn = 'https://cdn.coollabs.io';
|
||||
$bunny_cdn_path = 'coolify';
|
||||
@@ -45,7 +90,7 @@ class SyncBunny extends Command
|
||||
$install_script = 'install.sh';
|
||||
$upgrade_script = 'upgrade.sh';
|
||||
$production_env = '.env.production';
|
||||
$service_template = 'service-templates.json';
|
||||
$service_template = config('constants.services.file_name');
|
||||
$versions = 'versions.json';
|
||||
|
||||
$compose_file_location = "$parent_dir/$compose_file";
|
||||
@@ -90,7 +135,7 @@ class SyncBunny extends Command
|
||||
$install_script_location = "$parent_dir/other/nightly/$install_script";
|
||||
$versions_location = "$parent_dir/other/nightly/$versions";
|
||||
}
|
||||
if (! $only_template && ! $only_version) {
|
||||
if (! $only_template && ! $only_version && ! $only_github_releases) {
|
||||
if ($nightly) {
|
||||
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
|
||||
} else {
|
||||
@@ -102,7 +147,7 @@ class SyncBunny extends Command
|
||||
}
|
||||
}
|
||||
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?');
|
||||
if (! $confirmed) {
|
||||
return;
|
||||
@@ -128,12 +173,29 @@ class SyncBunny extends Command
|
||||
if (! $confirmed) {
|
||||
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) => [
|
||||
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
|
||||
]);
|
||||
$this->info('versions.json uploaded & purged...');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
278
app/Console/Commands/ViewScheduledLogs.php
Normal file
278
app/Console/Commands/ViewScheduledLogs.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class ViewScheduledLogs extends Command
|
||||
{
|
||||
protected $signature = 'logs:scheduled
|
||||
{--lines=50 : Number of lines to display}
|
||||
{--follow : Follow the log file (tail -f)}
|
||||
{--date= : Specific date (Y-m-d format, defaults to today)}
|
||||
{--task-name= : Filter by task name (partial match)}
|
||||
{--task-id= : Filter by task ID}
|
||||
{--backup-name= : Filter by backup name (partial match)}
|
||||
{--backup-id= : Filter by backup ID}
|
||||
{--errors : View error logs only}
|
||||
{--all : View both normal and error logs}
|
||||
{--hourly : Filter hourly jobs}
|
||||
{--daily : Filter daily jobs}
|
||||
{--weekly : Filter weekly jobs}
|
||||
{--monthly : Filter monthly jobs}
|
||||
{--frequency= : Filter by specific cron expression}';
|
||||
|
||||
protected $description = 'View scheduled backups and tasks logs with optional filtering';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$date = $this->option('date') ?: now()->format('Y-m-d');
|
||||
$logPaths = $this->getLogPaths($date);
|
||||
|
||||
if (empty($logPaths)) {
|
||||
$this->showAvailableLogFiles($date);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = $this->option('lines');
|
||||
$follow = $this->option('follow');
|
||||
|
||||
// Build grep filters
|
||||
$filters = $this->buildFilters();
|
||||
$filterDescription = $this->getFilterDescription();
|
||||
$logTypeDescription = $this->getLogTypeDescription();
|
||||
|
||||
if ($follow) {
|
||||
$this->info("Following {$logTypeDescription} logs for {$date}{$filterDescription} (Press Ctrl+C to stop)...");
|
||||
$this->line('');
|
||||
|
||||
if (count($logPaths) === 1) {
|
||||
$logPath = $logPaths[0];
|
||||
if ($filters) {
|
||||
passthru("tail -f {$logPath} | grep -E '{$filters}'");
|
||||
} else {
|
||||
passthru("tail -f {$logPath}");
|
||||
}
|
||||
} else {
|
||||
// Multiple files - use multitail or tail with process substitution
|
||||
$logPathsStr = implode(' ', $logPaths);
|
||||
if ($filters) {
|
||||
passthru("tail -f {$logPathsStr} | grep -E '{$filters}'");
|
||||
} else {
|
||||
passthru("tail -f {$logPathsStr}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:");
|
||||
$this->line('');
|
||||
|
||||
if (count($logPaths) === 1) {
|
||||
$logPath = $logPaths[0];
|
||||
if ($filters) {
|
||||
passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'");
|
||||
} else {
|
||||
passthru("tail -n {$lines} {$logPath}");
|
||||
}
|
||||
} else {
|
||||
// Multiple files - concatenate and sort by timestamp
|
||||
$logPathsStr = implode(' ', $logPaths);
|
||||
if ($filters) {
|
||||
passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'");
|
||||
} else {
|
||||
passthru("tail -n {$lines} {$logPathsStr} | sort");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getLogPaths(string $date): array
|
||||
{
|
||||
$paths = [];
|
||||
|
||||
if ($this->option('errors')) {
|
||||
// Error logs only
|
||||
$errorPath = storage_path("logs/scheduled-errors-{$date}.log");
|
||||
if (File::exists($errorPath)) {
|
||||
$paths[] = $errorPath;
|
||||
}
|
||||
} elseif ($this->option('all')) {
|
||||
// Both normal and error logs
|
||||
$normalPath = storage_path("logs/scheduled-{$date}.log");
|
||||
$errorPath = storage_path("logs/scheduled-errors-{$date}.log");
|
||||
|
||||
if (File::exists($normalPath)) {
|
||||
$paths[] = $normalPath;
|
||||
}
|
||||
if (File::exists($errorPath)) {
|
||||
$paths[] = $errorPath;
|
||||
}
|
||||
} else {
|
||||
// Normal logs only (default)
|
||||
$normalPath = storage_path("logs/scheduled-{$date}.log");
|
||||
if (File::exists($normalPath)) {
|
||||
$paths[] = $normalPath;
|
||||
}
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
|
||||
private function showAvailableLogFiles(string $date): void
|
||||
{
|
||||
$logType = $this->getLogTypeDescription();
|
||||
$this->warn("No {$logType} logs found for date {$date}");
|
||||
|
||||
// Show available log files
|
||||
$normalFiles = File::glob(storage_path('logs/scheduled-*.log'));
|
||||
$errorFiles = File::glob(storage_path('logs/scheduled-errors-*.log'));
|
||||
|
||||
if (! empty($normalFiles) || ! empty($errorFiles)) {
|
||||
$this->info('Available scheduled log files:');
|
||||
|
||||
if (! empty($normalFiles)) {
|
||||
$this->line(' Normal logs:');
|
||||
foreach ($normalFiles as $file) {
|
||||
$basename = basename($file);
|
||||
$this->line(" - {$basename}");
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($errorFiles)) {
|
||||
$this->line(' Error logs:');
|
||||
foreach ($errorFiles as $file) {
|
||||
$basename = basename($file);
|
||||
$this->line(" - {$basename}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getLogTypeDescription(): string
|
||||
{
|
||||
if ($this->option('errors')) {
|
||||
return 'error';
|
||||
} elseif ($this->option('all')) {
|
||||
return 'all';
|
||||
} else {
|
||||
return 'normal';
|
||||
}
|
||||
}
|
||||
|
||||
private function buildFilters(): ?string
|
||||
{
|
||||
$filters = [];
|
||||
|
||||
if ($taskName = $this->option('task-name')) {
|
||||
$filters[] = '"task_name":"[^"]*'.preg_quote($taskName, '/').'[^"]*"';
|
||||
}
|
||||
|
||||
if ($taskId = $this->option('task-id')) {
|
||||
$filters[] = '"task_id":'.preg_quote($taskId, '/');
|
||||
}
|
||||
|
||||
if ($backupName = $this->option('backup-name')) {
|
||||
$filters[] = '"backup_name":"[^"]*'.preg_quote($backupName, '/').'[^"]*"';
|
||||
}
|
||||
|
||||
if ($backupId = $this->option('backup-id')) {
|
||||
$filters[] = '"backup_id":'.preg_quote($backupId, '/');
|
||||
}
|
||||
|
||||
// Frequency filters
|
||||
if ($this->option('hourly')) {
|
||||
$filters[] = $this->getFrequencyPattern('hourly');
|
||||
}
|
||||
|
||||
if ($this->option('daily')) {
|
||||
$filters[] = $this->getFrequencyPattern('daily');
|
||||
}
|
||||
|
||||
if ($this->option('weekly')) {
|
||||
$filters[] = $this->getFrequencyPattern('weekly');
|
||||
}
|
||||
|
||||
if ($this->option('monthly')) {
|
||||
$filters[] = $this->getFrequencyPattern('monthly');
|
||||
}
|
||||
|
||||
if ($frequency = $this->option('frequency')) {
|
||||
$filters[] = '"frequency":"'.preg_quote($frequency, '/').'"';
|
||||
}
|
||||
|
||||
return empty($filters) ? null : implode('|', $filters);
|
||||
}
|
||||
|
||||
private function getFrequencyPattern(string $type): string
|
||||
{
|
||||
$patterns = [
|
||||
'hourly' => [
|
||||
'0 \* \* \* \*', // 0 * * * *
|
||||
'@hourly', // @hourly
|
||||
],
|
||||
'daily' => [
|
||||
'0 0 \* \* \*', // 0 0 * * *
|
||||
'@daily', // @daily
|
||||
'@midnight', // @midnight
|
||||
],
|
||||
'weekly' => [
|
||||
'0 0 \* \* [0-6]', // 0 0 * * 0-6 (any day of week)
|
||||
'@weekly', // @weekly
|
||||
],
|
||||
'monthly' => [
|
||||
'0 0 1 \* \*', // 0 0 1 * * (first of month)
|
||||
'@monthly', // @monthly
|
||||
],
|
||||
];
|
||||
|
||||
$typePatterns = $patterns[$type] ?? [];
|
||||
|
||||
// For grep, we need to match the frequency field in JSON
|
||||
return '"frequency":"('.implode('|', $typePatterns).')"';
|
||||
}
|
||||
|
||||
private function getFilterDescription(): string
|
||||
{
|
||||
$descriptions = [];
|
||||
|
||||
if ($taskName = $this->option('task-name')) {
|
||||
$descriptions[] = "task name: {$taskName}";
|
||||
}
|
||||
|
||||
if ($taskId = $this->option('task-id')) {
|
||||
$descriptions[] = "task ID: {$taskId}";
|
||||
}
|
||||
|
||||
if ($backupName = $this->option('backup-name')) {
|
||||
$descriptions[] = "backup name: {$backupName}";
|
||||
}
|
||||
|
||||
if ($backupId = $this->option('backup-id')) {
|
||||
$descriptions[] = "backup ID: {$backupId}";
|
||||
}
|
||||
|
||||
// Frequency filters
|
||||
if ($this->option('hourly')) {
|
||||
$descriptions[] = 'hourly jobs';
|
||||
}
|
||||
|
||||
if ($this->option('daily')) {
|
||||
$descriptions[] = 'daily jobs';
|
||||
}
|
||||
|
||||
if ($this->option('weekly')) {
|
||||
$descriptions[] = 'weekly jobs';
|
||||
}
|
||||
|
||||
if ($this->option('monthly')) {
|
||||
$descriptions[] = 'monthly jobs';
|
||||
}
|
||||
|
||||
if ($frequency = $this->option('frequency')) {
|
||||
$descriptions[] = "frequency: {$frequency}";
|
||||
}
|
||||
|
||||
return empty($descriptions) ? '' : ' (filtered by '.implode(', ', $descriptions).')';
|
||||
}
|
||||
}
|
||||
@@ -6,22 +6,17 @@ use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Jobs\CheckForUpdatesJob;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Jobs\DockerCleanupJob;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Jobs\PullTemplatesFromCDN;
|
||||
use App\Jobs\RegenerateSslCertJob;
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
use App\Jobs\ServerCheckJob;
|
||||
use App\Jobs\ServerStorageCheckJob;
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use App\Jobs\ServerManagerJob;
|
||||
use App\Jobs\UpdateCoolifyJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
@@ -51,7 +46,7 @@ class Kernel extends ConsoleKernel
|
||||
}
|
||||
|
||||
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
|
||||
$this->scheduleInstance->command('cleanup:redis')->hourly();
|
||||
$this->scheduleInstance->command('cleanup:redis')->weekly();
|
||||
|
||||
if (isDev()) {
|
||||
// Instance Jobs
|
||||
@@ -60,10 +55,10 @@ class Kernel extends ConsoleKernel
|
||||
$this->scheduleInstance->job(new CheckHelperImageJob)->everyTenMinutes()->onOneServer();
|
||||
|
||||
// Server Jobs
|
||||
$this->checkResources();
|
||||
$this->scheduleInstance->job(new ServerManagerJob)->everyMinute()->onOneServer();
|
||||
|
||||
$this->checkScheduledBackups();
|
||||
$this->checkScheduledTasks();
|
||||
// Scheduled Jobs (Backups & Tasks)
|
||||
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
|
||||
|
||||
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
||||
|
||||
@@ -73,17 +68,18 @@ class Kernel extends ConsoleKernel
|
||||
$this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->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->scheduleUpdates();
|
||||
|
||||
// Server Jobs
|
||||
$this->checkResources();
|
||||
$this->scheduleInstance->job(new ServerManagerJob)->everyMinute()->onOneServer();
|
||||
|
||||
$this->pullImages();
|
||||
|
||||
$this->checkScheduledBackups();
|
||||
$this->checkScheduledTasks();
|
||||
// Scheduled Jobs (Backups & Tasks)
|
||||
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
|
||||
|
||||
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
|
||||
|
||||
@@ -134,179 +130,6 @@ class Kernel extends ConsoleKernel
|
||||
}
|
||||
}
|
||||
|
||||
private function checkResources(): void
|
||||
{
|
||||
if (isCloud()) {
|
||||
$servers = $this->allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
|
||||
$own = Team::find(0)->servers;
|
||||
$servers = $servers->merge($own);
|
||||
} else {
|
||||
$servers = $this->allServers->get();
|
||||
}
|
||||
|
||||
foreach ($servers as $server) {
|
||||
try {
|
||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||
if (validate_timezone($serverTimezone) === false) {
|
||||
$serverTimezone = config('app.timezone');
|
||||
}
|
||||
|
||||
// Sentinel check
|
||||
$lastSentinelUpdate = $server->sentinel_updated_at;
|
||||
if (Carbon::parse($lastSentinelUpdate)->isBefore(now()->subSeconds($server->waitBeforeDoingSshCheck()))) {
|
||||
// Check container status every minute if Sentinel does not activated
|
||||
if (isCloud()) {
|
||||
$this->scheduleInstance->job(new ServerCheckJob($server))->timezone($serverTimezone)->everyFiveMinutes()->onOneServer();
|
||||
} else {
|
||||
$this->scheduleInstance->job(new ServerCheckJob($server))->timezone($serverTimezone)->everyMinute()->onOneServer();
|
||||
}
|
||||
// $this->scheduleInstance->job(new \App\Jobs\ServerCheckNewJob($server))->everyFiveMinutes()->onOneServer();
|
||||
|
||||
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
|
||||
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
|
||||
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
|
||||
}
|
||||
$this->scheduleInstance->job(new ServerStorageCheckJob($server))->cron($serverDiskUsageCheckFrequency)->timezone($serverTimezone)->onOneServer();
|
||||
}
|
||||
|
||||
$dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
|
||||
if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) {
|
||||
$dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency];
|
||||
}
|
||||
$this->scheduleInstance->job(new DockerCleanupJob($server))->cron($dockerCleanupFrequency)->timezone($serverTimezone)->onOneServer();
|
||||
|
||||
// Cleanup multiplexed connections every hour
|
||||
// $this->scheduleInstance->job(new ServerCleanupMux($server))->hourly()->onOneServer();
|
||||
|
||||
// Temporary solution until we have better memory management for Sentinel
|
||||
if ($server->isSentinelEnabled()) {
|
||||
$this->scheduleInstance->job(function () use ($server) {
|
||||
$server->restartContainer('coolify-sentinel');
|
||||
})->daily()->onOneServer();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error checking resources: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkScheduledBackups(): void
|
||||
{
|
||||
$scheduled_backups = ScheduledDatabaseBackup::where('enabled', true)->get();
|
||||
if ($scheduled_backups->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
$finalScheduledBackups = collect();
|
||||
foreach ($scheduled_backups as $scheduled_backup) {
|
||||
if (blank(data_get($scheduled_backup, 'database'))) {
|
||||
$scheduled_backup->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
$server = $scheduled_backup->server();
|
||||
if (blank($server)) {
|
||||
$scheduled_backup->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($server->isFunctional() === false) {
|
||||
continue;
|
||||
}
|
||||
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||
continue;
|
||||
}
|
||||
$finalScheduledBackups->push($scheduled_backup);
|
||||
}
|
||||
|
||||
foreach ($finalScheduledBackups as $scheduled_backup) {
|
||||
try {
|
||||
if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) {
|
||||
$scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency];
|
||||
}
|
||||
$server = $scheduled_backup->server();
|
||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||
|
||||
if (validate_timezone($serverTimezone) === false) {
|
||||
$serverTimezone = config('app.timezone');
|
||||
}
|
||||
|
||||
if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) {
|
||||
$scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency];
|
||||
}
|
||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||
$this->scheduleInstance->job(new DatabaseBackupJob(
|
||||
backup: $scheduled_backup
|
||||
))->cron($scheduled_backup->frequency)->timezone($serverTimezone)->onOneServer();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error scheduling backup: '.$e->getMessage());
|
||||
Log::error($e->getTraceAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkScheduledTasks(): void
|
||||
{
|
||||
$scheduled_tasks = ScheduledTask::where('enabled', true)->get();
|
||||
if ($scheduled_tasks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
$finalScheduledTasks = collect();
|
||||
foreach ($scheduled_tasks as $scheduled_task) {
|
||||
$service = $scheduled_task->service;
|
||||
$application = $scheduled_task->application;
|
||||
|
||||
$server = $scheduled_task->server();
|
||||
if (blank($server)) {
|
||||
$scheduled_task->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($server->isFunctional() === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $service && ! $application) {
|
||||
$scheduled_task->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($application && str($application->status)->contains('running') === false) {
|
||||
continue;
|
||||
}
|
||||
if ($service && str($service->status)->contains('running') === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$finalScheduledTasks->push($scheduled_task);
|
||||
}
|
||||
|
||||
foreach ($finalScheduledTasks as $scheduled_task) {
|
||||
try {
|
||||
$server = $scheduled_task->server();
|
||||
if (isset(VALID_CRON_STRINGS[$scheduled_task->frequency])) {
|
||||
$scheduled_task->frequency = VALID_CRON_STRINGS[$scheduled_task->frequency];
|
||||
}
|
||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||
|
||||
if (validate_timezone($serverTimezone) === false) {
|
||||
$serverTimezone = config('app.timezone');
|
||||
}
|
||||
$this->scheduleInstance->job(new ScheduledTaskJob(
|
||||
task: $scheduled_task
|
||||
))->cron($scheduled_task->frequency)->timezone($serverTimezone)->onOneServer();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error scheduling task: '.$e->getMessage());
|
||||
Log::error($e->getTraceAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function commands(): void
|
||||
{
|
||||
$this->load(__DIR__.'/Commands');
|
||||
|
||||
35
app/Events/ApplicationConfigurationChanged.php
Normal file
35
app/Events/ApplicationConfigurationChanged.php
Normal 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}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@ use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Laravel\Horizon\Contracts\Silenced;
|
||||
|
||||
class BackupCreated implements ShouldBroadcast
|
||||
class BackupCreated implements ShouldBroadcast, Silenced
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ProxyStarted
|
||||
class CloudflareTunnelChanged
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
@@ -3,33 +3,12 @@
|
||||
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 ProxyStatusChanged implements ShouldBroadcast
|
||||
class ProxyStatusChanged
|
||||
{
|
||||
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}"),
|
||||
];
|
||||
}
|
||||
public function __construct(public $data) {}
|
||||
}
|
||||
|
||||
35
app/Events/ProxyStatusChangedUI.php
Normal file
35
app/Events/ProxyStatusChangedUI.php
Normal 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 ProxyStatusChangedUI implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public ?int $teamId = null;
|
||||
|
||||
public function __construct(?int $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}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Events/SentinelRestarted.php
Normal file
39
app/Events/SentinelRestarted.php
Normal 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}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
35
app/Events/ServerPackageUpdated.php
Normal file
35
app/Events/ServerPackageUpdated.php
Normal 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 ServerPackageUpdated 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}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Events/ServiceChecked.php
Normal file
36
app/Events/ServiceChecked.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?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;
|
||||
use Laravel\Horizon\Contracts\Silenced;
|
||||
|
||||
class ServiceChecked implements ShouldBroadcast, Silenced
|
||||
{
|
||||
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}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -13,24 +13,22 @@ class ServiceStatusChanged implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public int|string|null $userId = null;
|
||||
|
||||
public function __construct($userId = null)
|
||||
{
|
||||
if (is_null($userId)) {
|
||||
$userId = Auth::id() ?? null;
|
||||
public function __construct(
|
||||
public ?int $teamId = null
|
||||
) {
|
||||
if (is_null($this->teamId) && Auth::check() && Auth::user()->currentTeam()) {
|
||||
$this->teamId = Auth::user()->currentTeam()->id;
|
||||
}
|
||||
$this->userId = $userId;
|
||||
}
|
||||
|
||||
public function broadcastOn(): ?array
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
if (is_null($this->userId)) {
|
||||
if (is_null($this->teamId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new PrivateChannel("user.{$this->userId}"),
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
protected $dontReport = [
|
||||
ProcessException::class,
|
||||
NonReportableException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -53,6 +54,35 @@ class Handler extends ExceptionHandler
|
||||
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.
|
||||
*/
|
||||
@@ -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')) {
|
||||
// Log locally but don't send to Sentry
|
||||
logger()->warning('Disk space error: '.$e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Integration::captureUnhandledException($e);
|
||||
});
|
||||
}
|
||||
|
||||
31
app/Exceptions/NonReportableException.php
Normal file
31
app/Exceptions/NonReportableException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ namespace App\Helpers;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class SshMultiplexingHelper
|
||||
@@ -30,6 +32,7 @@ class SshMultiplexingHelper
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
|
||||
// Check if connection exists
|
||||
$checkCommand = "ssh -O check -o ControlPath=$muxSocket ";
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
@@ -41,6 +44,24 @@ class SshMultiplexingHelper
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -65,6 +86,9 @@ class SshMultiplexingHelper
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store connection metadata for tracking
|
||||
self::storeConnectionMetadata($server);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -79,6 +103,9 @@ class SshMultiplexingHelper
|
||||
}
|
||||
$closeCommand .= "{$server->user}@{$server->ip}";
|
||||
Process::run($closeCommand);
|
||||
|
||||
// Clear connection metadata from cache
|
||||
self::clearConnectionMetadata($server);
|
||||
}
|
||||
|
||||
public static function generateScpCommand(Server $server, string $source, string $dest)
|
||||
@@ -94,8 +121,18 @@ class SshMultiplexingHelper
|
||||
if ($server->isIpv6()) {
|
||||
$scp_command .= '-6 ';
|
||||
}
|
||||
if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) {
|
||||
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
if (self::isMultiplexingEnabled()) {
|
||||
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')) {
|
||||
@@ -103,7 +140,11 @@ class SshMultiplexingHelper
|
||||
}
|
||||
|
||||
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
|
||||
$scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}";
|
||||
if ($server->isIpv6()) {
|
||||
$scp_command .= "{$source} {$server->user}@[{$server->ip}]:{$dest}";
|
||||
} else {
|
||||
$scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}";
|
||||
}
|
||||
|
||||
return $scp_command;
|
||||
}
|
||||
@@ -126,8 +167,16 @@ class SshMultiplexingHelper
|
||||
|
||||
$ssh_command = "timeout $timeout ssh ";
|
||||
|
||||
if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) {
|
||||
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
$multiplexingSuccessful = false;
|
||||
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')) {
|
||||
@@ -182,4 +231,81 @@ class SshMultiplexingHelper
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
34
app/Helpers/SshRetryHandler.php
Normal file
34
app/Helpers/SshRetryHandler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
@@ -217,6 +218,8 @@ class DatabasesController extends Controller
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $database);
|
||||
|
||||
return response()->json($this->removeSensitiveData($database));
|
||||
}
|
||||
|
||||
@@ -364,6 +367,9 @@ class DatabasesController extends Controller
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
if ($request->is_public && $request->public_port) {
|
||||
if (isPublicPortAlreadyUsed($database->destination->server, $request->public_port, $database->id)) {
|
||||
return response()->json(['message' => 'Public port already used by another database.'], 400);
|
||||
@@ -1266,6 +1272,9 @@ class DatabasesController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// Use a generic authorization for database creation - using PostgreSQL as representative model
|
||||
$this->authorize('create', StandalonePostgresql::class);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
@@ -1844,12 +1853,14 @@ class DatabasesController extends Controller
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('delete', $database);
|
||||
|
||||
DeleteResourceJob::dispatch(
|
||||
resource: $database,
|
||||
deleteConfigurations: $request->query->get('delete_configurations', 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([
|
||||
@@ -2017,6 +2028,9 @@ class DatabasesController extends Controller
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('manage', $database);
|
||||
|
||||
if (str($database->status)->contains('running')) {
|
||||
return response()->json(['message' => 'Database is already running.'], 400);
|
||||
}
|
||||
@@ -2095,6 +2109,9 @@ class DatabasesController extends Controller
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('manage', $database);
|
||||
|
||||
if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) {
|
||||
return response()->json(['message' => 'Database is already stopped.'], 400);
|
||||
}
|
||||
@@ -2173,6 +2190,9 @@ class DatabasesController extends Controller
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('manage', $database);
|
||||
|
||||
RestartDatabase::dispatch($database);
|
||||
|
||||
return response()->json(
|
||||
|
||||
@@ -225,6 +225,14 @@ class DeployController extends Controller
|
||||
foreach ($uuids as $uuid) {
|
||||
$resource = getResourceByUuid($uuid, $teamId);
|
||||
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);
|
||||
if ($deployment_uuid) {
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||
@@ -299,6 +307,12 @@ class DeployController extends Controller
|
||||
}
|
||||
switch ($resource?->getMorphClass()) {
|
||||
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;
|
||||
$result = queue_application_deployment(
|
||||
application: $resource,
|
||||
@@ -313,15 +327,27 @@ class DeployController extends Controller
|
||||
}
|
||||
break;
|
||||
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);
|
||||
$message = "Service {$resource->name} started. It could take a while, be patient.";
|
||||
break;
|
||||
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);
|
||||
$resource->update([
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$resource->started_at ??= now();
|
||||
$resource->save();
|
||||
|
||||
$message = "Database {$resource->name} started.";
|
||||
break;
|
||||
}
|
||||
@@ -422,6 +448,10 @@ class DeployController extends Controller
|
||||
if (is_null($application)) {
|
||||
return response()->json(['message' => 'Application not found'], 404);
|
||||
}
|
||||
|
||||
// Check authorization to view application deployments
|
||||
$this->authorize('view', $application);
|
||||
|
||||
$deployments = $application->deployments($skip, $take);
|
||||
|
||||
return response()->json($deployments);
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Project;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ProjectController extends Controller
|
||||
@@ -227,10 +229,10 @@ class ProjectController extends Controller
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255|required',
|
||||
'description' => 'string|nullable',
|
||||
]);
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
], ValidationPatterns::combinedMessages());
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
@@ -337,10 +339,10 @@ class ProjectController extends Controller
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255|nullable',
|
||||
'description' => 'string|nullable',
|
||||
]);
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => ValidationPatterns::nameRules(required: false),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
], ValidationPatterns::combinedMessages());
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
@@ -447,4 +449,255 @@ class ProjectController extends Controller
|
||||
|
||||
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.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ class ResourcesController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
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();
|
||||
$resources = collect();
|
||||
$resources->push($projects->pluck('applications')->flatten());
|
||||
|
||||
@@ -246,6 +246,8 @@ class ServicesController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('create', Service::class);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
@@ -351,7 +353,6 @@ class ServicesController extends Controller
|
||||
'value' => $generatedValue,
|
||||
'resourceable_id' => $service->id,
|
||||
'resourceable_type' => $service->getMorphClass(),
|
||||
'is_build_time' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
});
|
||||
@@ -377,14 +378,118 @@ class ServicesController extends Controller
|
||||
|
||||
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
|
||||
} 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;
|
||||
$result = $this->upsert_service($request, $service, $teamId);
|
||||
if ($result instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $result;
|
||||
$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',
|
||||
'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 {
|
||||
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);
|
||||
}
|
||||
|
||||
$this->authorize('view', $service);
|
||||
|
||||
$service = $service->load(['applications', 'databases']);
|
||||
|
||||
return response()->json($this->removeSensitiveData($service));
|
||||
@@ -508,12 +615,14 @@ class ServicesController extends Controller
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('delete', $service);
|
||||
|
||||
DeleteResourceJob::dispatch(
|
||||
resource: $service,
|
||||
deleteConfigurations: $request->query->get('delete_configurations', 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([
|
||||
@@ -550,7 +659,6 @@ class ServicesController extends Controller
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'],
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The service name.'],
|
||||
'description' => ['type' => 'string', 'description' => 'The service description.'],
|
||||
@@ -615,28 +723,16 @@ class ServicesController extends Controller
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$result = $this->upsert_service($request, $service, $teamId);
|
||||
if ($result instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
$this->authorize('update', $service);
|
||||
|
||||
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(), [
|
||||
'project_uuid' => 'string|required',
|
||||
'environment_name' => 'string|nullable',
|
||||
'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',
|
||||
'docker_compose_raw' => 'string|nullable',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -653,70 +749,42 @@ class ServicesController extends Controller
|
||||
'errors' => $errors,
|
||||
], 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;
|
||||
$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);
|
||||
if ($request->has('name')) {
|
||||
$service->name = $request->name;
|
||||
}
|
||||
$serverUuid = $request->server_uuid;
|
||||
$instantDeploy = $request->instant_deploy ?? false;
|
||||
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
if ($request->has('description')) {
|
||||
$service->description = $request->description;
|
||||
}
|
||||
$environment = $project->environments()->where('name', $environmentName)->first();
|
||||
if (! $environment) {
|
||||
$environment = $project->environments()->where('uuid', $environmentUuid)->first();
|
||||
if ($request->has('connect_to_docker_network')) {
|
||||
$service->connect_to_docker_network = $request->connect_to_docker_network;
|
||||
}
|
||||
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->parse();
|
||||
if ($instantDeploy) {
|
||||
if ($request->instant_deploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
||||
@@ -729,10 +797,10 @@ class ServicesController extends Controller
|
||||
return $domain;
|
||||
})->values();
|
||||
|
||||
return [
|
||||
return response()->json([
|
||||
'uuid' => $service->uuid,
|
||||
'domains' => $domains,
|
||||
];
|
||||
])->setStatusCode(200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -795,6 +863,8 @@ class ServicesController extends Controller
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('manageEnvironment', $service);
|
||||
|
||||
$envs = $service->environment_variables->map(function ($env) {
|
||||
$env->makeHidden([
|
||||
'application_id',
|
||||
@@ -848,7 +918,6 @@ class ServicesController extends Controller
|
||||
'key' => ['type' => 'string', 'description' => 'The key 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_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_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.'],
|
||||
@@ -899,10 +968,11 @@ class ServicesController extends Controller
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('manageEnvironment', $service);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
@@ -966,7 +1036,6 @@ class ServicesController extends Controller
|
||||
'key' => ['type' => 'string', 'description' => 'The key 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_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_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.'],
|
||||
@@ -1020,6 +1089,8 @@ class ServicesController extends Controller
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('manageEnvironment', $service);
|
||||
|
||||
$bulk_data = $request->get('data');
|
||||
if (! $bulk_data) {
|
||||
return response()->json(['message' => 'Bulk data is required.'], 400);
|
||||
@@ -1030,7 +1101,6 @@ class ServicesController extends Controller
|
||||
$validator = customApiValidator($item, [
|
||||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
@@ -1086,7 +1156,6 @@ class ServicesController extends Controller
|
||||
'key' => ['type' => 'string', 'description' => 'The key 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_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_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.'],
|
||||
@@ -1136,10 +1205,11 @@ class ServicesController extends Controller
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('manageEnvironment', $service);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'value' => 'string|nullable',
|
||||
'is_build_time' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
@@ -1238,6 +1308,8 @@ class ServicesController extends Controller
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('manageEnvironment', $service);
|
||||
|
||||
$env = EnvironmentVariable::where('uuid', $request->env_uuid)
|
||||
->where('resourceable_type', Service::class)
|
||||
->where('resourceable_id', $service->id)
|
||||
@@ -1317,6 +1389,9 @@ class ServicesController extends Controller
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('deploy', $service);
|
||||
|
||||
if (str($service->status)->contains('running')) {
|
||||
return response()->json(['message' => 'Service is already running.'], 400);
|
||||
}
|
||||
@@ -1395,6 +1470,9 @@ class ServicesController extends Controller
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('stop', $service);
|
||||
|
||||
if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) {
|
||||
return response()->json(['message' => 'Service is already stopped.'], 400);
|
||||
}
|
||||
@@ -1428,6 +1506,15 @@ class ServicesController extends Controller
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'latest',
|
||||
in: 'query',
|
||||
description: 'Pull latest images.',
|
||||
schema: new OA\Schema(
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
@@ -1473,7 +1560,11 @@ class ServicesController extends Controller
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
RestartService::dispatch($service);
|
||||
|
||||
$this->authorize('deploy', $service);
|
||||
|
||||
$pullLatest = $request->boolean('latest');
|
||||
RestartService::dispatch($service, $pullLatest);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
|
||||
@@ -144,7 +144,7 @@ class Controller extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
public function revoke_invitation()
|
||||
public function revokeInvitation()
|
||||
{
|
||||
$invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail();
|
||||
$user = User::whereEmail($invitation->email)->firstOrFail();
|
||||
|
||||
@@ -143,12 +143,13 @@ class Bitbucket extends Controller
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
ApplicationPreview::create([
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'bitbucket',
|
||||
'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(
|
||||
|
||||
@@ -175,12 +175,13 @@ class Gitea extends Controller
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
ApplicationPreview::create([
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'gitea',
|
||||
'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(
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\ApplicationPullRequestUpdateJob;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Jobs\GithubAppPermissionJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
@@ -78,6 +79,7 @@ class Github extends Controller
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$branch = data_get($payload, 'pull_request.head.ref');
|
||||
$base_branch = data_get($payload, 'pull_request.base.ref');
|
||||
$author_association = data_get($payload, 'pull_request.author_association');
|
||||
}
|
||||
if (! $branch) {
|
||||
return response('Nothing to do. No branch found in the request.');
|
||||
@@ -95,150 +97,168 @@ class Github extends Controller
|
||||
return response("Nothing to do. No applications found with branch '$base_branch'.");
|
||||
}
|
||||
}
|
||||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
|
||||
$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.',
|
||||
]);
|
||||
$applicationsByServer = $applications->groupBy(function ($app) {
|
||||
return $app->destination->server_id;
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
$isFunctional = $application->destination->server->isFunctional();
|
||||
if (! $isFunctional) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Server is not functional.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($x_github_event === 'push') {
|
||||
if ($application->isDeployable()) {
|
||||
$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,
|
||||
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 {
|
||||
$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 {
|
||||
foreach ($applicationsByServer as $serverId => $serverApplications) {
|
||||
foreach ($serverApplications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
|
||||
$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' => 'Deployments disabled.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'message' => 'Invalid signature.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
|
||||
if ($application->isPRDeployable()) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$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,
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
$isFunctional = $application->destination->server->isFunctional();
|
||||
if (! $isFunctional) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Server is not functional.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($x_github_event === 'push') {
|
||||
if ($application->isDeployable()) {
|
||||
$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,
|
||||
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'],
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
ApplicationPreview::create([
|
||||
'git_type' => 'github',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
$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 {
|
||||
$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([
|
||||
'status' => 'failed',
|
||||
'message' => 'Deployments disabled.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
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,
|
||||
]);
|
||||
|
||||
$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') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$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,
|
||||
'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([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
'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) {
|
||||
DeleteResourceJob::dispatch($found);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment closed.',
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment queued.',
|
||||
'status' => 'failed',
|
||||
'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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,6 +346,7 @@ class Github extends Controller
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$branch = data_get($payload, 'pull_request.head.ref');
|
||||
$base_branch = data_get($payload, 'pull_request.base.ref');
|
||||
$author_association = data_get($payload, 'pull_request.author_association');
|
||||
}
|
||||
if (! $id || ! $branch) {
|
||||
return response('Nothing to do. No id or branch found.');
|
||||
@@ -343,127 +364,147 @@ class Github extends Controller
|
||||
return response("Nothing to do. No applications found with branch '$base_branch'.");
|
||||
}
|
||||
}
|
||||
foreach ($applications as $application) {
|
||||
$isFunctional = $application->destination->server->isFunctional();
|
||||
if (! $isFunctional) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Server is not functional.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
$applicationsByServer = $applications->groupBy(function ($app) {
|
||||
return $app->destination->server_id;
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($x_github_event === 'push') {
|
||||
if ($application->isDeployable()) {
|
||||
$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 {
|
||||
foreach ($applicationsByServer as $serverId => $serverApplications) {
|
||||
foreach ($serverApplications as $application) {
|
||||
$isFunctional = $application->destination->server->isFunctional();
|
||||
if (! $isFunctional) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Deployments disabled.',
|
||||
'message' => 'Server is not functional.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
|
||||
if ($application->isPRDeployable()) {
|
||||
$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,
|
||||
if ($x_github_event === 'push') {
|
||||
if ($application->isDeployable()) {
|
||||
$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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
$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') {
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Deployments disabled.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
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([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => $result['message'],
|
||||
'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);
|
||||
|
||||
DeleteResourceJob::dispatch($found);
|
||||
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment closed.',
|
||||
]);
|
||||
} else {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment queued.',
|
||||
'status' => 'failed',
|
||||
'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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,12 +202,13 @@ class Gitlab extends Controller
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
ApplicationPreview::create([
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'gitlab',
|
||||
'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(
|
||||
|
||||
@@ -4,15 +4,12 @@ namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\StripeProcessJob;
|
||||
use App\Models\Webhook;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Stripe extends Controller
|
||||
{
|
||||
protected $webhook;
|
||||
|
||||
public function events(Request $request)
|
||||
{
|
||||
try {
|
||||
@@ -40,19 +37,10 @@ class Stripe extends Controller
|
||||
|
||||
return response('Webhook received. Cool cool cool cool cool.', 200);
|
||||
}
|
||||
$this->webhook = Webhook::create([
|
||||
'type' => 'stripe',
|
||||
'payload' => $request->getContent(),
|
||||
]);
|
||||
StripeProcessJob::dispatch($event);
|
||||
|
||||
return response('Webhook received. Cool cool cool cool cool.', 200);
|
||||
} catch (Exception $e) {
|
||||
$this->webhook->update([
|
||||
'status' => 'failed',
|
||||
'failure_reason' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response($e->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,5 +71,8 @@ class Kernel extends HttpKernel
|
||||
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
|
||||
'api.ability' => \App\Http\Middleware\ApiAbility::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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,12 +18,18 @@ class ApiAllowed
|
||||
return response()->json(['success' => true, 'message' => 'API is disabled.'], 403);
|
||||
}
|
||||
|
||||
if (! isDev()) {
|
||||
if ($settings->allowed_ips) {
|
||||
$allowedIps = explode(',', $settings->allowed_ips);
|
||||
if (! in_array($request->ip(), $allowedIps)) {
|
||||
return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403);
|
||||
}
|
||||
if ($settings->allowed_ips) {
|
||||
// Check for special case: 0.0.0.0 means allow all
|
||||
if (trim($settings->allowed_ips) === '0.0.0.0') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
29
app/Http/Middleware/CanAccessTerminal.php
Normal file
29
app/Http/Middleware/CanAccessTerminal.php
Normal 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);
|
||||
}
|
||||
}
|
||||
26
app/Http/Middleware/CanCreateResources.php
Normal file
26
app/Http/Middleware/CanCreateResources.php
Normal 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);
|
||||
}
|
||||
}
|
||||
75
app/Http/Middleware/CanUpdateResource.php
Normal file
75
app/Http/Middleware/CanUpdateResource.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,6 @@ class VerifyCsrfToken extends Middleware
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
//
|
||||
'webhooks/*',
|
||||
];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user