Merge pull request #5815 from coollabsio/next

v4.0.0-beta.419
This commit is contained in:
Andras Bacsai
2025-06-16 13:22:06 +02:00
committed by GitHub
323 changed files with 14044 additions and 4456 deletions

292
.cursor/rules/README.mdc Normal file
View File

@@ -0,0 +1,292 @@
---
description:
globs:
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
### 🗄️ 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.

View File

@@ -0,0 +1,474 @@
---
description:
globs:
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',
]);
});
```

View File

@@ -0,0 +1,368 @@
---
description:
globs:
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

View 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

View File

@@ -0,0 +1,306 @@
---
description:
globs:
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

View File

@@ -0,0 +1,310 @@
---
description:
globs:
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

View 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.*

View File

@@ -0,0 +1,653 @@
---
description:
globs:
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

View File

@@ -0,0 +1,319 @@
---
description:
globs:
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
## 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

View File

@@ -0,0 +1,161 @@
---
description:
globs:
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**

View File

@@ -0,0 +1,786 @@
---
description:
globs:
alwaysApply: false
---
# Coolify Security Architecture & Patterns
## Security Philosophy
Coolify implements **defense-in-depth security** with multiple layers of protection including authentication, authorization, encryption, network isolation, and secure deployment practices.
## Authentication Architecture
### Multi-Provider Authentication
- **[Laravel Fortify](mdc:config/fortify.php)** - Core authentication scaffolding (4.9KB, 149 lines)
- **[Laravel Sanctum](mdc:config/sanctum.php)** - API token authentication (2.4KB, 69 lines)
- **[Laravel Socialite](mdc:config/services.php)** - OAuth provider integration
### OAuth Integration
- **[OauthSetting.php](mdc:app/Models/OauthSetting.php)** - OAuth provider configurations
- **Supported Providers**:
- Google OAuth
- Microsoft Azure AD
- Authentik
- GitHub (via GitHub Apps)
- GitLab
### Authentication Models
```php
// User authentication with team-based access
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [
'name', 'email', 'password'
];
protected $hidden = [
'password', 'remember_token'
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class)
->withPivot('role')
->withTimestamps();
}
public function currentTeam(): BelongsTo
{
return $this->belongsTo(Team::class, 'current_team_id');
}
}
```
## Authorization & Access Control
### Team-Based Multi-Tenancy
- **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure (8.9KB, 308 lines)
- **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Secure team collaboration
- **Role-based permissions** within teams
- **Resource isolation** by team ownership
### Authorization Patterns
```php
// Team-scoped authorization middleware
class EnsureTeamAccess
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$teamId = $request->route('team');
if (!$user->teams->contains('id', $teamId)) {
abort(403, 'Access denied to team resources');
}
// Set current team context
$user->switchTeam($teamId);
return $next($request);
}
}
// Resource-level authorization policies
class ApplicationPolicy
{
public function view(User $user, Application $application): bool
{
return $user->teams->contains('id', $application->team_id);
}
public function deploy(User $user, Application $application): bool
{
return $this->view($user, $application) &&
$user->hasTeamPermission($application->team_id, 'deploy');
}
public function delete(User $user, Application $application): bool
{
return $this->view($user, $application) &&
$user->hasTeamRole($application->team_id, 'admin');
}
}
```
### Global Scopes for Data Isolation
```php
// Automatic team-based filtering
class Application extends Model
{
protected static function booted(): void
{
static::addGlobalScope('team', function (Builder $builder) {
if (auth()->check() && auth()->user()->currentTeam) {
$builder->whereHas('environment.project', function ($query) {
$query->where('team_id', auth()->user()->currentTeam->id);
});
}
});
}
}
```
## API Security
### Token-Based Authentication
```php
// Sanctum API token management
class PersonalAccessToken extends Model
{
protected $fillable = [
'name', 'token', 'abilities', 'expires_at'
];
protected $casts = [
'abilities' => 'array',
'expires_at' => 'datetime',
'last_used_at' => 'datetime',
];
public function tokenable(): MorphTo
{
return $this->morphTo();
}
public function hasAbility(string $ability): bool
{
return in_array('*', $this->abilities) ||
in_array($ability, $this->abilities);
}
}
```
### API Rate Limiting
```php
// Rate limiting configuration
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('deployments', function (Request $request) {
return Limit::perMinute(10)->by($request->user()->id);
});
RateLimiter::for('webhooks', function (Request $request) {
return Limit::perMinute(100)->by($request->ip());
});
```
### API Input Validation
```php
// Comprehensive input validation
class StoreApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Application::class);
}
public function rules(): array
{
return [
'name' => 'required|string|max:255|regex:/^[a-zA-Z0-9\-_]+$/',
'git_repository' => 'required|url|starts_with:https://',
'git_branch' => 'required|string|max:100|regex:/^[a-zA-Z0-9\-_\/]+$/',
'server_id' => 'required|exists:servers,id',
'environment_id' => 'required|exists:environments,id',
'environment_variables' => 'array',
'environment_variables.*' => 'string|max:1000',
];
}
public function prepareForValidation(): void
{
$this->merge([
'name' => strip_tags($this->name),
'git_repository' => filter_var($this->git_repository, FILTER_SANITIZE_URL),
]);
}
}
```
## SSH Security
### Private Key Management
- **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - Secure SSH key storage (6.5KB, 247 lines)
- **Encrypted key storage** in database
- **Key rotation** capabilities
- **Access logging** for key usage
### SSH Connection Security
```php
class SshConnection
{
private string $host;
private int $port;
private string $username;
private PrivateKey $privateKey;
public function __construct(Server $server)
{
$this->host = $server->ip;
$this->port = $server->port;
$this->username = $server->user;
$this->privateKey = $server->privateKey;
}
public function connect(): bool
{
$connection = ssh2_connect($this->host, $this->port);
if (!$connection) {
throw new SshConnectionException('Failed to connect to server');
}
// Use private key authentication
$privateKeyContent = decrypt($this->privateKey->private_key);
$publicKeyContent = decrypt($this->privateKey->public_key);
if (!ssh2_auth_pubkey_file($connection, $this->username, $publicKeyContent, $privateKeyContent)) {
throw new SshAuthenticationException('SSH authentication failed');
}
return true;
}
public function execute(string $command): string
{
// Sanitize command to prevent injection
$command = escapeshellcmd($command);
$stream = ssh2_exec($this->connection, $command);
if (!$stream) {
throw new SshExecutionException('Failed to execute command');
}
return stream_get_contents($stream);
}
}
```
## Container Security
### Docker Security Patterns
```php
class DockerSecurityService
{
public function createSecureContainer(Application $application): array
{
return [
'image' => $this->validateImageName($application->docker_image),
'user' => '1000:1000', // Non-root user
'read_only' => true,
'no_new_privileges' => true,
'security_opt' => [
'no-new-privileges:true',
'apparmor:docker-default'
],
'cap_drop' => ['ALL'],
'cap_add' => ['CHOWN', 'SETUID', 'SETGID'], // Minimal capabilities
'tmpfs' => [
'/tmp' => 'rw,noexec,nosuid,size=100m',
'/var/tmp' => 'rw,noexec,nosuid,size=50m'
],
'ulimits' => [
'nproc' => 1024,
'nofile' => 1024
]
];
}
private function validateImageName(string $image): string
{
// Validate image name against allowed registries
$allowedRegistries = ['docker.io', 'ghcr.io', 'quay.io'];
$parser = new DockerImageParser();
$parsed = $parser->parse($image);
if (!in_array($parsed['registry'], $allowedRegistries)) {
throw new SecurityException('Image registry not allowed');
}
return $image;
}
}
```
### Network Isolation
```yaml
# Docker Compose security configuration
version: '3.8'
services:
app:
image: ${APP_IMAGE}
networks:
- app-network
security_opt:
- no-new-privileges:true
- apparmor:docker-default
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=100m
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
networks:
app-network:
driver: bridge
internal: true
ipam:
config:
- subnet: 172.20.0.0/16
```
## SSL/TLS Security
### Certificate Management
- **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate automation
- **Let's Encrypt** integration for free certificates
- **Automatic renewal** and monitoring
- **Custom certificate** upload support
### SSL Configuration
```php
class SslCertificateService
{
public function generateCertificate(Application $application): SslCertificate
{
$domains = $this->validateDomains($application->getAllDomains());
$certificate = SslCertificate::create([
'application_id' => $application->id,
'domains' => $domains,
'provider' => 'letsencrypt',
'status' => 'pending'
]);
// Generate certificate using ACME protocol
$acmeClient = new AcmeClient();
$certData = $acmeClient->generateCertificate($domains);
$certificate->update([
'certificate' => encrypt($certData['certificate']),
'private_key' => encrypt($certData['private_key']),
'chain' => encrypt($certData['chain']),
'expires_at' => $certData['expires_at'],
'status' => 'active'
]);
return $certificate;
}
private function validateDomains(array $domains): array
{
foreach ($domains as $domain) {
if (!filter_var($domain, FILTER_VALIDATE_DOMAIN)) {
throw new InvalidDomainException("Invalid domain: {$domain}");
}
// Check domain ownership
if (!$this->verifyDomainOwnership($domain)) {
throw new DomainOwnershipException("Domain ownership verification failed: {$domain}");
}
}
return $domains;
}
}
```
## Environment Variable Security
### Secure Configuration Management
```php
class EnvironmentVariable extends Model
{
protected $fillable = [
'key', 'value', 'is_secret', 'application_id'
];
protected $casts = [
'is_secret' => 'boolean',
'value' => 'encrypted' // Automatic encryption for sensitive values
];
public function setValueAttribute($value): void
{
// Automatically encrypt sensitive environment variables
if ($this->isSensitiveKey($this->key)) {
$this->attributes['value'] = encrypt($value);
$this->attributes['is_secret'] = true;
} else {
$this->attributes['value'] = $value;
}
}
public function getValueAttribute($value): string
{
if ($this->is_secret) {
return decrypt($value);
}
return $value;
}
private function isSensitiveKey(string $key): bool
{
$sensitivePatterns = [
'PASSWORD', 'SECRET', 'KEY', 'TOKEN', 'API_KEY',
'DATABASE_URL', 'REDIS_URL', 'PRIVATE', 'CREDENTIAL',
'AUTH', 'CERTIFICATE', 'ENCRYPTION', 'SALT', 'HASH',
'OAUTH', 'JWT', 'BEARER', 'ACCESS', 'REFRESH'
];
foreach ($sensitivePatterns as $pattern) {
if (str_contains(strtoupper($key), $pattern)) {
return true;
}
}
return false;
}
}
```
## Webhook Security
### Webhook Signature Verification
```php
class WebhookSecurityService
{
public function verifyGitHubSignature(Request $request, string $secret): bool
{
$signature = $request->header('X-Hub-Signature-256');
if (!$signature) {
return false;
}
$expectedSignature = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret);
return hash_equals($expectedSignature, $signature);
}
public function verifyGitLabSignature(Request $request, string $secret): bool
{
$signature = $request->header('X-Gitlab-Token');
return hash_equals($secret, $signature);
}
public function validateWebhookPayload(array $payload): array
{
// Sanitize and validate webhook payload
$validator = Validator::make($payload, [
'repository.clone_url' => 'required|url|starts_with:https://',
'ref' => 'required|string|max:255',
'head_commit.id' => 'required|string|size:40', // Git SHA
'head_commit.message' => 'required|string|max:1000'
]);
if ($validator->fails()) {
throw new InvalidWebhookPayloadException('Invalid webhook payload');
}
return $validator->validated();
}
}
```
## Input Sanitization & Validation
### XSS Prevention
```php
class SecurityMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// Sanitize input data
$input = $request->all();
$sanitized = $this->sanitizeInput($input);
$request->merge($sanitized);
return $next($request);
}
private function sanitizeInput(array $input): array
{
foreach ($input as $key => $value) {
if (is_string($value)) {
// Remove potentially dangerous HTML tags
$input[$key] = strip_tags($value, '<p><br><strong><em>');
// Escape special characters
$input[$key] = htmlspecialchars($input[$key], ENT_QUOTES, 'UTF-8');
} elseif (is_array($value)) {
$input[$key] = $this->sanitizeInput($value);
}
}
return $input;
}
}
```
### SQL Injection Prevention
```php
// Always use parameterized queries and Eloquent ORM
class ApplicationRepository
{
public function findByName(string $name): ?Application
{
// Safe: Uses parameter binding
return Application::where('name', $name)->first();
}
public function searchApplications(string $query): Collection
{
// Safe: Eloquent handles escaping
return Application::where('name', 'LIKE', "%{$query}%")
->orWhere('description', 'LIKE', "%{$query}%")
->get();
}
// NEVER do this - vulnerable to SQL injection
// public function unsafeSearch(string $query): Collection
// {
// return DB::select("SELECT * FROM applications WHERE name LIKE '%{$query}%'");
// }
}
```
## Audit Logging & Monitoring
### Activity Logging
```php
// Using Spatie Activity Log package
class Application extends Model
{
use LogsActivity;
protected static $logAttributes = [
'name', 'git_repository', 'git_branch', 'fqdn'
];
protected static $logOnlyDirty = true;
public function getDescriptionForEvent(string $eventName): string
{
return "Application {$this->name} was {$eventName}";
}
}
// Custom security events
class SecurityEventLogger
{
public function logFailedLogin(string $email, string $ip): void
{
activity('security')
->withProperties([
'email' => $email,
'ip' => $ip,
'user_agent' => request()->userAgent()
])
->log('Failed login attempt');
}
public function logSuspiciousActivity(User $user, string $activity): void
{
activity('security')
->causedBy($user)
->withProperties([
'activity' => $activity,
'ip' => request()->ip(),
'timestamp' => now()
])
->log('Suspicious activity detected');
}
}
```
### Security Monitoring
```php
class SecurityMonitoringService
{
public function detectAnomalousActivity(User $user): bool
{
// Check for unusual login patterns
$recentLogins = $user->activities()
->where('description', 'like', '%login%')
->where('created_at', '>=', now()->subHours(24))
->get();
// Multiple failed attempts
$failedAttempts = $recentLogins->where('description', 'Failed login attempt')->count();
if ($failedAttempts > 5) {
$this->triggerSecurityAlert($user, 'Multiple failed login attempts');
return true;
}
// Login from new location
$uniqueIps = $recentLogins->pluck('properties.ip')->unique();
if ($uniqueIps->count() > 3) {
$this->triggerSecurityAlert($user, 'Login from multiple IP addresses');
return true;
}
return false;
}
private function triggerSecurityAlert(User $user, string $reason): void
{
// Send security notification
$user->notify(new SecurityAlertNotification($reason));
// Log security event
activity('security')
->causedBy($user)
->withProperties(['reason' => $reason])
->log('Security alert triggered');
}
}
```
## Backup Security
### Encrypted Backups
```php
class SecureBackupService
{
public function createEncryptedBackup(ScheduledDatabaseBackup $backup): void
{
$database = $backup->database;
$dumpPath = $this->createDatabaseDump($database);
// Encrypt backup file
$encryptedPath = $this->encryptFile($dumpPath, $backup->encryption_key);
// Upload to secure storage
$this->uploadToSecureStorage($encryptedPath, $backup->s3Storage);
// Clean up local files
unlink($dumpPath);
unlink($encryptedPath);
}
private function encryptFile(string $filePath, string $key): string
{
$data = file_get_contents($filePath);
$encryptedData = encrypt($data, $key);
$encryptedPath = $filePath . '.encrypted';
file_put_contents($encryptedPath, $encryptedData);
return $encryptedPath;
}
}
```
## Security Headers & CORS
### Security Headers Configuration
```php
// Security headers middleware
class SecurityHeadersMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
if ($request->secure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}
```
### CORS Configuration
```php
// CORS configuration for API endpoints
return [
'paths' => ['api/*', 'webhooks/*'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
'allowed_origins' => [
'https://app.coolify.io',
'https://*.coolify.io'
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];
```
## Security Testing
### Security Test Patterns
```php
// Security-focused tests
test('prevents SQL injection in search', function () {
$user = User::factory()->create();
$maliciousInput = "'; DROP TABLE applications; --";
$response = $this->actingAs($user)
->getJson("/api/v1/applications?search={$maliciousInput}");
$response->assertStatus(200);
// Verify applications table still exists
expect(Schema::hasTable('applications'))->toBeTrue();
});
test('prevents XSS in application names', function () {
$user = User::factory()->create();
$xssPayload = '<script>alert("xss")</script>';
$response = $this->actingAs($user)
->postJson('/api/v1/applications', [
'name' => $xssPayload,
'git_repository' => 'https://github.com/user/repo.git',
'server_id' => Server::factory()->create()->id
]);
$response->assertStatus(422);
});
test('enforces team isolation', 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)
->getJson("/api/v1/applications/{$application->id}");
$response->assertStatus(403);
});
```

View File

@@ -0,0 +1,72 @@
---
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
- **Example Pattern Recognition:**
```typescript
// If you see repeated patterns like:
const data = await prisma.user.findMany({
select: { id: true, email: true },
where: { status: 'ACTIVE' }
});
// Consider adding to [prisma.mdc](mdc:.cursor/rules/prisma.mdc):
// - Standard select fields
// - Common where conditions
// - Performance optimization patterns
```
- **Rule Quality Checks:**
- 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.

View File

@@ -0,0 +1,250 @@
---
description:
globs:
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
- 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

View File

@@ -0,0 +1,606 @@
---
description:
globs:
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.
## 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);
});
```

2
.gitignore vendored
View File

@@ -36,4 +36,4 @@ scripts/load-test/*
.env.dusk.local .env.dusk.local
docker/coolify-realtime/node_modules docker/coolify-realtime/node_modules
.DS_Store .DS_Store
Changelog.md CHANGELOG.md

View File

@@ -3856,7 +3856,7 @@ All notable changes to this project will be documented in this file.
- Bitbucket manual deployments - Bitbucket manual deployments
- Webhooks for multiple apps - Webhooks for multiple apps
- Unhealthy deployments should be failed - Unhealthy deployments should be failed
- Add env variables for wordpress template without database - Add env variables for WordPress template without database
- Service deletion function - Service deletion function
- Service deletion fix - Service deletion fix
- Dns validation + duplicated fqdns - Dns validation + duplicated fqdns
@@ -4365,7 +4365,7 @@ All notable changes to this project will be documented in this file.
### 💼 Other ### 💼 Other
- Wordpress - WordPress
## [4.0.0-beta.89] - 2023-10-17 ## [4.0.0-beta.89] - 2023-10-17
@@ -6280,7 +6280,7 @@ All notable changes to this project will be documented in this file.
- Gitpod updates - Gitpod updates
- Gitpod - Gitpod
- Gitpod - Gitpod
- Wordpress FTP permission issues - WordPress FTP permission issues
- GitLab search fields - GitLab search fields
- GitHub App button - GitHub App button
- GitLab loop on misconfigured source - GitLab loop on misconfigured source
@@ -6644,7 +6644,7 @@ All notable changes to this project will be documented in this file.
- Switch from bitnami/redis to normal redis - Switch from bitnami/redis to normal redis
- Use redis-alpine - Use redis-alpine
- Wordpress extra config - WordPress extra config
- Stop sFTP connection on wp stop - Stop sFTP connection on wp stop
- Change user's id in sftp wp instance - Change user's id in sftp wp instance
- Use arm based certbot on arm - Use arm based certbot on arm
@@ -6789,7 +6789,7 @@ All notable changes to this project will be documented in this file.
### 🚀 Features ### 🚀 Features
- Wordpress on-demand SFTP - WordPress on-demand SFTP
- Finalize on-demand sftp for wp - Finalize on-demand sftp for wp
- PHP Composer support - PHP Composer support
- Working on-demand sftp to wp data - Working on-demand sftp to wp data

View File

@@ -3,6 +3,7 @@
namespace App\Actions\Application; namespace App\Actions\Application;
use App\Actions\Server\CleanupDocker; use App\Actions\Server\CleanupDocker;
use App\Events\ServiceStatusChanged;
use App\Models\Application; use App\Models\Application;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
@@ -14,30 +15,46 @@ class StopApplication
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true) public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true)
{ {
try { $servers = collect([$application->destination->server]);
$server = $application->destination->server; if ($application?->additional_servers?->count() > 0) {
if (! $server->isFunctional()) { $servers = $servers->merge($application->additional_servers);
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();
} }
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, true);
}
} catch (\Exception $e) {
return $e->getMessage();
}
}
ServiceStatusChanged::dispatch($application->environment->project->team->id);
} }
} }

View File

@@ -25,7 +25,10 @@ class StopApplicationOneServer
$containerName = data_get($container, 'Names'); $containerName = data_get($container, 'Names');
if ($containerName) { if ($containerName) {
instant_remote_process( instant_remote_process(
["docker rm -f {$containerName}"], [
"docker stop --time=30 $containerName",
"docker rm -f $containerName",
],
$server $server
); );
} }

View File

@@ -104,14 +104,11 @@ class RunRemoteProcess
$this->activity->save(); $this->activity->save();
if ($this->call_event_on_finish) { if ($this->call_event_on_finish) {
try { try {
if ($this->call_event_data) { $eventClass = "App\\Events\\$this->call_event_on_finish";
event(resolve("App\\Events\\$this->call_event_on_finish", [ if (! is_null($this->call_event_data)) {
'data' => $this->call_event_data, event(new $eventClass($this->call_event_data));
]));
} else { } else {
event(resolve("App\\Events\\$this->call_event_on_finish", [ event(new $eventClass($this->activity->causer_id));
'userId' => $this->activity->causer_id,
]));
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Error calling event: '.$e->getMessage()); Log::error('Error calling event: '.$e->getMessage());

View File

@@ -44,6 +44,9 @@ class StartDatabaseProxy
}; };
$configuration_dir = database_proxy_dir($database->uuid); $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 $nginxconf = <<<EOF
user nginx; user nginx;
worker_processes auto; worker_processes auto;
@@ -59,19 +62,10 @@ class StartDatabaseProxy
proxy_pass $containerName:$internalPort; proxy_pass $containerName:$internalPort;
} }
} }
EOF;
$dockerfile = <<< 'EOF'
FROM nginx:stable-alpine
COPY nginx.conf /etc/nginx/nginx.conf
EOF; EOF;
$docker_compose = [ $docker_compose = [
'services' => [ 'services' => [
$proxyContainerName => [ $proxyContainerName => [
'build' => [
'context' => $configuration_dir,
'dockerfile' => 'Dockerfile',
],
'image' => 'nginx:stable-alpine', 'image' => 'nginx:stable-alpine',
'container_name' => $proxyContainerName, 'container_name' => $proxyContainerName,
'restart' => RESTART_MODE, 'restart' => RESTART_MODE,
@@ -81,6 +75,13 @@ class StartDatabaseProxy
'networks' => [ 'networks' => [
$network, $network,
], ],
'volumes' => [
[
'type' => 'bind',
'source' => "$configuration_dir/nginx.conf",
'target' => '/etc/nginx/nginx.conf',
],
],
'healthcheck' => [ 'healthcheck' => [
'test' => [ 'test' => [
'CMD-SHELL', 'CMD-SHELL',
@@ -103,15 +104,13 @@ class StartDatabaseProxy
]; ];
$dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2)); $dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2));
$nginxconf_base64 = base64_encode($nginxconf); $nginxconf_base64 = base64_encode($nginxconf);
$dockerfile_base64 = base64_encode($dockerfile);
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
instant_remote_process([ instant_remote_process([
"mkdir -p $configuration_dir", "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 '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /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} pull",
"docker compose --project-directory {$configuration_dir} up --build -d", "docker compose --project-directory {$configuration_dir} up -d",
], $server); ], $server);
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Actions\Database; namespace App\Actions\Database;
use App\Actions\Server\CleanupDocker; use App\Actions\Server\CleanupDocker;
use App\Events\ServiceStatusChanged;
use App\Models\StandaloneClickhouse; use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly; use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb; use App\Models\StandaloneKeydb;
@@ -19,23 +20,29 @@ class StopDatabase
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 $isDeleteOperation = false, bool $dockerCleanup = true)
{ {
$server = $database->destination->server; try {
if (! $server->isFunctional()) { $server = $database->destination->server;
return 'Server is not functional'; if (! $server->isFunctional()) {
} return 'Server is not functional';
}
$this->stopContainer($database, $database->uuid, 30);
$this->stopContainer($database, $database->uuid, 30);
if ($isDeleteOperation) {
if ($dockerCleanup) { if ($dockerCleanup) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, true);
} }
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 private function stopContainer($database, string $containerName, int $timeout = 30): void

View File

@@ -4,6 +4,7 @@ namespace App\Actions\Docker;
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Shared\ComplexStatusCheck; use App\Actions\Shared\ComplexStatusCheck;
use App\Events\ServiceChecked;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Models\Server; use App\Models\Server;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
@@ -273,24 +274,13 @@ class GetContainersStatus
if (str($application->status)->startsWith('exited')) { if (str($application->status)->startsWith('exited')) {
continue; continue;
} }
$application->update(['status' => 'exited']);
$name = data_get($application, 'name'); // Only protection: If no containers at all, Docker query might have failed
$fqdn = data_get($application, 'fqdn'); if ($this->containers->isEmpty()) {
continue;
$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;
} }
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); $application->update(['status' => 'exited']);
} }
$notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews); $notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews);
foreach ($notRunningApplicationPreviews as $previewId) { foreach ($notRunningApplicationPreviews as $previewId) {
@@ -298,24 +288,13 @@ class GetContainersStatus
if (str($preview->status)->startsWith('exited')) { if (str($preview->status)->startsWith('exited')) {
continue; continue;
} }
$preview->update(['status' => 'exited']);
$name = data_get($preview, 'name'); // Only protection: If no containers at all, Docker query might have failed
$fqdn = data_get($preview, 'fqdn'); if ($this->containers->isEmpty()) {
continue;
$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;
} }
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); $preview->update(['status' => 'exited']);
} }
$notRunningDatabases = $databases->pluck('id')->diff($foundDatabases); $notRunningDatabases = $databases->pluck('id')->diff($foundDatabases);
foreach ($notRunningDatabases as $database) { foreach ($notRunningDatabases as $database) {
@@ -341,5 +320,6 @@ class GetContainersStatus
} }
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
} }
ServiceChecked::dispatch($this->server->team->id);
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Actions\Proxy; namespace App\Actions\Proxy;
use App\Models\Server; use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
class CheckConfiguration class CheckConfiguration
@@ -28,6 +29,8 @@ class CheckConfiguration
throw new \Exception('Could not generate proxy configuration'); throw new \Exception('Could not generate proxy configuration');
} }
ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration);
return $proxy_configuration; return $proxy_configuration;
} }
} }

View File

@@ -5,6 +5,7 @@ namespace App\Actions\Proxy;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Models\Server; use App\Models\Server;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
@@ -67,22 +68,12 @@ class CheckProxy
} }
$portsToCheck = ['80', '443']; $portsToCheck = ['80', '443'];
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 { try {
if ($server->proxyType() !== ProxyTypes::NONE->value) { if ($server->proxyType() !== ProxyTypes::NONE->value) {
$proxyCompose = CheckConfiguration::run($server); $proxyCompose = CheckConfiguration::run($server);
if (isset($proxyCompose)) { if (isset($proxyCompose)) {
$yaml = Yaml::parse($proxyCompose); $yaml = Yaml::parse($proxyCompose);
$portsToCheck = []; $configPorts = [];
if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { if ($server->proxyType() === ProxyTypes::TRAEFIK->value) {
$ports = data_get($yaml, 'services.traefik.ports'); $ports = data_get($yaml, 'services.traefik.ports');
} elseif ($server->proxyType() === ProxyTypes::CADDY->value) { } elseif ($server->proxyType() === ProxyTypes::CADDY->value) {
@@ -90,9 +81,11 @@ class CheckProxy
} }
if (isset($ports)) { if (isset($ports)) {
foreach ($ports as $port) { 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 { } else {
$portsToCheck = []; $portsToCheck = [];
@@ -103,11 +96,188 @@ class CheckProxy
if (count($portsToCheck) === 0) { if (count($portsToCheck) === 0) {
return false; 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; 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 * Smart port checker that handles dual-stack configurations
* Returns true only if there's a real port conflict (not just dual-stack) * 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 // Run the actual check commands
$output = instant_remote_process($set['check'], $server, true); $output = instant_remote_process($set['check'], $server, true);
// Parse the output lines // Parse the output lines
$lines = explode("\n", trim($output)); $lines = explode("\n", trim($output));
// Get the detailed output and listener count // Get the detailed output and listener count
$details = trim($lines[0] ?? ''); $details = trim(implode("\n", array_slice($lines, 0, -1)));
$count = intval(trim($lines[1] ?? '0')); $count = intval(trim($lines[count($lines) - 1] ?? '0'));
// If no listeners or empty result, port is free // If no listeners or empty result, port is free
if ($count == 0 || empty($details)) { if ($count == 0 || empty($details)) {
return false; return false;

View File

@@ -3,7 +3,8 @@
namespace App\Actions\Proxy; namespace App\Actions\Proxy;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Events\ProxyStarted; use App\Events\ProxyStatusChanged;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server; use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Models\Activity;
@@ -28,6 +29,7 @@ class StartProxy
$docker_compose_yml_base64 = base64_encode($configuration); $docker_compose_yml_base64 = base64_encode($configuration);
$server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); $server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
$server->save(); $server->save();
if ($server->isSwarmManager()) { if ($server->isSwarmManager()) {
$commands = $commands->merge([ $commands = $commands->merge([
"mkdir -p $proxy_path/dynamic", "mkdir -p $proxy_path/dynamic",
@@ -57,20 +59,22 @@ class StartProxy
" echo 'Successfully stopped and removed existing coolify-proxy.'", " echo 'Successfully stopped and removed existing coolify-proxy.'",
'fi', 'fi',
"echo 'Starting coolify-proxy.'", "echo 'Starting coolify-proxy.'",
'docker compose up -d', 'docker compose up -d --wait --remove-orphans',
"echo 'Successfully started coolify-proxy.'", "echo 'Successfully started coolify-proxy.'",
]); ]);
$commands = $commands->merge(connectProxyToNetworks($server)); $commands = $commands->merge(connectProxyToNetworks($server));
} }
$server->proxy->set('status', 'starting');
$server->save();
ProxyStatusChangedUI::dispatch($server->team_id);
if ($async) { if ($async) {
return remote_process($commands, $server, callEventOnFinish: 'ProxyStarted', callEventData: $server); return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id);
} else { } else {
instant_remote_process($commands, $server); instant_remote_process($commands, $server);
$server->proxy->set('status', 'running');
$server->proxy->set('type', $proxyType); $server->proxy->set('type', $proxyType);
$server->save(); $server->save();
ProxyStarted::dispatch($server); ProxyStatusChanged::dispatch($server->id);
return 'OK'; return 'OK';
} }

View File

@@ -2,7 +2,10 @@
namespace App\Actions\Proxy; namespace App\Actions\Proxy;
use App\Events\ProxyStatusChanged;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server; use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
class StopProxy class StopProxy
@@ -13,6 +16,9 @@ class StopProxy
{ {
try { try {
$containerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; $containerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
$server->proxy->status = 'stopping';
$server->save();
ProxyStatusChangedUI::dispatch($server->team_id);
instant_remote_process(command: [ instant_remote_process(command: [
"docker stop --time=$timeout $containerName", "docker stop --time=$timeout $containerName",
@@ -24,6 +30,9 @@ class StopProxy
$server->save(); $server->save();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e); return handleError($e);
} finally {
ProxyDashboardCacheService::clearCache($server);
ProxyStatusChanged::dispatch($server->id);
} }
} }
} }

View File

@@ -0,0 +1,223 @@
<?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) {
ray('Error:', $e->getMessage());
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,
];
}
}

View File

@@ -2,16 +2,16 @@
namespace App\Actions\Server; namespace App\Actions\Server;
use App\Events\CloudflareTunnelConfigured;
use App\Models\Server; use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
class ConfigureCloudflared class ConfigureCloudflared
{ {
use AsAction; use AsAction;
public function handle(Server $server, string $cloudflare_token) public function handle(Server $server, string $cloudflare_token, string $ssh_domain): Activity
{ {
try { try {
$config = [ $config = [
@@ -24,6 +24,13 @@ class ConfigureCloudflared
'command' => 'tunnel run', 'command' => 'tunnel run',
'environment' => [ 'environment' => [
"TUNNEL_TOKEN={$cloudflare_token}", "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', 'mkdir -p /tmp/cloudflared',
'cd /tmp/cloudflared', 'cd /tmp/cloudflared',
"echo '$docker_compose_yml_base64' | base64 -d | tee docker-compose.yml > /dev/null", "echo '$docker_compose_yml_base64' | base64 -d | tee docker-compose.yml > /dev/null",
'echo Pulling latest Cloudflare Tunnel image.',
'docker compose pull', 'docker compose pull',
'docker compose down -v --remove-orphans > /dev/null 2>&1', 'echo Stopping existing Cloudflare Tunnel container.',
'docker compose up -d --remove-orphans', '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([ return remote_process($commands, $server, callEventOnFinish: 'CloudflareTunnelChanged', callEventData: [
'rm -fr /tmp/cloudflared', 'server_id' => $server->id,
'ssh_domain' => $ssh_domain,
]); ]);
instant_remote_process($commands, $server); } catch (\Throwable $e) {
throw $e;
} }
} }
} }

View File

@@ -104,7 +104,7 @@ class ServerCheck
try { try {
$shouldStart = CheckProxy::run($this->server); $shouldStart = CheckProxy::run($this->server);
if ($shouldStart) { if ($shouldStart) {
StartProxy::run($this->server, false); StartProxy::run($this->server, async: false);
$this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server)); $this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server));
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@@ -27,7 +27,7 @@ class StartSentinel
$mountDir = '/data/coolify/sentinel'; $mountDir = '/data/coolify/sentinel';
$image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version; $image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version;
if (! $endpoint) { if (! $endpoint) {
throw new \Exception('You should set FQDN in Instance Settings.'); throw new \RuntimeException('You should set FQDN in Instance Settings.');
} }
$environments = [ $environments = [
'TOKEN' => $token, 'TOKEN' => $token,

View 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(),
];
}
}
}

View File

@@ -53,7 +53,7 @@ class DeleteService
instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false); instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false);
} catch (\Exception $e) { } catch (\Exception $e) {
throw new \Exception($e->getMessage()); throw new \RuntimeException($e->getMessage());
} finally { } finally {
if ($deleteConfigurations) { if ($deleteConfigurations) {
$service->deleteConfigurations(); $service->deleteConfigurations();

View File

@@ -11,10 +11,10 @@ class RestartService
public string $jobQueue = 'high'; public string $jobQueue = 'high';
public function handle(Service $service) public function handle(Service $service, bool $pullLatestImages)
{ {
StopService::run($service); StopService::run($service);
return StartService::run($service); return StartService::run($service, $pullLatestImages);
} }
} }

View File

@@ -41,6 +41,6 @@ class StartService
} }
} }
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); return remote_process($commands, $service->server, type_uuid: $service->uuid);
} }
} }

View File

@@ -3,6 +3,8 @@
namespace App\Actions\Service; namespace App\Actions\Service;
use App\Actions\Server\CleanupDocker; use App\Actions\Server\CleanupDocker;
use App\Events\ServiceStatusChanged;
use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
@@ -20,17 +22,44 @@ class StopService
return 'Server is not functional'; return 'Server is not functional';
} }
$containersToStop = $service->getContainersToStop(); $containersToStop = [];
$service->stopContainers($containersToStop, $server); $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 (! empty($containersToStop)) {
$this->stopContainersInParallel($containersToStop, $server);
}
if ($isDeleteOperation) { if ($isDeleteOperation) {
$service->deleteConnectedNetworks(); $service->deleteConnectedNetworks();
if ($dockerCleanup) { }
CleanupDocker::dispatch($server, true); if ($dockerCleanup) {
} CleanupDocker::dispatch($server, true);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
return $e->getMessage(); 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
);
}
} }

View File

@@ -12,6 +12,7 @@ use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob; use App\Jobs\RegenerateSslCertJob;
use App\Jobs\ScheduledTaskJob; use App\Jobs\ScheduledTaskJob;
use App\Jobs\ServerCheckJob; use App\Jobs\ServerCheckJob;
use App\Jobs\ServerPatchCheckJob;
use App\Jobs\ServerStorageCheckJob; use App\Jobs\ServerStorageCheckJob;
use App\Jobs\UpdateCoolifyJob; use App\Jobs\UpdateCoolifyJob;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
@@ -175,6 +176,9 @@ class Kernel extends ConsoleKernel
} }
$this->scheduleInstance->job(new DockerCleanupJob($server))->cron($dockerCleanupFrequency)->timezone($serverTimezone)->onOneServer(); $this->scheduleInstance->job(new DockerCleanupJob($server))->cron($dockerCleanupFrequency)->timezone($serverTimezone)->onOneServer();
// Server patch check - weekly
$this->scheduleInstance->job(new ServerPatchCheckJob($server))->weekly()->timezone($serverTimezone)->onOneServer();
// Cleanup multiplexed connections every hour // Cleanup multiplexed connections every hour
// $this->scheduleInstance->job(new ServerCleanupMux($server))->hourly()->onOneServer(); // $this->scheduleInstance->job(new ServerCleanupMux($server))->hourly()->onOneServer();

View File

@@ -6,7 +6,7 @@ use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class ProxyStarted class CloudflareTunnelChanged
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;

View File

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

View File

@@ -0,0 +1,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}"),
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class 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}"),
];
}
}

View File

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

View File

@@ -13,24 +13,22 @@ class ServiceStatusChanged implements ShouldBroadcast
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
public int|string|null $userId = null; public function __construct(
public ?int $teamId = null
public function __construct($userId = null) ) {
{ if (is_null($this->teamId) && Auth::check() && Auth::user()->currentTeam()) {
if (is_null($userId)) { $this->teamId = Auth::user()->currentTeam()->id;
$userId = Auth::id() ?? null;
} }
$this->userId = $userId;
} }
public function broadcastOn(): ?array public function broadcastOn(): array
{ {
if (is_null($this->userId)) { if (is_null($this->teamId)) {
return []; return [];
} }
return [ return [
new PrivateChannel("user.{$this->userId}"), new PrivateChannel("team.{$this->teamId}"),
]; ];
} }
} }

View File

@@ -187,6 +187,7 @@ class ApplicationsController extends Controller
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
], ],
) )
), ),
@@ -306,6 +307,7 @@ class ApplicationsController extends Controller
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
], ],
) )
), ),
@@ -425,6 +427,7 @@ class ApplicationsController extends Controller
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
], ],
) )
), ),
@@ -528,6 +531,7 @@ class ApplicationsController extends Controller
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
], ],
) )
), ),
@@ -628,6 +632,7 @@ class ApplicationsController extends Controller
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
], ],
) )
), ),
@@ -691,6 +696,7 @@ class ApplicationsController extends Controller
'description' => ['type' => 'string', 'description' => 'The application description.'], 'description' => ['type' => 'string', 'description' => 'The application description.'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
], ],
) )
), ),
@@ -736,7 +742,7 @@ class ApplicationsController extends Controller
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return; return $return;
} }
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password']; $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'name' => 'string|max:255', 'name' => 'string|max:255',
@@ -777,6 +783,7 @@ class ApplicationsController extends Controller
$githubAppUuid = $request->github_app_uuid; $githubAppUuid = $request->github_app_uuid;
$useBuildServer = $request->use_build_server; $useBuildServer = $request->use_build_server;
$isStatic = $request->is_static; $isStatic = $request->is_static;
$connectToDockerNetwork = $request->connect_to_docker_network;
$customNginxConfiguration = $request->custom_nginx_configuration; $customNginxConfiguration = $request->custom_nginx_configuration;
if (! is_null($customNginxConfiguration)) { if (! is_null($customNginxConfiguration)) {
@@ -886,6 +893,10 @@ class ApplicationsController extends Controller
$application->settings->is_static = $isStatic; $application->settings->is_static = $isStatic;
$application->settings->save(); $application->settings->save();
} }
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
}
if (isset($useBuildServer)) { if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer; $application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save(); $application->settings->save();
@@ -989,7 +1000,33 @@ class ApplicationsController extends Controller
$dockerComposeDomainsJson = collect(); $dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) { if ($request->has('docker_compose_domains')) {
$yaml = Yaml::parse($application->docker_compose_raw); if (! $request->has('docker_compose_raw')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The base64 encoded docker_compose_raw is required.',
],
], 422);
}
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);
}
$yaml = Yaml::parse($dockerComposeRaw);
$services = data_get($yaml, 'services'); $services = data_get($yaml, 'services');
$dockerComposeDomains = collect($request->docker_compose_domains); $dockerComposeDomains = collect($request->docker_compose_domains);
if ($dockerComposeDomains->count() > 0) { if ($dockerComposeDomains->count() > 0) {
@@ -1095,7 +1132,34 @@ class ApplicationsController extends Controller
$dockerComposeDomainsJson = collect(); $dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) { if ($request->has('docker_compose_domains')) {
$yaml = Yaml::parse($application->docker_compose_raw); if (! $request->has('docker_compose_raw')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The base64 encoded docker_compose_raw is required.',
],
], 422);
}
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);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
$yaml = Yaml::parse($dockerComposeRaw);
$services = data_get($yaml, 'services'); $services = data_get($yaml, 'services');
$dockerComposeDomains = collect($request->docker_compose_domains); $dockerComposeDomains = collect($request->docker_compose_domains);
if ($dockerComposeDomains->count() > 0) { if ($dockerComposeDomains->count() > 0) {
@@ -1737,6 +1801,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => ['type' => 'array', 'description' => 'The Docker Compose domains.'], 'docker_compose_domains' => ['type' => 'array', 'description' => 'The Docker Compose domains.'],
'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
], ],
) )
), ),
@@ -1790,7 +1855,7 @@ class ApplicationsController extends Controller
], 404); ], 404);
} }
$server = $application->destination->server; $server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password']; $allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network'];
$validationRules = [ $validationRules = [
'name' => 'string|max:255', 'name' => 'string|max:255',
@@ -1918,7 +1983,34 @@ class ApplicationsController extends Controller
$dockerComposeDomainsJson = collect(); $dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) { if ($request->has('docker_compose_domains')) {
$yaml = Yaml::parse($application->docker_compose_raw); if (! $request->has('docker_compose_raw')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The base64 encoded docker_compose_raw is required.',
],
], 422);
}
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);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
$yaml = Yaml::parse($dockerComposeRaw);
$services = data_get($yaml, 'services'); $services = data_get($yaml, 'services');
$dockerComposeDomains = collect($request->docker_compose_domains); $dockerComposeDomains = collect($request->docker_compose_domains);
if ($dockerComposeDomains->count() > 0) { if ($dockerComposeDomains->count() > 0) {
@@ -1933,6 +2025,7 @@ class ApplicationsController extends Controller
} }
$instantDeploy = $request->instant_deploy; $instantDeploy = $request->instant_deploy;
$isStatic = $request->is_static; $isStatic = $request->is_static;
$connectToDockerNetwork = $request->connect_to_docker_network;
$useBuildServer = $request->use_build_server; $useBuildServer = $request->use_build_server;
if (isset($useBuildServer)) { if (isset($useBuildServer)) {
@@ -1945,6 +2038,11 @@ class ApplicationsController extends Controller
$application->settings->save(); $application->settings->save();
} }
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
}
removeUnnecessaryFieldsFromRequest($request); removeUnnecessaryFieldsFromRequest($request);
$data = $request->all(); $data = $request->all();

View File

@@ -319,9 +319,10 @@ class DeployController extends Controller
default: default:
// Database resource // Database resource
StartDatabase::dispatch($resource); StartDatabase::dispatch($resource);
$resource->update([
'started_at' => now(), $resource->started_at ??= now();
]); $resource->save();
$message = "Database {$resource->name} started."; $message = "Database {$resource->name} started.";
break; break;
} }

View File

@@ -1428,6 +1428,15 @@ class ServicesController extends Controller
format: 'uuid', format: 'uuid',
) )
), ),
new OA\Parameter(
name: 'latest',
in: 'query',
description: 'Pull latest images.',
schema: new OA\Schema(
type: 'boolean',
default: false,
)
),
], ],
responses: [ responses: [
new OA\Response( new OA\Response(
@@ -1473,7 +1482,8 @@ class ServicesController extends Controller
if (! $service) { if (! $service) {
return response()->json(['message' => 'Service not found.'], 404); return response()->json(['message' => 'Service not found.'], 404);
} }
RestartService::dispatch($service); $pullLatest = $request->boolean('latest');
RestartService::dispatch($service, $pullLatest);
return response()->json( return response()->json(
[ [

View File

@@ -5,7 +5,7 @@ namespace App\Jobs;
use App\Actions\Docker\GetContainersStatus; use App\Actions\Docker\GetContainersStatus;
use App\Enums\ApplicationDeploymentStatus; use App\Enums\ApplicationDeploymentStatus;
use App\Enums\ProcessStatus; use App\Enums\ProcessStatus;
use App\Events\ApplicationStatusChanged; use App\Events\ServiceStatusChanged;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
@@ -331,7 +331,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}"); $this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
$this->graceful_shutdown_container($this->deployment_uuid); $this->graceful_shutdown_container($this->deployment_uuid);
ApplicationStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id')); ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
} }
} }
@@ -361,9 +361,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function post_deployment() private function post_deployment()
{ {
if ($this->server->isProxyShouldRun()) { GetContainersStatus::dispatch($this->server);
GetContainersStatus::dispatch($this->server);
}
$this->next(ApplicationDeploymentStatus::FINISHED->value); $this->next(ApplicationDeploymentStatus::FINISHED->value);
if ($this->pull_request_id !== 0) { if ($this->pull_request_id !== 0) {
if ($this->application->is_github_based()) { if ($this->application->is_github_based()) {
@@ -507,7 +505,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->env_filename) { if ($this->env_filename) {
$command .= " --env-file {$this->workdir}/{$this->env_filename}"; $command .= " --env-file {$this->workdir}/{$this->env_filename}";
} }
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull"; if ($this->force_rebuild) {
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache";
} else {
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull";
}
$this->execute_remote_command( $this->execute_remote_command(
[executeInDocker($this->deployment_uuid, $command), 'hidden' => true], [executeInDocker($this->deployment_uuid, $command), 'hidden' => true],
); );
@@ -1590,13 +1592,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} }
} }
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_FQDN')->isEmpty()) { if ($this->application->environment_variables_preview->where('key', 'COOLIFY_FQDN')->isEmpty()) {
$coolify_envs->put('COOLIFY_FQDN', $this->preview->fqdn); if ((int) $this->application->compose_parsing_version >= 3) {
$coolify_envs->put('COOLIFY_DOMAIN_URL', $this->preview->fqdn); $coolify_envs->put('COOLIFY_URL', $this->preview->fqdn);
} else {
$coolify_envs->put('COOLIFY_FQDN', $this->preview->fqdn);
}
} }
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_URL')->isEmpty()) { if ($this->application->environment_variables_preview->where('key', 'COOLIFY_URL')->isEmpty()) {
$url = str($this->preview->fqdn)->replace('http://', '')->replace('https://', ''); $url = str($this->preview->fqdn)->replace('http://', '')->replace('https://', '');
$coolify_envs->put('COOLIFY_URL', $url); if ((int) $this->application->compose_parsing_version >= 3) {
$coolify_envs->put('COOLIFY_DOMAIN_FQDN', $url); $coolify_envs->put('COOLIFY_FQDN', $url);
} else {
$coolify_envs->put('COOLIFY_URL', $url);
}
} }
if ($this->application->build_pack !== 'dockercompose' || $this->application->compose_parsing_version === '1' || $this->application->compose_parsing_version === '2') { if ($this->application->build_pack !== 'dockercompose' || $this->application->compose_parsing_version === '1' || $this->application->compose_parsing_version === '2') {
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_BRANCH')->isEmpty()) { if ($this->application->environment_variables_preview->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
@@ -2428,8 +2436,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
queue_next_deployment($this->application); queue_next_deployment($this->application);
// Never allow changing status from FAILED or CANCELLED_BY_USER to anything else // Never allow changing status from FAILED or CANCELLED_BY_USER to anything else
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value || if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
$this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { $this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview));
return;
}
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
return; return;
} }
@@ -2437,12 +2449,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
'status' => $status, 'status' => $status,
]); ]);
if ($status === ApplicationDeploymentStatus::FAILED->value) {
$this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview));
return;
}
if ($status === ApplicationDeploymentStatus::FINISHED->value) { if ($status === ApplicationDeploymentStatus::FINISHED->value) {
if (! $this->only_this_server) { if (! $this->only_this_server) {
$this->deploy_to_additional_destinations(); $this->deploy_to_additional_destinations();

View File

@@ -54,6 +54,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public ?string $postgres_password = null; public ?string $postgres_password = null;
public ?string $mongo_root_username = null;
public ?string $mongo_root_password = null;
public ?S3Storage $s3 = null; public ?S3Storage $s3 = null;
public function __construct(public ScheduledDatabaseBackup $backup) public function __construct(public ScheduledDatabaseBackup $backup)
@@ -189,6 +193,40 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
throw new \Exception('MARIADB_DATABASE or MYSQL_DATABASE not found'); throw new \Exception('MARIADB_DATABASE or MYSQL_DATABASE not found');
} }
} }
} elseif (str($databaseType)->contains('mongo')) {
$databasesToBackup = ['*'];
$this->container_name = "{$this->database->name}-$serviceUuid";
$this->directory_name = $serviceName.'-'.$this->container_name;
// Try to extract MongoDB credentials from environment variables
try {
$commands = [];
$commands[] = "docker exec $this->container_name env | grep MONGO_INITDB_";
$envs = instant_remote_process($commands, $this->server);
if (filled($envs)) {
$envs = str($envs)->explode("\n");
$rootPassword = $envs->filter(function ($env) {
return str($env)->startsWith('MONGO_INITDB_ROOT_PASSWORD=');
})->first();
if ($rootPassword) {
$this->mongo_root_password = str($rootPassword)->after('MONGO_INITDB_ROOT_PASSWORD=')->value();
}
$rootUsername = $envs->filter(function ($env) {
return str($env)->startsWith('MONGO_INITDB_ROOT_USERNAME=');
})->first();
if ($rootUsername) {
$this->mongo_root_username = str($rootUsername)->after('MONGO_INITDB_ROOT_USERNAME=')->value();
}
}
\Log::info('MongoDB credentials extracted from environment', [
'has_username' => filled($this->mongo_root_username),
'has_password' => filled($this->mongo_root_password),
]);
} catch (\Throwable $e) {
\Log::warning('Failed to extract MongoDB environment variables', ['error' => $e->getMessage()]);
// Continue without env vars - will be handled in backup_standalone_mongodb method
}
} }
} else { } else {
$databaseName = str($this->database->name)->slug()->value(); $databaseName = str($this->database->name)->slug()->value();
@@ -200,7 +238,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if (blank($databasesToBackup)) { if (blank($databasesToBackup)) {
if (str($databaseType)->contains('postgres')) { if (str($databaseType)->contains('postgres')) {
$databasesToBackup = [$this->database->postgres_db]; $databasesToBackup = [$this->database->postgres_db];
} elseif (str($databaseType)->contains('mongodb')) { } elseif (str($databaseType)->contains('mongo')) {
$databasesToBackup = ['*']; $databasesToBackup = ['*'];
} elseif (str($databaseType)->contains('mysql')) { } elseif (str($databaseType)->contains('mysql')) {
$databasesToBackup = [$this->database->mysql_database]; $databasesToBackup = [$this->database->mysql_database];
@@ -214,10 +252,13 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
// Format: db1,db2,db3 // Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup); $databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup); $databasesToBackup = array_map('trim', $databasesToBackup);
} elseif (str($databaseType)->contains('mongodb')) { } elseif (str($databaseType)->contains('mongo')) {
// Format: db1:collection1,collection2|db2:collection3,collection4 // Format: db1:collection1,collection2|db2:collection3,collection4
$databasesToBackup = explode('|', $databasesToBackup); // Only explode if it's a string, not if it's already an array
$databasesToBackup = array_map('trim', $databasesToBackup); if (is_string($databasesToBackup)) {
$databasesToBackup = explode('|', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
}
} elseif (str($databaseType)->contains('mysql')) { } elseif (str($databaseType)->contains('mysql')) {
// Format: db1,db2,db3 // Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup); $databasesToBackup = explode(',', $databasesToBackup);
@@ -252,7 +293,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'scheduled_database_backup_id' => $this->backup->id, 'scheduled_database_backup_id' => $this->backup->id,
]); ]);
$this->backup_standalone_postgresql($database); $this->backup_standalone_postgresql($database);
} elseif (str($databaseType)->contains('mongodb')) { } elseif (str($databaseType)->contains('mongo')) {
if ($database === '*') { if ($database === '*') {
$database = 'all'; $database = 'all';
$databaseName = 'all'; $databaseName = 'all';
@@ -343,6 +384,17 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{ {
try { try {
$url = $this->database->internal_db_url; $url = $this->database->internal_db_url;
if (blank($url)) {
// For service-based MongoDB, try to build URL from environment variables
if (filled($this->mongo_root_username) && filled($this->mongo_root_password)) {
// Use container name instead of server IP for service-based MongoDB
$url = "mongodb://{$this->mongo_root_username}:{$this->mongo_root_password}@{$this->container_name}:27017";
} else {
// If no environment variables are available, throw an exception
throw new \Exception('MongoDB credentials not found. Ensure MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables are available in the container.');
}
}
\Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]);
if ($databaseWithCollections === 'all') { if ($databaseWithCollections === 'all') {
$commands[] = 'mkdir -p '.$this->backup_dir; $commands[] = 'mkdir -p '.$this->backup_dir;
if (str($this->database->image)->startsWith('mongo:4')) { if (str($this->database->image)->startsWith('mongo:4')) {

View File

@@ -31,7 +31,7 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping($this->server->uuid))->dontRelease()]; return [(new WithoutOverlapping('docker-cleanup-'.$this->server->uuid))->dontRelease()];
} }
public function __construct(public Server $server, public bool $manualCleanup = false) {} public function __construct(public Server $server, public bool $manualCleanup = false) {}

View File

@@ -9,7 +9,6 @@ use App\Actions\Proxy\StartProxy;
use App\Actions\Server\StartLogDrain; use App\Actions\Server\StartLogDrain;
use App\Actions\Shared\ComplexStatusCheck; use App\Actions\Shared\ComplexStatusCheck;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Server; use App\Models\Server;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
@@ -71,7 +70,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping($this->server->uuid))->dontRelease()]; return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->dontRelease()];
} }
public function backoff(): int public function backoff(): int
@@ -122,7 +121,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
$this->allApplicationsWithAdditionalServers = $this->applications->filter(function ($application) { $this->allApplicationsWithAdditionalServers = $this->applications->filter(function ($application) {
return $application->additional_servers->count() > 0; return $application->additional_servers->count() > 0;
}); });
$this->allApplicationPreviewsIds = $this->previews->pluck('id'); $this->allApplicationPreviewsIds = $this->previews->map(function ($preview) {
return $preview->application_id.':'.$preview->pull_request_id;
});
$this->allDatabaseUuids = $this->databases->pluck('uuid'); $this->allDatabaseUuids = $this->databases->pluck('uuid');
$this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid'); $this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid');
$this->services->each(function ($service) { $this->services->each(function ($service) {
@@ -147,7 +148,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
} }
if ($labels->has('coolify.applicationId')) { if ($labels->has('coolify.applicationId')) {
$applicationId = $labels->get('coolify.applicationId'); $applicationId = $labels->get('coolify.applicationId');
$pullRequestId = data_get($labels, 'coolify.pullRequestId', '0'); $pullRequestId = $labels->get('coolify.pullRequestId', '0');
try { try {
if ($pullRequestId === '0') { if ($pullRequestId === '0') {
if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) { if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) {
@@ -155,10 +156,11 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
} }
$this->updateApplicationStatus($applicationId, $containerStatus); $this->updateApplicationStatus($applicationId, $containerStatus);
} else { } else {
if ($this->allApplicationPreviewsIds->contains($applicationId) && $this->isRunning($containerStatus)) { $previewKey = $applicationId.':'.$pullRequestId;
$this->foundApplicationPreviewsIds->push($applicationId); if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) {
$this->foundApplicationPreviewsIds->push($previewKey);
} }
$this->updateApplicationPreviewStatus($applicationId, $containerStatus); $this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
} }
@@ -211,18 +213,24 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
if (! $application) { if (! $application) {
return; return;
} }
$application->status = $containerStatus; if ($application->status !== $containerStatus) {
$application->save(); $application->status = $containerStatus;
$application->save();
}
} }
private function updateApplicationPreviewStatus(string $applicationId, string $containerStatus) private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus)
{ {
$application = $this->previews->where('id', $applicationId)->first(); $application = $this->previews->where('application_id', $applicationId)
->where('pull_request_id', $pullRequestId)
->first();
if (! $application) { if (! $application) {
return; return;
} }
$application->status = $containerStatus; if ($application->status !== $containerStatus) {
$application->save(); $application->status = $containerStatus;
$application->save();
}
} }
private function updateNotFoundApplicationStatus() private function updateNotFoundApplicationStatus()
@@ -232,8 +240,21 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
$notFoundApplicationIds->each(function ($applicationId) { $notFoundApplicationIds->each(function ($applicationId) {
$application = Application::find($applicationId); $application = Application::find($applicationId);
if ($application) { if ($application) {
$application->status = 'exited'; // Don't mark as exited if already exited
$application->save(); if (str($application->status)->startsWith('exited')) {
return;
}
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
if ($application->status !== 'exited') {
$application->status = 'exited';
$application->save();
}
} }
}); });
} }
@@ -243,11 +264,36 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
{ {
$notFoundApplicationPreviewsIds = $this->allApplicationPreviewsIds->diff($this->foundApplicationPreviewsIds); $notFoundApplicationPreviewsIds = $this->allApplicationPreviewsIds->diff($this->foundApplicationPreviewsIds);
if ($notFoundApplicationPreviewsIds->isNotEmpty()) { if ($notFoundApplicationPreviewsIds->isNotEmpty()) {
$notFoundApplicationPreviewsIds->each(function ($applicationPreviewId) { $notFoundApplicationPreviewsIds->each(function ($previewKey) {
$applicationPreview = ApplicationPreview::find($applicationPreviewId); // Parse the previewKey format "application_id:pull_request_id"
$parts = explode(':', $previewKey);
if (count($parts) !== 2) {
return;
}
$applicationId = $parts[0];
$pullRequestId = $parts[1];
$applicationPreview = $this->previews->where('application_id', $applicationId)
->where('pull_request_id', $pullRequestId)
->first();
if ($applicationPreview) { if ($applicationPreview) {
$applicationPreview->status = 'exited'; // Don't mark as exited if already exited
$applicationPreview->save(); if (str($applicationPreview->status)->startsWith('exited')) {
return;
}
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
if ($applicationPreview->status !== 'exited') {
$applicationPreview->status = 'exited';
$applicationPreview->save();
}
} }
}); });
} }
@@ -260,7 +306,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
if ($this->foundProxy === false) { if ($this->foundProxy === false) {
try { try {
if (CheckProxy::run($this->server)) { if (CheckProxy::run($this->server)) {
StartProxy::run($this->server, false); StartProxy::run($this->server, async: false);
$this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server)); $this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server));
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -278,8 +324,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
if (! $database) { if (! $database) {
return; return;
} }
$database->status = $containerStatus; if ($database->status !== $containerStatus) {
$database->save(); $database->status = $containerStatus;
$database->save();
}
if ($this->isRunning($containerStatus) && $tcpProxy) { if ($this->isRunning($containerStatus) && $tcpProxy) {
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running'; return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running';
@@ -299,8 +347,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
$notFoundDatabaseUuids->each(function ($databaseUuid) { $notFoundDatabaseUuids->each(function ($databaseUuid) {
$database = $this->databases->where('uuid', $databaseUuid)->first(); $database = $this->databases->where('uuid', $databaseUuid)->first();
if ($database) { if ($database) {
$database->status = 'exited'; if ($database->status !== 'exited') {
$database->save(); $database->status = 'exited';
$database->save();
}
if ($database->is_public) { if ($database->is_public) {
StopDatabaseProxy::dispatch($database); StopDatabaseProxy::dispatch($database);
} }
@@ -317,13 +367,20 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
} }
if ($subType === 'application') { if ($subType === 'application') {
$application = $service->applications()->where('id', $subId)->first(); $application = $service->applications()->where('id', $subId)->first();
$application->status = $containerStatus; if ($application) {
$application->save(); if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
}
}
} elseif ($subType === 'database') { } elseif ($subType === 'database') {
$database = $service->databases()->where('id', $subId)->first(); $database = $service->databases()->where('id', $subId)->first();
$database->status = $containerStatus; if ($database) {
$database->save(); if ($database->status !== $containerStatus) {
} else { $database->status = $containerStatus;
$database->save();
}
}
} }
} }
@@ -335,8 +392,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
$notFoundServiceApplicationIds->each(function ($serviceApplicationId) { $notFoundServiceApplicationIds->each(function ($serviceApplicationId) {
$application = ServiceApplication::find($serviceApplicationId); $application = ServiceApplication::find($serviceApplicationId);
if ($application) { if ($application) {
$application->status = 'exited'; if ($application->status !== 'exited') {
$application->save(); $application->status = 'exited';
$application->save();
}
} }
}); });
} }
@@ -344,8 +403,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
$notFoundServiceDatabaseIds->each(function ($serviceDatabaseId) { $notFoundServiceDatabaseIds->each(function ($serviceDatabaseId) {
$database = ServiceDatabase::find($serviceDatabaseId); $database = ServiceDatabase::find($serviceDatabaseId);
if ($database) { if ($database) {
$database->status = 'exited'; if ($database->status !== 'exited') {
$database->save(); $database->status = 'exited';
$database->save();
}
} }
}); });
} }

View File

@@ -2,7 +2,6 @@
namespace App\Jobs; namespace App\Jobs;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy; use App\Actions\Proxy\StartProxy;
use App\Actions\Proxy\StopProxy; use App\Actions\Proxy\StopProxy;
use App\Models\Server; use App\Models\Server;
@@ -24,7 +23,7 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping($this->server->uuid))->dontRelease()]; return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->dontRelease()];
} }
public function __construct(public Server $server) {} public function __construct(public Server $server) {}
@@ -36,9 +35,9 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
$this->server->proxy->force_stop = false; $this->server->proxy->force_stop = false;
$this->server->save(); $this->server->save();
StartProxy::run($this->server, force: true); StartProxy::run($this->server, force: true);
CheckProxy::run($this->server, true);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e); return handleError($e);
} }

View File

@@ -28,7 +28,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping($this->server->uuid))->dontRelease()]; return [(new WithoutOverlapping('server-check-'.$this->server->uuid))->dontRelease()];
} }
public function __construct(public Server $server) {} public function __construct(public Server $server) {}
@@ -68,7 +68,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
try { try {
$shouldStart = CheckProxy::run($this->server); $shouldStart = CheckProxy::run($this->server);
if ($shouldStart) { if ($shouldStart) {
StartProxy::run($this->server, false); StartProxy::run($this->server, async: false);
$this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server)); $this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server));
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Jobs;
use App\Actions\Server\CheckUpdates;
use App\Models\Server;
use App\Notifications\Server\ServerPatchCheck;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
class ServerPatchCheckJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $timeout = 600; // 10 minutes timeout
public function middleware(): array
{
return [(new WithoutOverlapping('server-patch-check-'.$this->server->uuid))->dontRelease()];
}
public function __construct(public Server $server) {}
public function handle(): void
{
try {
if ($this->server->serverStatus() === false) {
return;
}
$team = data_get($this->server, 'team');
if (! $team) {
return;
}
// Check for updates
$patchData = CheckUpdates::run($this->server);
if (isset($patchData['error'])) {
$team->notify(new ServerPatchCheck($this->server, $patchData));
return; // Skip if there's an error checking for updates
}
$totalUpdates = $patchData['total_updates'] ?? 0;
// Only send notification if there are updates available
if ($totalUpdates > 0) {
$team->notify(new ServerPatchCheck($this->server, $patchData));
}
} catch (\Throwable $e) {
// Log error but don't fail the job
\Illuminate\Support\Facades\Log::error('ServerPatchCheckJob failed: '.$e->getMessage(), [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Listeners;
use App\Events\CloudflareTunnelChanged;
use App\Events\CloudflareTunnelConfigured;
use App\Models\Server;
use Illuminate\Support\Sleep;
class CloudflareTunnelChangedNotification
{
public Server $server;
public function __construct() {}
public function handle(CloudflareTunnelChanged $event): void
{
$server_id = data_get($event, 'data.server_id');
$ssh_domain = data_get($event, 'data.ssh_domain');
$this->server = Server::where('id', $server_id)->firstOrFail();
// Check if cloudflare tunnel is running (container is healthy) - try 3 times with 5 second intervals
$cloudflareHealthy = false;
$attempts = 3;
for ($i = 1; $i <= $attempts; $i++) {
\Log::debug("Cloudflare health check attempt {$i}/{$attempts}", ['server_id' => $server_id]);
$result = instant_remote_process_with_timeout(['docker inspect coolify-cloudflared | jq -e ".[0].State.Health.Status == \"healthy\""'], $this->server, false, 10);
if (blank($result)) {
\Log::debug("Cloudflare Tunnels container not found on attempt {$i}", ['server_id' => $server_id]);
} elseif ($result === 'true') {
\Log::debug("Cloudflare Tunnels container healthy on attempt {$i}", ['server_id' => $server_id]);
$cloudflareHealthy = true;
break;
} else {
\Log::debug("Cloudflare Tunnels container not healthy on attempt {$i}", ['server_id' => $server_id, 'result' => $result]);
}
// Sleep between attempts (except after the last attempt)
if ($i < $attempts) {
Sleep::for(5)->seconds();
}
}
if (! $cloudflareHealthy) {
\Log::error('Cloudflare Tunnels container failed all health checks.', ['server_id' => $server_id, 'attempts' => $attempts]);
return;
}
$this->server->settings->update([
'is_cloudflare_tunnel' => true,
]);
// Only update IP if it's not already set to the ssh_domain or if it's empty
if ($this->server->ip !== $ssh_domain && ! empty($ssh_domain)) {
\Log::debug('Cloudflare Tunnels configuration updated - updating IP address.', ['old_ip' => $this->server->ip, 'new_ip' => $ssh_domain]);
$this->server->update(['ip' => $ssh_domain]);
} else {
\Log::debug('Cloudflare Tunnels configuration updated - IP address unchanged.', ['current_ip' => $this->server->ip]);
}
$teamId = $this->server->team_id;
CloudflareTunnelConfigured::dispatch($teamId);
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Listeners;
use App\Events\ProxyStarted;
use App\Models\Server;
class ProxyStartedNotification
{
public Server $server;
public function __construct() {}
public function handle(ProxyStarted $event): void
{
$this->server = data_get($event, 'data');
$this->server->setupDefaultRedirect();
$this->server->setupDynamicProxyConfiguration();
$this->server->proxy->force_stop = false;
$this->server->save();
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Listeners;
use App\Events\ProxyStatusChanged;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
{
public function __construct() {}
public function handle(ProxyStatusChanged $event)
{
$serverId = $event->data;
if (is_null($serverId)) {
return;
}
$server = Server::where('id', $serverId)->first();
if (is_null($server)) {
return;
}
$proxyContainerName = 'coolify-proxy';
$status = getContainerStatus($server, $proxyContainerName);
$server->proxy->set('status', $status);
$server->save();
ProxyStatusChangedUI::dispatch($server->team_id);
if ($status === 'running') {
$server->setupDefaultRedirect();
$server->setupDynamicProxyConfiguration();
$server->proxy->force_stop = false;
$server->save();
}
if ($status === 'created') {
instant_remote_process([
'docker rm -f coolify-proxy',
], $server);
}
}
}

View File

@@ -14,20 +14,25 @@ class ActivityMonitor extends Component
public $eventToDispatch = 'activityFinished'; public $eventToDispatch = 'activityFinished';
public $eventData = null;
public $isPollingActive = false; public $isPollingActive = false;
public bool $fullHeight = false; public bool $fullHeight = false;
public bool $showWaiting = false; public $activity;
protected $activity; public bool $showWaiting = true;
public static $eventDispatched = false;
protected $listeners = ['activityMonitor' => 'newMonitorActivity']; protected $listeners = ['activityMonitor' => 'newMonitorActivity'];
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished') public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null)
{ {
$this->activityId = $activityId; $this->activityId = $activityId;
$this->eventToDispatch = $eventToDispatch; $this->eventToDispatch = $eventToDispatch;
$this->eventData = $eventData;
$this->hydrateActivity(); $this->hydrateActivity();
@@ -51,15 +56,27 @@ class ActivityMonitor extends Component
$causer_id = data_get($this->activity, 'causer_id'); $causer_id = data_get($this->activity, 'causer_id');
$user = User::find($causer_id); $user = User::find($causer_id);
if ($user) { if ($user) {
foreach ($user->teams as $team) { $teamId = $user->currentTeam()->id;
$teamId = $team->id; if (! self::$eventDispatched) {
$this->eventToDispatch::dispatch($teamId); if (filled($this->eventData)) {
$this->eventToDispatch::dispatch($teamId, $this->eventData);
} else {
$this->eventToDispatch::dispatch($teamId);
}
self::$eventDispatched = true;
} }
} }
return; return;
} }
$this->dispatch($this->eventToDispatch); if (! self::$eventDispatched) {
if (filled($this->eventData)) {
$this->dispatch($this->eventToDispatch, $this->eventData);
} else {
$this->dispatch($this->eventToDispatch);
}
self::$eventDispatched = true;
}
} }
} }
} }

View File

@@ -1,74 +0,0 @@
<?php
namespace App\Livewire;
use App\Models\User;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
class NewActivityMonitor extends Component
{
public ?string $header = null;
public $activityId;
public $eventToDispatch = 'activityFinished';
public $eventData = null;
public $isPollingActive = false;
protected $activity;
protected $listeners = ['newActivityMonitor' => 'newMonitorActivity'];
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null)
{
$this->activityId = $activityId;
$this->eventToDispatch = $eventToDispatch;
$this->eventData = $eventData;
$this->hydrateActivity();
$this->isPollingActive = true;
}
public function hydrateActivity()
{
$this->activity = Activity::find($this->activityId);
}
public function polling()
{
$this->hydrateActivity();
// $this->setStatus(ProcessStatus::IN_PROGRESS);
$exit_code = data_get($this->activity, 'properties.exitCode');
if ($exit_code !== null) {
// if ($exit_code === 0) {
// // $this->setStatus(ProcessStatus::FINISHED);
// } else {
// // $this->setStatus(ProcessStatus::ERROR);
// }
$this->isPollingActive = false;
if ($this->eventToDispatch !== null) {
if (str($this->eventToDispatch)->startsWith('App\\Events\\')) {
$causer_id = data_get($this->activity, 'causer_id');
$user = User::find($causer_id);
if ($user) {
foreach ($user->teams as $team) {
$teamId = $team->id;
$this->eventToDispatch::dispatch($teamId);
}
}
return;
}
if (! is_null($this->eventData)) {
$this->dispatch($this->eventToDispatch, $this->eventData);
} else {
$this->dispatch($this->eventToDispatch);
}
}
}
}
}

View File

@@ -56,6 +56,9 @@ class Discord extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $serverUnreachableDiscordNotifications = true; public bool $serverUnreachableDiscordNotifications = true;
#[Validate(['boolean'])]
public bool $serverPatchDiscordNotifications = false;
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $discordPingEnabled = true; public bool $discordPingEnabled = true;
@@ -89,6 +92,7 @@ class Discord extends Component
$this->settings->server_disk_usage_discord_notifications = $this->serverDiskUsageDiscordNotifications; $this->settings->server_disk_usage_discord_notifications = $this->serverDiskUsageDiscordNotifications;
$this->settings->server_reachable_discord_notifications = $this->serverReachableDiscordNotifications; $this->settings->server_reachable_discord_notifications = $this->serverReachableDiscordNotifications;
$this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications; $this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications;
$this->settings->server_patch_discord_notifications = $this->serverPatchDiscordNotifications;
$this->settings->discord_ping_enabled = $this->discordPingEnabled; $this->settings->discord_ping_enabled = $this->discordPingEnabled;
@@ -110,6 +114,7 @@ class Discord extends Component
$this->serverDiskUsageDiscordNotifications = $this->settings->server_disk_usage_discord_notifications; $this->serverDiskUsageDiscordNotifications = $this->settings->server_disk_usage_discord_notifications;
$this->serverReachableDiscordNotifications = $this->settings->server_reachable_discord_notifications; $this->serverReachableDiscordNotifications = $this->settings->server_reachable_discord_notifications;
$this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications; $this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications;
$this->serverPatchDiscordNotifications = $this->settings->server_patch_discord_notifications;
$this->discordPingEnabled = $this->settings->discord_ping_enabled; $this->discordPingEnabled = $this->settings->discord_ping_enabled;
} }

View File

@@ -98,6 +98,9 @@ class Email extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $serverUnreachableEmailNotifications = true; public bool $serverUnreachableEmailNotifications = true;
#[Validate(['boolean'])]
public bool $serverPatchEmailNotifications = false;
#[Validate(['nullable', 'email'])] #[Validate(['nullable', 'email'])]
public ?string $testEmailAddress = null; public ?string $testEmailAddress = null;
@@ -146,6 +149,7 @@ class Email extends Component
$this->settings->server_disk_usage_email_notifications = $this->serverDiskUsageEmailNotifications; $this->settings->server_disk_usage_email_notifications = $this->serverDiskUsageEmailNotifications;
$this->settings->server_reachable_email_notifications = $this->serverReachableEmailNotifications; $this->settings->server_reachable_email_notifications = $this->serverReachableEmailNotifications;
$this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications; $this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications;
$this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications;
$this->settings->save(); $this->settings->save();
} else { } else {
@@ -177,6 +181,7 @@ class Email extends Component
$this->serverDiskUsageEmailNotifications = $this->settings->server_disk_usage_email_notifications; $this->serverDiskUsageEmailNotifications = $this->settings->server_disk_usage_email_notifications;
$this->serverReachableEmailNotifications = $this->settings->server_reachable_email_notifications; $this->serverReachableEmailNotifications = $this->settings->server_reachable_email_notifications;
$this->serverUnreachableEmailNotifications = $this->settings->server_unreachable_email_notifications; $this->serverUnreachableEmailNotifications = $this->settings->server_unreachable_email_notifications;
$this->serverPatchEmailNotifications = $this->settings->server_patch_email_notifications;
} }
} }
@@ -320,7 +325,7 @@ class Email extends Component
'test-email:'.$this->team->id, 'test-email:'.$this->team->id,
$perMinute = 0, $perMinute = 0,
function () { function () {
$this->team?->notify(new Test($this->testEmailAddress, 'email')); $this->team?->notifyNow(new Test($this->testEmailAddress, 'email'));
$this->dispatch('success', 'Test Email sent.'); $this->dispatch('success', 'Test Email sent.');
}, },
$decaySeconds = 10, $decaySeconds = 10,

View File

@@ -64,6 +64,9 @@ class Pushover extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $serverUnreachablePushoverNotifications = true; public bool $serverUnreachablePushoverNotifications = true;
#[Validate(['boolean'])]
public bool $serverPatchPushoverNotifications = false;
public function mount() public function mount()
{ {
try { try {
@@ -95,6 +98,7 @@ class Pushover extends Component
$this->settings->server_disk_usage_pushover_notifications = $this->serverDiskUsagePushoverNotifications; $this->settings->server_disk_usage_pushover_notifications = $this->serverDiskUsagePushoverNotifications;
$this->settings->server_reachable_pushover_notifications = $this->serverReachablePushoverNotifications; $this->settings->server_reachable_pushover_notifications = $this->serverReachablePushoverNotifications;
$this->settings->server_unreachable_pushover_notifications = $this->serverUnreachablePushoverNotifications; $this->settings->server_unreachable_pushover_notifications = $this->serverUnreachablePushoverNotifications;
$this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications;
$this->settings->save(); $this->settings->save();
refreshSession(); refreshSession();
@@ -115,6 +119,7 @@ class Pushover extends Component
$this->serverDiskUsagePushoverNotifications = $this->settings->server_disk_usage_pushover_notifications; $this->serverDiskUsagePushoverNotifications = $this->settings->server_disk_usage_pushover_notifications;
$this->serverReachablePushoverNotifications = $this->settings->server_reachable_pushover_notifications; $this->serverReachablePushoverNotifications = $this->settings->server_reachable_pushover_notifications;
$this->serverUnreachablePushoverNotifications = $this->settings->server_unreachable_pushover_notifications; $this->serverUnreachablePushoverNotifications = $this->settings->server_unreachable_pushover_notifications;
$this->serverPatchPushoverNotifications = $this->settings->server_patch_pushover_notifications;
} }
} }

View File

@@ -61,6 +61,9 @@ class Slack extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $serverUnreachableSlackNotifications = true; public bool $serverUnreachableSlackNotifications = true;
#[Validate(['boolean'])]
public bool $serverPatchSlackNotifications = false;
public function mount() public function mount()
{ {
try { try {
@@ -91,6 +94,7 @@ class Slack extends Component
$this->settings->server_disk_usage_slack_notifications = $this->serverDiskUsageSlackNotifications; $this->settings->server_disk_usage_slack_notifications = $this->serverDiskUsageSlackNotifications;
$this->settings->server_reachable_slack_notifications = $this->serverReachableSlackNotifications; $this->settings->server_reachable_slack_notifications = $this->serverReachableSlackNotifications;
$this->settings->server_unreachable_slack_notifications = $this->serverUnreachableSlackNotifications; $this->settings->server_unreachable_slack_notifications = $this->serverUnreachableSlackNotifications;
$this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications;
$this->settings->save(); $this->settings->save();
refreshSession(); refreshSession();
@@ -110,6 +114,7 @@ class Slack extends Component
$this->serverDiskUsageSlackNotifications = $this->settings->server_disk_usage_slack_notifications; $this->serverDiskUsageSlackNotifications = $this->settings->server_disk_usage_slack_notifications;
$this->serverReachableSlackNotifications = $this->settings->server_reachable_slack_notifications; $this->serverReachableSlackNotifications = $this->settings->server_reachable_slack_notifications;
$this->serverUnreachableSlackNotifications = $this->settings->server_unreachable_slack_notifications; $this->serverUnreachableSlackNotifications = $this->settings->server_unreachable_slack_notifications;
$this->serverPatchSlackNotifications = $this->settings->server_patch_slack_notifications;
} }
} }

View File

@@ -64,6 +64,9 @@ class Telegram extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $serverUnreachableTelegramNotifications = true; public bool $serverUnreachableTelegramNotifications = true;
#[Validate(['boolean'])]
public bool $serverPatchTelegramNotifications = false;
#[Validate(['nullable', 'string'])] #[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsDeploymentSuccessThreadId = null; public ?string $telegramNotificationsDeploymentSuccessThreadId = null;
@@ -100,6 +103,9 @@ class Telegram extends Component
#[Validate(['nullable', 'string'])] #[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsServerUnreachableThreadId = null; public ?string $telegramNotificationsServerUnreachableThreadId = null;
#[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsServerPatchThreadId = null;
public function mount() public function mount()
{ {
try { try {
@@ -131,6 +137,7 @@ class Telegram extends Component
$this->settings->server_disk_usage_telegram_notifications = $this->serverDiskUsageTelegramNotifications; $this->settings->server_disk_usage_telegram_notifications = $this->serverDiskUsageTelegramNotifications;
$this->settings->server_reachable_telegram_notifications = $this->serverReachableTelegramNotifications; $this->settings->server_reachable_telegram_notifications = $this->serverReachableTelegramNotifications;
$this->settings->server_unreachable_telegram_notifications = $this->serverUnreachableTelegramNotifications; $this->settings->server_unreachable_telegram_notifications = $this->serverUnreachableTelegramNotifications;
$this->settings->server_patch_telegram_notifications = $this->serverPatchTelegramNotifications;
$this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId; $this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId;
$this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId; $this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId;
@@ -144,6 +151,7 @@ class Telegram extends Component
$this->settings->telegram_notifications_server_disk_usage_thread_id = $this->telegramNotificationsServerDiskUsageThreadId; $this->settings->telegram_notifications_server_disk_usage_thread_id = $this->telegramNotificationsServerDiskUsageThreadId;
$this->settings->telegram_notifications_server_reachable_thread_id = $this->telegramNotificationsServerReachableThreadId; $this->settings->telegram_notifications_server_reachable_thread_id = $this->telegramNotificationsServerReachableThreadId;
$this->settings->telegram_notifications_server_unreachable_thread_id = $this->telegramNotificationsServerUnreachableThreadId; $this->settings->telegram_notifications_server_unreachable_thread_id = $this->telegramNotificationsServerUnreachableThreadId;
$this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId;
$this->settings->save(); $this->settings->save();
} else { } else {
@@ -163,6 +171,7 @@ class Telegram extends Component
$this->serverDiskUsageTelegramNotifications = $this->settings->server_disk_usage_telegram_notifications; $this->serverDiskUsageTelegramNotifications = $this->settings->server_disk_usage_telegram_notifications;
$this->serverReachableTelegramNotifications = $this->settings->server_reachable_telegram_notifications; $this->serverReachableTelegramNotifications = $this->settings->server_reachable_telegram_notifications;
$this->serverUnreachableTelegramNotifications = $this->settings->server_unreachable_telegram_notifications; $this->serverUnreachableTelegramNotifications = $this->settings->server_unreachable_telegram_notifications;
$this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications;
$this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id; $this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id;
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id; $this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
@@ -176,6 +185,7 @@ class Telegram extends Component
$this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id; $this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id;
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id; $this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id; $this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
} }
} }

View File

@@ -17,7 +17,17 @@ class Configuration extends Component
public $servers; public $servers;
protected $listeners = ['buildPackUpdated' => '$refresh']; public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
"echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh',
'buildPackUpdated' => '$refresh',
'refresh' => '$refresh',
];
}
public function mount() public function mount()
{ {
@@ -40,6 +50,11 @@ class Configuration extends Component
$this->project = $project; $this->project = $project;
$this->environment = $environment; $this->environment = $environment;
$this->application = $application; $this->application = $application;
if ($this->application->deploymentType() === 'deploy_key' && $this->currentRoute === 'project.application.preview-deployments') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
}
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
} }

View File

@@ -28,6 +28,15 @@ class Index extends Component
protected $queryString = ['pull_request_id']; protected $queryString = ['pull_request_id'];
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
];
}
public function mount() public function mount()
{ {
$project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first(); $project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first();

View File

@@ -18,7 +18,15 @@ class Show extends Component
public $isKeepAliveOn = true; public $isKeepAliveOn = true;
protected $listeners = ['refreshQueue']; public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
'refreshQueue',
];
}
public function mount() public function mount()
{ {

View File

@@ -4,7 +4,6 @@ namespace App\Livewire\Project\Application;
use App\Actions\Application\StopApplication; use App\Actions\Application\StopApplication;
use App\Actions\Docker\GetContainersStatus; use App\Actions\Docker\GetContainersStatus;
use App\Events\ApplicationStatusChanged;
use App\Models\Application; use App\Models\Application;
use Livewire\Component; use Livewire\Component;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
@@ -28,7 +27,8 @@ class Heading extends Component
$teamId = auth()->user()->currentTeam()->id; $teamId = auth()->user()->currentTeam()->id;
return [ return [
"echo-private:team.{$teamId},ApplicationStatusChanged" => 'check_status', "echo-private:team.{$teamId},ServiceStatusChanged" => 'checkStatus',
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
'compose_loaded' => '$refresh', 'compose_loaded' => '$refresh',
'update_links' => '$refresh', 'update_links' => '$refresh',
]; ];
@@ -46,13 +46,12 @@ class Heading extends Component
$this->lastDeploymentLink = $this->application->gitCommitLink(data_get($lastDeployment, 'commit')); $this->lastDeploymentLink = $this->application->gitCommitLink(data_get($lastDeployment, 'commit'));
} }
public function check_status($showNotification = false) public function checkStatus()
{ {
if ($this->application->destination->server->isFunctional()) { if ($this->application->destination->server->isFunctional()) {
GetContainersStatus::dispatch($this->application->destination->server); GetContainersStatus::dispatch($this->application->destination->server);
} } else {
if ($showNotification) { $this->dispatch('error', 'Server is not functional.');
$this->dispatch('success', 'Success', 'Application status updated.');
} }
} }
@@ -111,16 +110,8 @@ class Heading extends Component
public function stop() public function stop()
{ {
StopApplication::run($this->application, false, $this->docker_cleanup); $this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.');
$this->application->status = 'exited'; StopApplication::dispatch($this->application, false, $this->docker_cleanup);
$this->application->save();
if ($this->application->additional_servers->count() > 0) {
$this->application->additional_servers->each(function ($server) {
$server->pivot->status = 'exited:unhealthy';
$server->pivot->save();
});
}
ApplicationStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
} }
public function restart() public function restart()

View File

@@ -138,13 +138,18 @@ class Previews extends Component
} }
} }
public function force_deploy_without_cache(int $pull_request_id, ?string $pull_request_html_url = null)
{
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true);
}
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null) public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null)
{ {
$this->add($pull_request_id, $pull_request_html_url); $this->add($pull_request_id, $pull_request_html_url);
$this->deploy($pull_request_id, $pull_request_html_url); $this->deploy($pull_request_id, $pull_request_html_url);
} }
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null) public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false)
{ {
try { try {
$this->setDeploymentUuid(); $this->setDeploymentUuid();
@@ -159,7 +164,7 @@ class Previews extends Component
$result = queue_application_deployment( $result = queue_application_deployment(
application: $this->application, application: $this->application,
deployment_uuid: $this->deployment_uuid, deployment_uuid: $this->deployment_uuid,
force_rebuild: false, force_rebuild: $force_rebuild,
pull_request_id: $pull_request_id, pull_request_id: $pull_request_id,
git_type: $found->git_type ?? null, git_type: $found->git_type ?? null,
); );
@@ -234,12 +239,24 @@ class Previews extends Component
private function stopContainers(array $containers, $server, int $timeout = 30) private function stopContainers(array $containers, $server, int $timeout = 30)
{ {
foreach ($containers as $container) { if (empty($containers)) {
$containerName = str_replace('/', '', $container['Names']); return;
instant_remote_process(command: [
"docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
} }
$containerNames = [];
foreach ($containers as $container) {
$containerNames[] = str_replace('/', '', $container['Names']);
}
$containerList = implode(' ', array_map('escapeshellarg', $containerNames));
$commands = [
"docker stop --time=$timeout $containerList",
"docker rm -f $containerList",
];
instant_remote_process(
command: $commands,
server: $server,
throwError: false
);
} }
} }

View File

@@ -54,7 +54,11 @@ class CloneMe extends Component
$this->project = Project::where('uuid', $project_uuid)->firstOrFail(); $this->project = Project::where('uuid', $project_uuid)->firstOrFail();
$this->environment = $this->project->environments->where('uuid', $this->environment_uuid)->first(); $this->environment = $this->project->environments->where('uuid', $this->environment_uuid)->first();
$this->project_id = $this->project->id; $this->project_id = $this->project->id;
$this->servers = currentTeam()->servers; $this->servers = currentTeam()
->servers()
->with('destinations')
->get()
->reject(fn ($server) => $server->isBuildServer());
$this->newName = str($this->project->name.'-clone-'.(string) new Cuid2)->slug(); $this->newName = str($this->project->name.'-clone-'.(string) new Cuid2)->slug();
} }

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use Auth;
use Livewire\Component; use Livewire\Component;
class Configuration extends Component class Configuration extends Component
@@ -14,6 +15,15 @@ class Configuration extends Component
public $environment; public $environment;
public function getListeners()
{
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
];
}
public function mount() public function mount()
{ {
$this->currentRoute = request()->route()->getName(); $this->currentRoute = request()->route()->getName();

View File

@@ -6,7 +6,7 @@ use App\Actions\Database\RestartDatabase;
use App\Actions\Database\StartDatabase; use App\Actions\Database\StartDatabase;
use App\Actions\Database\StopDatabase; use App\Actions\Database\StopDatabase;
use App\Actions\Docker\GetContainersStatus; use App\Actions\Docker\GetContainersStatus;
use Illuminate\Support\Facades\Auth; use App\Events\ServiceStatusChanged;
use Livewire\Component; use Livewire\Component;
class Heading extends Component class Heading extends Component
@@ -19,36 +19,40 @@ class Heading extends Component
public function getListeners() public function getListeners()
{ {
$userId = Auth::id(); $teamId = auth()->user()->currentTeam()->id;
return [ return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'activityFinished', "echo-private:team.{$teamId},ServiceStatusChanged" => 'checkStatus',
"echo-private:team.{$teamId},ServiceChecked" => 'activityFinished',
'refresh' => '$refresh',
'compose_loaded' => '$refresh',
'update_links' => '$refresh',
]; ];
} }
public function activityFinished() public function activityFinished()
{ {
$this->database->update([ try {
'started_at' => now(), $this->database->started_at ??= now();
]); $this->database->save();
$this->check_status();
if (is_null($this->database->config_hash) || $this->database->isConfigurationChanged()) { if (is_null($this->database->config_hash) || $this->database->isConfigurationChanged()) {
$this->database->isConfigurationChanged(true); $this->database->isConfigurationChanged(true);
$this->dispatch('configurationChanged'); }
} else {
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
} catch (\Exception $e) {
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
} }
} }
public function check_status($showNotification = false) public function checkStatus()
{ {
if ($this->database->destination->server->isFunctional()) { if ($this->database->destination->server->isFunctional()) {
GetContainersStatus::run($this->database->destination->server); GetContainersStatus::dispatch($this->database->destination->server);
} } else {
$this->dispatch('error', 'Server is not functional.');
if ($showNotification) {
$this->dispatch('success', 'Database status updated.');
} }
} }
@@ -59,23 +63,24 @@ class Heading extends Component
public function stop() public function stop()
{ {
StopDatabase::run($this->database, false, $this->docker_cleanup); try {
$this->database->status = 'exited'; $this->dispatch('info', 'Gracefully stopping database.');
$this->database->save(); StopDatabase::dispatch($this->database, false, $this->docker_cleanup);
$this->check_status(); } catch (\Exception $e) {
$this->dispatch('refresh'); $this->dispatch('error', $e->getMessage());
}
} }
public function restart() public function restart()
{ {
$activity = RestartDatabase::run($this->database); $activity = RestartDatabase::run($this->database);
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} }
public function start() public function start()
{ {
$activity = StartDatabase::run($this->database); $activity = StartDatabase::run($this->database);
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} }
public function render() public function render()

View File

@@ -25,21 +25,7 @@ class DockerCompose extends Component
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->query = request()->query(); $this->query = request()->query();
if (isDev()) { if (isDev()) {
$this->dockerComposeRaw = 'services: $this->dockerComposeRaw = file_get_contents(base_path('templates/test-database-detection.yaml'));
appsmith:
build:
context: .
dockerfile_inline: |
FROM nginx
ARG GIT_COMMIT
ARG GIT_BRANCH
RUN echo "Hello World ${GIT_COMMIT} ${GIT_BRANCH}"
args:
- GIT_COMMIT=cdc3b19
- GIT_BRANCH=${GIT_BRANCH}
environment:
- APPSMITH_MAIL_ENABLED=${APPSMITH_MAIL_ENABLED}
';
} }
} }

View File

@@ -57,13 +57,18 @@ class Select extends Component
public function mount() public function mount()
{ {
$this->parameters = get_route_parameters(); try {
if (isDev()) { $this->parameters = get_route_parameters();
$this->existingPostgresqlUrl = 'postgres://coolify:password@coolify-db:5432'; if (isDev()) {
$this->existingPostgresqlUrl = 'postgres://coolify:password@coolify-db:5432';
}
$projectUuid = data_get($this->parameters, 'project_uuid');
$project = Project::whereUuid($projectUuid)->firstOrFail();
$this->environments = $project->environments;
$this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name;
} catch (\Exception $e) {
return handleError($e, $this);
} }
$projectUuid = data_get($this->parameters, 'project_uuid');
$this->environments = Project::whereUuid($projectUuid)->first()->environments;
$this->selectedEnvironment = data_get($this->parameters, 'environment_uuid');
} }
public function render() public function render()
@@ -73,9 +78,11 @@ class Select extends Component
public function updatedSelectedEnvironment() public function updatedSelectedEnvironment()
{ {
$environmentUuid = $this->environments->where('name', $this->selectedEnvironment)->first()->uuid;
return redirect()->route('project.resource.create', [ return redirect()->route('project.resource.create', [
'project_uuid' => $this->parameters['project_uuid'], 'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->selectedEnvironment, 'environment_uuid' => $environmentUuid,
]); ]);
} }

View File

@@ -2,7 +2,6 @@
namespace App\Livewire\Project\Service; namespace App\Livewire\Project\Service;
use App\Actions\Docker\GetContainersStatus;
use App\Models\Service; use App\Models\Service;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
@@ -27,13 +26,10 @@ class Configuration extends Component
public function getListeners() public function getListeners()
{ {
$userId = Auth::id(); $teamId = Auth::user()->currentTeam()->id;
return [ return [
"echo-private:user.{$userId},ServiceStatusChanged" => 'check_status', "echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
'refreshStatus' => '$refresh',
'check_status',
'refreshServices',
]; ];
} }
@@ -97,19 +93,15 @@ class Configuration extends Component
} }
} }
public function check_status() public function serviceChecked()
{ {
try { try {
if ($this->service->server->isFunctional()) {
GetContainersStatus::dispatch($this->service->server);
}
$this->service->applications->each(function ($application) { $this->service->applications->each(function ($application) {
$application->refresh(); $application->refresh();
}); });
$this->service->databases->each(function ($database) { $this->service->databases->each(function ($database) {
$database->refresh(); $database->refresh();
}); });
$this->dispatch('refreshStatus');
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View File

@@ -47,7 +47,6 @@ class EditDomain extends Component
$this->application->service->parse(); $this->application->service->parse();
$this->dispatch('refresh'); $this->dispatch('refresh');
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
$this->dispatch('refreshStatus');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$originalFqdn = $this->application->getOriginal('fqdn'); $originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) { if ($originalFqdn !== $this->application->fqdn) {

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Service; namespace App\Livewire\Project\Service;
use App\Actions\Docker\GetContainersStatus;
use App\Actions\Service\StartService; use App\Actions\Service\StartService;
use App\Actions\Service\StopService; use App\Actions\Service\StopService;
use App\Enums\ProcessStatus; use App\Enums\ProcessStatus;
@@ -11,7 +12,7 @@ use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Models\Activity;
class Navbar extends Component class Heading extends Component
{ {
public Service $service; public Service $service;
@@ -35,35 +36,44 @@ class Navbar extends Component
public function getListeners() public function getListeners()
{ {
$userId = Auth::id(); $teamId = Auth::user()->currentTeam()->id;
return [ return [
"echo-private:user.{$userId},ServiceStatusChanged" => 'serviceStarted', "echo-private:team.{$teamId},ServiceStatusChanged" => 'checkStatus',
"echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
'refresh' => '$refresh',
'envsUpdated' => '$refresh', 'envsUpdated' => '$refresh',
'refreshStatus' => '$refresh',
]; ];
} }
public function serviceStarted() public function checkStatus()
{ {
// $this->dispatch('success', 'Service status changed.'); if ($this->service->server->isFunctional()) {
if (is_null($this->service->config_hash) || $this->service->isConfigurationChanged()) { GetContainersStatus::dispatch($this->service->server);
$this->service->isConfigurationChanged(true);
$this->dispatch('configurationChanged');
} else { } else {
$this->dispatch('configurationChanged'); $this->dispatch('error', 'Server is not functional.');
} }
} }
public function check_status_without_notification() public function serviceChecked()
{ {
$this->dispatch('check_status'); try {
} $this->service->applications->each(function ($application) {
$application->refresh();
});
$this->service->databases->each(function ($database) {
$database->refresh();
});
if (is_null($this->service->config_hash) || $this->service->isConfigurationChanged()) {
$this->service->isConfigurationChanged(true);
}
$this->dispatch('configurationChanged');
} catch (\Exception $e) {
return handleError($e, $this);
} finally {
$this->dispatch('refresh')->self();
}
public function check_status()
{
$this->dispatch('check_status');
$this->dispatch('success', 'Service status updated.');
} }
public function checkDeployments() public function checkDeployments()
@@ -86,34 +96,32 @@ class Navbar extends Component
public function start() public function start()
{ {
$activity = StartService::run($this->service, pullLatestImages: true); $activity = StartService::run($this->service, pullLatestImages: true);
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} }
public function forceDeploy() public function forceDeploy()
{ {
try { try {
$activities = Activity::where('properties->type_uuid', $this->service->uuid)->where('properties->status', ProcessStatus::IN_PROGRESS->value)->orWhere('properties->status', ProcessStatus::QUEUED->value)->get(); $activities = Activity::where('properties->type_uuid', $this->service->uuid)
->where(function ($q) {
$q->where('properties->status', ProcessStatus::IN_PROGRESS->value)
->orWhere('properties->status', ProcessStatus::QUEUED->value);
})->get();
foreach ($activities as $activity) { foreach ($activities as $activity) {
$activity->properties->status = ProcessStatus::ERROR->value; $activity->properties->status = ProcessStatus::ERROR->value;
$activity->save(); $activity->save();
} }
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
} }
} }
public function stop($cleanupContainers = false) public function stop()
{ {
try { try {
StopService::run($this->service, false, $this->docker_cleanup); StopService::dispatch($this->service, false, $this->docker_cleanup);
ServiceStatusChanged::dispatch();
if ($cleanupContainers) {
$this->dispatch('success', 'Containers cleaned up.');
} else {
$this->dispatch('success', 'Service stopped.');
}
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
} }
@@ -128,7 +136,7 @@ class Navbar extends Component
return; return;
} }
$activity = StartService::run($this->service, stopBeforeStart: true); $activity = StartService::run($this->service, stopBeforeStart: true);
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} }
public function pullAndRestartEvent() public function pullAndRestartEvent()
@@ -140,12 +148,12 @@ class Navbar extends Component
return; return;
} }
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} }
public function render() public function render()
{ {
return view('livewire.project.service.navbar', [ return view('livewire.project.service.heading', [
'checkboxes' => [ 'checkboxes' => [
['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')],
], ],

View File

@@ -26,6 +26,8 @@ class Destination extends Component
return [ return [
"echo-private:team.{$teamId},ApplicationStatusChanged" => 'loadData', "echo-private:team.{$teamId},ApplicationStatusChanged" => 'loadData',
"echo-private:team.{$teamId},ServiceStatusChanged" => 'mount',
'refresh' => 'mount',
]; ];
} }
@@ -114,22 +116,20 @@ class Destination extends Component
$this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
$this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
$this->refreshServers(); $this->refreshServers();
$this->resource->refresh();
} }
public function refreshServers() public function refreshServers()
{ {
GetContainersStatus::run($this->resource->destination->server); GetContainersStatus::run($this->resource->destination->server);
// ContainerStatusJob::dispatchSync($this->resource->destination->server);
$this->loadData(); $this->loadData();
$this->dispatch('refresh'); $this->dispatch('refresh');
ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id'));
} }
public function addServer(int $network_id, int $server_id) public function addServer(int $network_id, int $server_id)
{ {
$this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]); $this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]);
$this->loadData(); $this->dispatch('refresh');
ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id'));
} }
public function removeServer(int $network_id, int $server_id, $password) public function removeServer(int $network_id, int $server_id, $password)
@@ -144,7 +144,7 @@ class Destination extends Component
} }
if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) { if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) {
$this->dispatch('error', 'You cannot remove this destination server.', 'You are trying to remove the main server.'); $this->dispatch('error', 'You are trying to remove the main server.');
return; return;
} }
@@ -152,6 +152,7 @@ class Destination extends Component
StopApplicationOneServer::run($this->resource, $server); StopApplicationOneServer::run($this->resource, $server);
$this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
$this->loadData(); $this->loadData();
$this->dispatch('refresh');
ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id'));
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); return handleError($e, $this);

View File

@@ -178,16 +178,6 @@ class All extends Component
} }
} }
// Debug information
\Log::info('Environment variables update status', [
'deletedCount' => $deletedCount,
'updatedCount' => $updatedCount,
'deletedPreviewCount' => $deletedPreviewCount ?? 0,
'updatedPreviewCount' => $updatedPreviewCount ?? 0,
'changesMade' => $changesMade,
'errorOccurred' => $errorOccurred,
]);
// Only show success message if changes were actually made and no errors occurred // Only show success message if changes were actually made and no errors occurred
if ($changesMade && ! $errorOccurred) { if ($changesMade && ! $errorOccurred) {
$this->dispatch('success', 'Environment variables updated.'); $this->dispatch('success', 'Environment variables updated.');

View File

@@ -29,6 +29,8 @@ class ExecuteContainerCommand extends Component
public bool $hasShell = true; public bool $hasShell = true;
public bool $isConnecting = true;
protected $rules = [ protected $rules = [
'server' => 'required', 'server' => 'required',
'container' => 'required', 'container' => 'required',
@@ -165,7 +167,6 @@ class ExecuteContainerCommand extends Component
if ($this->server->isForceDisabled()) { if ($this->server->isForceDisabled()) {
throw new \RuntimeException('Server is disabled.'); throw new \RuntimeException('Server is disabled.');
} }
$this->hasShell = true;
$this->dispatch( $this->dispatch(
'send-terminal-command', 'send-terminal-command',
false, false,
@@ -174,6 +175,8 @@ class ExecuteContainerCommand extends Component
); );
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} finally {
$this->isConnecting = false;
} }
} }
@@ -232,6 +235,8 @@ class ExecuteContainerCommand extends Component
); );
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} finally {
$this->isConnecting = false;
} }
} }

View File

@@ -25,6 +25,8 @@ class Logs extends Component
public Collection $containers; public Collection $containers;
public array $serverContainers = [];
public $container = []; public $container = [];
public $parameters; public $parameters;
@@ -37,25 +39,60 @@ class Logs extends Component
public $cpu; public $cpu;
public function loadContainers($server_id) public bool $containersLoaded = false;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
];
}
public function loadAllContainers()
{ {
try { try {
$server = $this->servers->firstWhere('id', $server_id); foreach ($this->servers as $server) {
if (! $server->isFunctional()) { $this->serverContainers[$server->id] = $this->getContainersForServer($server);
return;
} }
$this->containersLoaded = true;
} catch (\Exception $e) {
$this->containersLoaded = true; // Set to true to stop loading spinner
return handleError($e, $this);
}
}
private function getContainersForServer($server)
{
if (! $server->isFunctional()) {
return [];
}
try {
if ($server->isSwarm()) { if ($server->isSwarm()) {
$containers = collect([ $containers = collect([
[ [
'ID' => $this->resource->uuid,
'Names' => $this->resource->uuid.'_'.$this->resource->uuid, 'Names' => $this->resource->uuid.'_'.$this->resource->uuid,
], ],
]); ]);
return $containers->toArray();
} else { } else {
$containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true); $containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true);
if ($containers && $containers->count() > 0) {
return $containers->sort()->toArray();
}
return [];
} }
$server->containers = $containers->sort();
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); // Log error but don't fail the entire operation
ray("Error loading containers for server {$server->name}: ".$e->getMessage());
return [];
} }
} }
@@ -64,6 +101,7 @@ class Logs extends Component
try { try {
$this->containers = collect(); $this->containers = collect();
$this->servers = collect(); $this->servers = collect();
$this->serverContainers = [];
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->query = request()->query(); $this->query = request()->query();
if (data_get($this->parameters, 'application_uuid')) { if (data_get($this->parameters, 'application_uuid')) {
@@ -71,7 +109,8 @@ class Logs extends Component
$this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail(); $this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail();
$this->status = $this->resource->status; $this->status = $this->resource->status;
if ($this->resource->destination->server->isFunctional()) { if ($this->resource->destination->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->destination->server); $server = $this->resource->destination->server;
$this->servers = $this->servers->push($server);
} }
foreach ($this->resource->additional_servers as $server) { foreach ($this->resource->additional_servers as $server) {
if ($server->isFunctional()) { if ($server->isFunctional()) {
@@ -87,7 +126,8 @@ class Logs extends Component
$this->resource = $resource; $this->resource = $resource;
$this->status = $this->resource->status; $this->status = $this->resource->status;
if ($this->resource->destination->server->isFunctional()) { if ($this->resource->destination->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->destination->server); $server = $this->resource->destination->server;
$this->servers = $this->servers->push($server);
} }
$this->container = $this->resource->uuid; $this->container = $this->resource->uuid;
$this->containers->push($this->container); $this->containers->push($this->container);
@@ -101,7 +141,8 @@ class Logs extends Component
$this->containers->push(data_get($database, 'name').'-'.data_get($this->resource, 'uuid')); $this->containers->push(data_get($database, 'name').'-'.data_get($this->resource, 'uuid'));
}); });
if ($this->resource->server->isFunctional()) { if ($this->resource->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->server); $server = $this->resource->server;
$this->servers = $this->servers->push($server);
} }
} }
$this->containers = $this->containers->sort(); $this->containers = $this->containers->sort();

View File

@@ -35,7 +35,7 @@ class ResourceOperations extends Component
$this->projectUuid = data_get($parameters, 'project_uuid'); $this->projectUuid = data_get($parameters, 'project_uuid');
$this->environmentUuid = data_get($parameters, 'environment_uuid'); $this->environmentUuid = data_get($parameters, 'environment_uuid');
$this->projects = Project::ownedByCurrentTeam()->get(); $this->projects = Project::ownedByCurrentTeam()->get();
$this->servers = currentTeam()->servers; $this->servers = currentTeam()->servers->filter(fn ($server) => ! $server->isBuildServer());
} }
public function toggleVolumeCloning(bool $value) public function toggleVolumeCloning(bool $value)

View File

@@ -46,6 +46,15 @@ class Show extends Component
#[Locked] #[Locked]
public string $task_uuid; public string $task_uuid;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
];
}
public function mount(string $task_uuid, string $project_uuid, string $environment_uuid, ?string $application_uuid = null, ?string $service_uuid = null) public function mount(string $task_uuid, string $project_uuid, string $environment_uuid, ?string $application_uuid = null, ?string $service_uuid = null)
{ {
try { try {

View File

@@ -44,6 +44,9 @@ class Terminal extends Component
public function sendTerminalCommand($isContainer, $identifier, $serverUuid) public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
{ {
$server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->firstOrFail(); $server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->firstOrFail();
if (! $server->isTerminalEnabled() || $server->isForceDisabled()) {
throw new \RuntimeException('Terminal access is disabled on this server.');
}
if ($isContainer) { if ($isContainer) {
// Validate container identifier format (alphanumeric, dashes, and underscores only) // Validate container identifier format (alphanumeric, dashes, and underscores only)

View File

@@ -2,11 +2,10 @@
namespace App\Livewire\Server; namespace App\Livewire\Server;
use App\Helpers\SslHelper; use App\Models\InstanceSettings;
use App\Jobs\RegenerateSslCertJob;
use App\Models\Server; use App\Models\Server;
use App\Models\SslCertificate; use Illuminate\Support\Facades\Auth;
use Carbon\Carbon; use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
@@ -14,14 +13,6 @@ class Advanced extends Component
{ {
public Server $server; public Server $server;
public ?SslCertificate $caCertificate = null;
public $showCertificate = false;
public $certificateContent = '';
public ?Carbon $certificateValidUntil = null;
public array $parameters = []; public array $parameters = [];
#[Validate(['string'])] #[Validate(['string'])]
@@ -36,105 +27,52 @@ class Advanced extends Component
#[Validate(['integer', 'min:1'])] #[Validate(['integer', 'min:1'])]
public int $dynamicTimeout = 1; public int $dynamicTimeout = 1;
#[Validate(['boolean'])]
public bool $isTerminalEnabled = false;
public function mount(string $server_uuid) public function mount(string $server_uuid)
{ {
try { try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->syncData(); $this->syncData();
$this->loadCaCertificate();
} catch (\Throwable) { } catch (\Throwable) {
return redirect()->route('server.index'); return redirect()->route('server.index');
} }
} }
public function loadCaCertificate() public function toggleTerminal($password)
{
$this->caCertificate = SslCertificate::where('server_id', $this->server->id)->where('is_ca_certificate', true)->first();
if ($this->caCertificate) {
$this->certificateContent = $this->caCertificate->ssl_certificate;
$this->certificateValidUntil = $this->caCertificate->valid_until;
}
}
public function toggleCertificate()
{
$this->showCertificate = ! $this->showCertificate;
}
public function saveCaCertificate()
{ {
try { try {
if (! $this->certificateContent) { // Check if user is admin or owner
throw new \Exception('Certificate content cannot be empty.'); if (! auth()->user()->isAdmin()) {
throw new \Exception('Only team administrators and owners can modify terminal access.');
} }
if (! openssl_x509_read($this->certificateContent)) { // Verify password unless two-step confirmation is disabled
throw new \Exception('Invalid certificate format.'); if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
} }
if ($this->caCertificate) { // Toggle the terminal setting
$this->caCertificate->ssl_certificate = $this->certificateContent; $this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled;
$this->caCertificate->save(); $this->server->settings->save();
$this->loadCaCertificate(); // Update the local property
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
$this->writeCertificateToServer(); $status = $this->isTerminalEnabled ? 'enabled' : 'disabled';
$this->dispatch('success', "Terminal access has been {$status}.");
dispatch(new RegenerateSslCertJob(
server_id: $this->server->id,
force_regeneration: true
));
}
$this->dispatch('success', 'CA Certificate saved successfully.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
} }
public function regenerateCaCertificate()
{
try {
SslHelper::generateSslCertificate(
commonName: 'Coolify CA Certificate',
serverId: $this->server->id,
isCaCertificate: true,
validityDays: 10 * 365
);
$this->loadCaCertificate();
$this->writeCertificateToServer();
dispatch(new RegenerateSslCertJob(
server_id: $this->server->id,
force_regeneration: true
));
$this->loadCaCertificate();
$this->dispatch('success', 'CA Certificate regenerated successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function writeCertificateToServer()
{
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt",
"chmod 644 $caCertPath/coolify-ca.crt",
]);
remote_process($commands, $this->server);
}
public function syncData(bool $toModel = false) public function syncData(bool $toModel = false)
{ {
if ($toModel) { if ($toModel) {
@@ -149,6 +87,7 @@ class Advanced extends Component
$this->dynamicTimeout = $this->server->settings->dynamic_timeout; $this->dynamicTimeout = $this->server->settings->dynamic_timeout;
$this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold; $this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold;
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency; $this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
} }
} }

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Livewire\Server\CaCertificate;
use App\Helpers\SslHelper;
use App\Jobs\RegenerateSslCertJob;
use App\Models\Server;
use App\Models\SslCertificate;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Show extends Component
{
#[Locked]
public Server $server;
public ?SslCertificate $caCertificate = null;
public $showCertificate = false;
public $certificateContent = '';
public ?Carbon $certificateValidUntil = null;
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->loadCaCertificate();
} catch (\Throwable $e) {
return redirect()->route('server.index');
}
}
public function loadCaCertificate()
{
$this->caCertificate = SslCertificate::where('server_id', $this->server->id)->where('is_ca_certificate', true)->first();
if ($this->caCertificate) {
$this->certificateContent = $this->caCertificate->ssl_certificate;
$this->certificateValidUntil = $this->caCertificate->valid_until;
}
}
public function toggleCertificate()
{
$this->showCertificate = ! $this->showCertificate;
}
public function saveCaCertificate()
{
try {
if (! $this->certificateContent) {
throw new \Exception('Certificate content cannot be empty.');
}
if (! openssl_x509_read($this->certificateContent)) {
throw new \Exception('Invalid certificate format.');
}
if ($this->caCertificate) {
$this->caCertificate->ssl_certificate = $this->certificateContent;
$this->caCertificate->save();
$this->loadCaCertificate();
$this->writeCertificateToServer();
dispatch(new RegenerateSslCertJob(
server_id: $this->server->id,
force_regeneration: true
));
}
$this->dispatch('success', 'CA Certificate saved successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function regenerateCaCertificate()
{
try {
SslHelper::generateSslCertificate(
commonName: 'Coolify CA Certificate',
serverId: $this->server->id,
isCaCertificate: true,
validityDays: 10 * 365
);
$this->loadCaCertificate();
$this->writeCertificateToServer();
dispatch(new RegenerateSslCertJob(
server_id: $this->server->id,
force_regeneration: true
));
$this->loadCaCertificate();
$this->dispatch('success', 'CA Certificate regenerated successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function writeCertificateToServer()
{
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt",
"chmod 644 $caCertPath/coolify-ca.crt",
]);
remote_process($commands, $this->server);
}
public function render()
{
return view('livewire.server.ca-certificate.show');
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Livewire\Server;
use App\Actions\Server\ConfigureCloudflared;
use App\Models\Server;
use Livewire\Attributes\Validate;
use Livewire\Component;
class CloudflareTunnel extends Component
{
public Server $server;
#[Validate(['required', 'string'])]
public string $cloudflare_token;
#[Validate(['required', 'string'])]
public string $ssh_domain;
#[Validate(['required', 'boolean'])]
public bool $isCloudflareTunnelsEnabled;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},CloudflareTunnelConfigured" => 'refresh',
];
}
public function refresh()
{
$this->server->refresh();
$this->isCloudflareTunnelsEnabled = $this->server->settings->is_cloudflare_tunnel;
}
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
if ($this->server->isLocalhost()) {
return redirect()->route('server.show', ['server_uuid' => $server_uuid]);
}
$this->isCloudflareTunnelsEnabled = $this->server->settings->is_cloudflare_tunnel;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function toggleCloudflareTunnels()
{
try {
remote_process(['docker rm -f coolify-cloudflared'], $this->server, false, 10);
$this->isCloudflareTunnelsEnabled = false;
$this->server->settings->is_cloudflare_tunnel = false;
$this->server->settings->save();
if ($this->server->ip_previous) {
$this->server->update(['ip' => $this->server->ip_previous]);
$this->dispatch('success', 'Cloudflare Tunnel disabled.<br><br>Manually updated the server IP address to its previous IP address.');
} else {
$this->dispatch('warning', 'Cloudflare Tunnel disabled. Action required: Update the server IP address to its real IP address in the Advanced settings.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function manualCloudflareConfig()
{
$this->isCloudflareTunnelsEnabled = true;
$this->server->settings->is_cloudflare_tunnel = true;
$this->server->settings->save();
$this->server->refresh();
$this->dispatch('success', 'Cloudflare Tunnel enabled.');
}
public function automatedCloudflareConfig()
{
try {
if (str($this->ssh_domain)->contains('https://')) {
$this->ssh_domain = str($this->ssh_domain)->replace('https://', '')->replace('http://', '')->trim();
$this->ssh_domain = str($this->ssh_domain)->replace('/', '');
}
$activity = ConfigureCloudflared::run($this->server, $this->cloudflare_token, $this->ssh_domain);
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.cloudflare-tunnel');
}
}

View File

@@ -1,54 +0,0 @@
<?php
namespace App\Livewire\Server;
use App\Models\Server;
use Livewire\Attributes\Validate;
use Livewire\Component;
class CloudflareTunnels extends Component
{
public Server $server;
#[Validate(['required', 'boolean'])]
public bool $isCloudflareTunnelsEnabled;
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
if ($this->server->isLocalhost()) {
return redirect()->route('server.show', ['server_uuid' => $server_uuid]);
}
$this->isCloudflareTunnelsEnabled = $this->server->settings->is_cloudflare_tunnel;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->validate();
$this->server->settings->is_cloudflare_tunnel = $this->isCloudflareTunnelsEnabled;
$this->server->settings->save();
$this->dispatch('success', 'Server updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function manualCloudflareConfig()
{
$this->isCloudflareTunnelsEnabled = true;
$this->server->settings->is_cloudflare_tunnel = true;
$this->server->settings->save();
$this->server->refresh();
$this->dispatch('success', 'Cloudflare Tunnels enabled.');
}
public function render()
{
return view('livewire.server.cloudflare-tunnels');
}
}

View File

@@ -1,54 +0,0 @@
<?php
namespace App\Livewire\Server;
use App\Actions\Server\ConfigureCloudflared;
use App\Models\Server;
use Livewire\Component;
class ConfigureCloudflareTunnels extends Component
{
public $server_id;
public string $cloudflare_token;
public string $ssh_domain;
public function alreadyConfigured()
{
try {
$server = Server::ownedByCurrentTeam()->where('id', $this->server_id)->firstOrFail();
$server->settings->is_cloudflare_tunnel = true;
$server->settings->save();
$this->dispatch('success', 'Cloudflare Tunnels configured successfully.');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {
if (str($this->ssh_domain)->contains('https://')) {
$this->ssh_domain = str($this->ssh_domain)->replace('https://', '')->replace('http://', '')->trim();
// remove / from the end
$this->ssh_domain = str($this->ssh_domain)->replace('/', '');
}
$server = Server::ownedByCurrentTeam()->where('id', $this->server_id)->firstOrFail();
ConfigureCloudflared::dispatch($server, $this->cloudflare_token);
$server->settings->is_cloudflare_tunnel = true;
$server->ip = $this->ssh_domain;
$server->save();
$server->settings->save();
$this->dispatch('info', 'Cloudflare Tunnels configuration started.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.configure-cloudflare-tunnels');
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Livewire\Server;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Actions\Proxy\StopProxy;
use App\Jobs\RestartProxyJob;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Livewire\Component;
class Navbar extends Component
{
public Server $server;
public bool $isChecking = false;
public ?string $currentRoute = null;
public bool $traefikDashboardAvailable = false;
public ?string $serverIp = null;
public ?string $proxyStatus = 'unknown';
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
'refreshServerShow' => '$refresh',
"echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification',
];
}
public function mount(Server $server)
{
$this->server = $server;
$this->currentRoute = request()->route()->getName();
$this->serverIp = $this->server->id === 0 ? base_ip() : $this->server->ip;
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
$this->loadProxyConfiguration();
}
public function loadProxyConfiguration()
{
try {
if ($this->proxyStatus === 'running') {
$this->traefikDashboardAvailable = ProxyDashboardCacheService::isTraefikDashboardAvailableFromCache($this->server);
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function restart()
{
try {
RestartProxyJob::dispatch($this->server);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function checkProxy()
{
try {
CheckProxy::run($this->server, true);
$this->dispatch('startProxy')->self();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function startProxy()
{
try {
$activity = StartProxy::run($this->server, force: true);
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function stop(bool $forceStop = true)
{
try {
StopProxy::dispatch($this->server, $forceStop);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function checkProxyStatus()
{
if ($this->isChecking) {
return;
}
try {
$this->isChecking = true;
CheckProxy::run($this->server, true);
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->isChecking = false;
$this->showNotification();
}
}
public function showNotification()
{
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
$forceStop = $this->server->proxy->force_stop ?? false;
switch ($this->proxyStatus) {
case 'running':
$this->loadProxyConfiguration();
break;
case 'restarting':
$this->dispatch('info', 'Initiating proxy restart.');
break;
default:
break;
}
}
public function render()
{
return view('livewire.server.navbar');
}
}

View File

@@ -19,7 +19,7 @@ class Proxy extends Component
public ?string $redirect_url = null; public ?string $redirect_url = null;
protected $listeners = ['proxyStatusUpdated', 'saveConfiguration' => 'submit']; protected $listeners = ['saveConfiguration' => 'submit'];
protected $rules = [ protected $rules = [
'server.settings.generate_exact_labels' => 'required|boolean', 'server.settings.generate_exact_labels' => 'required|boolean',
@@ -32,15 +32,16 @@ class Proxy extends Component
$this->redirect_url = data_get($this->server, 'proxy.redirect_url'); $this->redirect_url = data_get($this->server, 'proxy.redirect_url');
} }
public function proxyStatusUpdated() // public function proxyStatusUpdated()
{ // {
$this->dispatch('refresh')->self(); // $this->dispatch('refresh')->self();
} // }
public function changeProxy() public function changeProxy()
{ {
$this->server->proxy = null; $this->server->proxy = null;
$this->server->save(); $this->server->save();
$this->dispatch('reloadWindow'); $this->dispatch('reloadWindow');
} }
@@ -49,6 +50,7 @@ class Proxy extends Component
try { try {
$this->server->changeProxy($proxy_type, async: false); $this->server->changeProxy($proxy_type, async: false);
$this->selectedProxy = $this->server->proxy->type; $this->selectedProxy = $this->server->proxy->type;
$this->dispatch('reloadWindow'); $this->dispatch('reloadWindow');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@@ -107,11 +109,6 @@ class Proxy extends Component
{ {
try { try {
$this->proxy_settings = CheckConfiguration::run($this->server); $this->proxy_settings = CheckConfiguration::run($this->server);
if (str($this->proxy_settings)->contains('--api.dashboard=true') && str($this->proxy_settings)->contains('--api.insecure=true')) {
$this->dispatch('traefikDashboardAvailable', true);
} else {
$this->dispatch('traefikDashboardAvailable', false);
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View File

@@ -1,106 +0,0 @@
<?php
namespace App\Livewire\Server\Proxy;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Actions\Proxy\StopProxy;
use App\Events\ProxyStatusChanged;
use App\Jobs\RestartProxyJob;
use App\Models\Server;
use Livewire\Component;
class Deploy extends Component
{
public Server $server;
public bool $traefikDashboardAvailable = false;
public ?string $currentRoute = null;
public ?string $serverIp = null;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ProxyStatusChanged" => 'proxyStarted',
'proxyStatusUpdated',
'traefikDashboardAvailable',
'serverRefresh' => 'proxyStatusUpdated',
'checkProxy',
'startProxy',
'proxyChanged' => 'proxyStatusUpdated',
];
}
public function mount()
{
if ($this->server->id === 0) {
$this->serverIp = base_ip();
} else {
$this->serverIp = $this->server->ip;
}
$this->currentRoute = request()->route()->getName();
}
public function traefikDashboardAvailable(bool $data)
{
$this->traefikDashboardAvailable = $data;
}
public function proxyStarted()
{
CheckProxy::run($this->server, true);
$this->dispatch('proxyStatusUpdated');
}
public function proxyStatusUpdated()
{
$this->server->refresh();
}
public function restart()
{
try {
RestartProxyJob::dispatch($this->server);
$this->dispatch('checkProxy');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function checkProxy()
{
try {
CheckProxy::run($this->server, true);
$this->dispatch('startProxyPolling');
$this->dispatch('proxyChecked');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function startProxy()
{
try {
$this->server->proxy->force_stop = false;
$this->server->save();
$activity = StartProxy::run($this->server, force: true);
$this->dispatch('activityMonitor', $activity->id, ProxyStatusChanged::class);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function stop(bool $forceStop = true)
{
try {
StopProxy::run($this->server, $forceStop);
$this->dispatch('proxyStatusUpdated');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
}

View File

@@ -19,7 +19,7 @@ class DynamicConfigurations extends Component
$teamId = auth()->user()->currentTeam()->id; $teamId = auth()->user()->currentTeam()->id;
return [ return [
"echo-private:team.{$teamId},ProxyStatusChanged" => 'loadDynamicConfigurations', "echo-private:team.{$teamId},ProxyStatusChangedUI" => 'loadDynamicConfigurations',
'loadDynamicConfigurations', 'loadDynamicConfigurations',
]; ];
} }
@@ -28,6 +28,11 @@ class DynamicConfigurations extends Component
'contents.*' => 'nullable|string', 'contents.*' => 'nullable|string',
]; ];
public function initLoadDynamicConfigurations()
{
$this->loadDynamicConfigurations();
}
public function loadDynamicConfigurations() public function loadDynamicConfigurations()
{ {
$proxy_path = $this->server->proxyPath(); $proxy_path = $this->server->proxyPath();
@@ -43,6 +48,7 @@ class DynamicConfigurations extends Component
} }
$this->contents = $contents; $this->contents = $contents;
$this->dispatch('$refresh'); $this->dispatch('$refresh');
$this->dispatch('success', 'Dynamic configurations loaded.');
} }
public function mount() public function mount()

View File

@@ -11,13 +11,6 @@ class Show extends Component
public $parameters = []; public $parameters = [];
protected $listeners = ['proxyStatusUpdated', 'proxyChanged' => 'proxyStatusUpdated'];
public function proxyStatusUpdated()
{
$this->server->refresh();
}
public function mount() public function mount()
{ {
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();

View File

@@ -1,77 +0,0 @@
<?php
namespace App\Livewire\Server\Proxy;
use App\Actions\Docker\GetContainersStatus;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Models\Server;
use Livewire\Component;
class Status extends Component
{
public Server $server;
public bool $polling = false;
public int $numberOfPolls = 0;
protected $listeners = [
'proxyStatusUpdated',
'startProxyPolling',
];
public function startProxyPolling()
{
$this->checkProxy();
}
public function proxyStatusUpdated()
{
$this->server->refresh();
}
public function checkProxy(bool $notification = false)
{
try {
if ($this->polling) {
if ($this->numberOfPolls >= 10) {
$this->polling = false;
$this->numberOfPolls = 0;
$notification && $this->dispatch('error', 'Proxy is not running.');
return;
}
$this->numberOfPolls++;
}
$shouldStart = CheckProxy::run($this->server, true);
if ($shouldStart) {
StartProxy::run($this->server, false);
}
$this->dispatch('proxyStatusUpdated');
if ($this->server->proxy->status === 'running') {
$this->polling = false;
$notification && $this->dispatch('success', 'Proxy is running.');
} elseif ($this->server->proxy->status === 'exited' and ! $this->server->proxy->force_stop) {
$notification && $this->dispatch('error', 'Proxy has exited.');
} elseif ($this->server->proxy->force_stop) {
$notification && $this->dispatch('error', 'Proxy is stopped manually.');
} else {
$notification && $this->dispatch('error', 'Proxy is not running.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function getProxyStatus()
{
try {
GetContainersStatus::run($this->server);
// dispatch_sync(new ContainerStatusJob($this->server));
$this->dispatch('proxyStatusUpdated');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Livewire\Server\Security;
use App\Actions\Server\CheckUpdates;
use App\Actions\Server\UpdatePackage;
use App\Events\ServerPackageUpdated;
use App\Models\Server;
use Livewire\Component;
class Patches extends Component
{
public array $parameters;
public Server $server;
public ?int $totalUpdates = null;
public ?array $updates = null;
public ?string $error = null;
public ?string $osId = null;
public ?string $packageManager = null;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServerPackageUpdated" => 'checkForUpdatesDispatch',
];
}
public function mount()
{
if (! auth()->user()->isAdmin()) {
abort(403);
}
$this->parameters = get_route_parameters();
$this->server = Server::ownedByCurrentTeam()->whereUuid($this->parameters['server_uuid'])->firstOrFail();
}
public function checkForUpdatesDispatch()
{
$this->totalUpdates = null;
$this->updates = null;
$this->error = null;
$this->osId = null;
$this->packageManager = null;
$this->dispatch('checkForUpdatesDispatch');
}
public function checkForUpdates()
{
$job = CheckUpdates::run($this->server);
if (isset($job['error'])) {
$this->error = data_get($job, 'error', 'Something went wrong.');
} else {
$this->totalUpdates = data_get($job, 'total_updates', 0);
$this->updates = data_get($job, 'updates', []);
$this->osId = data_get($job, 'osId', null);
$this->packageManager = data_get($job, 'package_manager', null);
}
}
public function updateAllPackages()
{
if (! $this->packageManager || ! $this->osId) {
$this->dispatch('error', message: 'Run “Check for updates” first.');
return;
}
try {
$activity = UpdatePackage::run(
server: $this->server,
packageManager: $this->packageManager,
osId: $this->osId,
all: true
);
$this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class);
} catch (\Exception $e) {
$this->dispatch('error', message: $e->getMessage());
}
}
public function updatePackage($package)
{
try {
$activity = UpdatePackage::run(server: $this->server, packageManager: $this->packageManager, osId: $this->osId, package: $package);
$this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class);
} catch (\Exception $e) {
$this->dispatch('error', message: $e->getMessage());
}
}
public function render()
{
return view('livewire.server.security.patches');
}
}

View File

@@ -86,10 +86,7 @@ class Show extends Component
public function getListeners() public function getListeners()
{ {
$teamId = auth()->user()->currentTeam()->id;
return [ return [
"echo-private:team.{$teamId},CloudflareTunnelConfigured" => 'refresh',
'refreshServerShow' => 'refresh', 'refreshServerShow' => 'refresh',
]; ];
} }
@@ -187,7 +184,6 @@ class Show extends Component
public function refresh() public function refresh()
{ {
$this->syncData(); $this->syncData();
$this->dispatch('$refresh');
} }
public function validateServer($install = true) public function validateServer($install = true)
@@ -211,7 +207,6 @@ class Show extends Component
$this->server->settings->is_usable = $this->isUsable = true; $this->server->settings->is_usable = $this->isUsable = true;
$this->server->settings->save(); $this->server->settings->save();
ServerReachabilityChanged::dispatch($this->server); ServerReachabilityChanged::dispatch($this->server);
$this->dispatch('proxyStatusUpdated');
} else { } else {
$this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error); $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error);

View File

@@ -27,8 +27,6 @@ class ValidateAndInstall extends Component
public $docker_version = null; public $docker_version = null;
public $proxy_started = false;
public $error = null; public $error = null;
public bool $ask = false; public bool $ask = false;
@@ -39,7 +37,6 @@ class ValidateAndInstall extends Component
'validateOS', 'validateOS',
'validateDockerEngine', 'validateDockerEngine',
'validateDockerVersion', 'validateDockerVersion',
'startProxy',
'refresh' => '$refresh', 'refresh' => '$refresh',
]; ];
@@ -50,7 +47,6 @@ class ValidateAndInstall extends Component
$this->docker_installed = null; $this->docker_installed = null;
$this->docker_version = null; $this->docker_version = null;
$this->docker_compose_installed = null; $this->docker_compose_installed = null;
$this->proxy_started = null;
$this->error = null; $this->error = null;
$this->number_of_tries = $data; $this->number_of_tries = $data;
if (! $this->ask) { if (! $this->ask) {
@@ -64,25 +60,6 @@ class ValidateAndInstall extends Component
$this->init(); $this->init();
} }
public function startProxy()
{
try {
$shouldStart = CheckProxy::run($this->server);
if ($shouldStart) {
$proxy = StartProxy::run($this->server, false);
if ($proxy === 'OK') {
$this->proxy_started = true;
} else {
throw new \Exception('Proxy could not be started.');
}
} else {
$this->proxy_started = true;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function validateConnection() public function validateConnection()
{ {
['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
@@ -128,7 +105,7 @@ class ValidateAndInstall extends Component
if ($this->number_of_tries <= $this->max_tries) { if ($this->number_of_tries <= $this->max_tries) {
$activity = $this->server->installDocker(); $activity = $this->server->installDocker();
$this->number_of_tries++; $this->number_of_tries++;
$this->dispatch('newActivityMonitor', $activity->id, 'init', $this->number_of_tries); $this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries);
} }
return; return;
@@ -157,7 +134,12 @@ class ValidateAndInstall extends Component
if ($this->docker_version) { if ($this->docker_version) {
$this->dispatch('refreshServerShow'); $this->dispatch('refreshServerShow');
$this->dispatch('refreshBoardingIndex'); $this->dispatch('refreshBoardingIndex');
$this->dispatch('success', 'Server validated.'); $this->dispatch('success', 'Server validated, proxy is starting in a moment.');
$proxyShouldRun = CheckProxy::run($this->server, true);
if (! $proxyShouldRun) {
return;
}
StartProxy::dispatch($this->server);
} else { } else {
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); $requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
$this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not instaled. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.'; $this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not instaled. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
@@ -172,7 +154,6 @@ class ValidateAndInstall extends Component
if ($this->server->isBuildServer()) { if ($this->server->isBuildServer()) {
return; return;
} }
$this->dispatch('startProxy');
} }
public function render() public function render()

View File

@@ -225,7 +225,7 @@ class SettingsEmail extends Component
'test-email:'.$this->team->id, 'test-email:'.$this->team->id,
$perMinute = 0, $perMinute = 0,
function () { function () {
$this->team?->notify(new Test($this->testEmailAddress)); $this->team?->notifyNow(new Test($this->testEmailAddress));
$this->dispatch('success', 'Test Email sent.'); $this->dispatch('success', 'Test Email sent.');
}, },
$decaySeconds = 10, $decaySeconds = 10,

View File

@@ -21,7 +21,9 @@ class Index extends Component
if (! auth()->user()->isAdmin()) { if (! auth()->user()->isAdmin()) {
abort(403); abort(403);
} }
$this->servers = Server::isReachable()->get(); $this->servers = Server::isReachable()->get()->filter(function ($server) {
return $server->isTerminalEnabled();
});
} }
public function loadContainers() public function loadContainers()

View File

@@ -259,25 +259,15 @@ class Application extends BaseModel
return Application::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); return Application::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
} }
public function getContainersToStop(bool $previewDeployments = false): array public function getContainersToStop(Server $server, bool $previewDeployments = false): array
{ {
$containers = $previewDeployments $containers = $previewDeployments
? getCurrentApplicationContainerStatus($this->destination->server, $this->id, includePullrequests: true) ? getCurrentApplicationContainerStatus($server, $this->id, includePullrequests: true)
: getCurrentApplicationContainerStatus($this->destination->server, $this->id, 0); : getCurrentApplicationContainerStatus($server, $this->id, 0);
return $containers->pluck('Names')->toArray(); return $containers->pluck('Names')->toArray();
} }
public function stopContainers(array $containerNames, $server, int $timeout = 30)
{
foreach ($containerNames as $containerName) {
instant_remote_process(command: [
"docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
}
public function deleteConfigurations() public function deleteConfigurations()
{ {
$server = data_get($this, 'destination.server'); $server = data_get($this, 'destination.server');
@@ -1299,7 +1289,7 @@ class Application extends BaseModel
try { try {
$yaml = Yaml::parse($this->docker_compose_raw); $yaml = Yaml::parse($this->docker_compose_raw);
} catch (\Exception $e) { } catch (\Exception $e) {
throw new \Exception($e->getMessage()); throw new \RuntimeException($e->getMessage());
} }
$services = data_get($yaml, 'services'); $services = data_get($yaml, 'services');

View File

@@ -28,6 +28,7 @@ class DiscordNotificationSettings extends Model
'server_disk_usage_discord_notifications', 'server_disk_usage_discord_notifications',
'server_reachable_discord_notifications', 'server_reachable_discord_notifications',
'server_unreachable_discord_notifications', 'server_unreachable_discord_notifications',
'server_patch_discord_notifications',
'discord_ping_enabled', 'discord_ping_enabled',
]; ];
@@ -46,6 +47,7 @@ class DiscordNotificationSettings extends Model
'server_disk_usage_discord_notifications' => 'boolean', 'server_disk_usage_discord_notifications' => 'boolean',
'server_reachable_discord_notifications' => 'boolean', 'server_reachable_discord_notifications' => 'boolean',
'server_unreachable_discord_notifications' => 'boolean', 'server_unreachable_discord_notifications' => 'boolean',
'server_patch_discord_notifications' => 'boolean',
'discord_ping_enabled' => 'boolean', 'discord_ping_enabled' => 'boolean',
]; ];

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