Merge branch 'coollabsio:next' into next
This commit is contained in:
292
.cursor/rules/README.mdc
Normal file
292
.cursor/rules/README.mdc
Normal 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.
|
||||||
474
.cursor/rules/api-and-routing.mdc
Normal file
474
.cursor/rules/api-and-routing.mdc
Normal 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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
```
|
||||||
368
.cursor/rules/application-architecture.mdc
Normal file
368
.cursor/rules/application-architecture.mdc
Normal 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
|
||||||
53
.cursor/rules/cursor_rules.mdc
Normal file
53
.cursor/rules/cursor_rules.mdc
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
description: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness.
|
||||||
|
globs: .cursor/rules/*.mdc
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
- **Required Rule Structure:**
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
description: Clear, one-line description of what the rule enforces
|
||||||
|
globs: path/to/files/*.ext, other/path/**/*
|
||||||
|
alwaysApply: boolean
|
||||||
|
---
|
||||||
|
|
||||||
|
- **Main Points in Bold**
|
||||||
|
- Sub-points with details
|
||||||
|
- Examples and explanations
|
||||||
|
```
|
||||||
|
|
||||||
|
- **File References:**
|
||||||
|
- Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files
|
||||||
|
- Example: [prisma.mdc](mdc:.cursor/rules/prisma.mdc) for rule references
|
||||||
|
- Example: [schema.prisma](mdc:prisma/schema.prisma) for code references
|
||||||
|
|
||||||
|
- **Code Examples:**
|
||||||
|
- Use language-specific code blocks
|
||||||
|
```typescript
|
||||||
|
// ✅ DO: Show good examples
|
||||||
|
const goodExample = true;
|
||||||
|
|
||||||
|
// ❌ DON'T: Show anti-patterns
|
||||||
|
const badExample = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Rule Content Guidelines:**
|
||||||
|
- Start with high-level overview
|
||||||
|
- Include specific, actionable requirements
|
||||||
|
- Show examples of correct implementation
|
||||||
|
- Reference existing code when possible
|
||||||
|
- Keep rules DRY by referencing other rules
|
||||||
|
|
||||||
|
- **Rule Maintenance:**
|
||||||
|
- Update rules when new patterns emerge
|
||||||
|
- Add examples from actual codebase
|
||||||
|
- Remove outdated patterns
|
||||||
|
- Cross-reference related rules
|
||||||
|
|
||||||
|
- **Best Practices:**
|
||||||
|
- Use bullet points for clarity
|
||||||
|
- Keep descriptions concise
|
||||||
|
- Include both DO and DON'T examples
|
||||||
|
- Reference actual code over theoretical examples
|
||||||
|
- Use consistent formatting across rules
|
||||||
306
.cursor/rules/database-patterns.mdc
Normal file
306
.cursor/rules/database-patterns.mdc
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
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
|
||||||
310
.cursor/rules/deployment-architecture.mdc
Normal file
310
.cursor/rules/deployment-architecture.mdc
Normal 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
|
||||||
219
.cursor/rules/dev_workflow.mdc
Normal file
219
.cursor/rules/dev_workflow.mdc
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
---
|
||||||
|
description: Guide for using Task Master to manage task-driven development workflows
|
||||||
|
globs: **/*
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
# Task Master Development Workflow
|
||||||
|
|
||||||
|
This guide outlines the typical process for using Task Master to manage software development projects.
|
||||||
|
|
||||||
|
## Primary Interaction: MCP Server vs. CLI
|
||||||
|
|
||||||
|
Task Master offers two primary ways to interact:
|
||||||
|
|
||||||
|
1. **MCP Server (Recommended for Integrated Tools)**:
|
||||||
|
- For AI agents and integrated development environments (like Cursor), interacting via the **MCP server is the preferred method**.
|
||||||
|
- The MCP server exposes Task Master functionality through a set of tools (e.g., `get_tasks`, `add_subtask`).
|
||||||
|
- This method offers better performance, structured data exchange, and richer error handling compared to CLI parsing.
|
||||||
|
- Refer to [`mcp.mdc`](mdc:.cursor/rules/mcp.mdc) for details on the MCP architecture and available tools.
|
||||||
|
- A comprehensive list and description of MCP tools and their corresponding CLI commands can be found in [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc).
|
||||||
|
- **Restart the MCP server** if core logic in `scripts/modules` or MCP tool/direct function definitions change.
|
||||||
|
|
||||||
|
2. **`task-master` CLI (For Users & Fallback)**:
|
||||||
|
- The global `task-master` command provides a user-friendly interface for direct terminal interaction.
|
||||||
|
- It can also serve as a fallback if the MCP server is inaccessible or a specific function isn't exposed via MCP.
|
||||||
|
- Install globally with `npm install -g task-master-ai` or use locally via `npx task-master-ai ...`.
|
||||||
|
- The CLI commands often mirror the MCP tools (e.g., `task-master list` corresponds to `get_tasks`).
|
||||||
|
- Refer to [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc) for a detailed command reference.
|
||||||
|
|
||||||
|
## Standard Development Workflow Process
|
||||||
|
|
||||||
|
- Start new projects by running `initialize_project` tool / `task-master init` or `parse_prd` / `task-master parse-prd --input='<prd-file.txt>'` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to generate initial tasks.json
|
||||||
|
- Begin coding sessions with `get_tasks` / `task-master list` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to see current tasks, status, and IDs
|
||||||
|
- Determine the next task to work on using `next_task` / `task-master next` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
||||||
|
- Analyze task complexity with `analyze_project_complexity` / `task-master analyze-complexity --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) before breaking down tasks
|
||||||
|
- Review complexity report using `complexity_report` / `task-master complexity-report` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
||||||
|
- Select tasks based on dependencies (all marked 'done'), priority level, and ID order
|
||||||
|
- Clarify tasks by checking task files in tasks/ directory or asking for user input
|
||||||
|
- View specific task details using `get_task` / `task-master show <id>` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to understand implementation requirements
|
||||||
|
- Break down complex tasks using `expand_task` / `task-master expand --id=<id> --force --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) with appropriate flags like `--force` (to replace existing subtasks) and `--research`.
|
||||||
|
- Clear existing subtasks if needed using `clear_subtasks` / `task-master clear-subtasks --id=<id>` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) before regenerating
|
||||||
|
- Implement code following task details, dependencies, and project standards
|
||||||
|
- Verify tasks according to test strategies before marking as complete (See [`tests.mdc`](mdc:.cursor/rules/tests.mdc))
|
||||||
|
- Mark completed tasks with `set_task_status` / `task-master set-status --id=<id> --status=done` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc))
|
||||||
|
- Update dependent tasks when implementation differs from original plan using `update` / `task-master update --from=<id> --prompt="..."` or `update_task` / `task-master update-task --id=<id> --prompt="..."` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc))
|
||||||
|
- Add new tasks discovered during implementation using `add_task` / `task-master add-task --prompt="..." --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
||||||
|
- Add new subtasks as needed using `add_subtask` / `task-master add-subtask --parent=<id> --title="..."` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
||||||
|
- Append notes or details to subtasks using `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='Add implementation notes here...\nMore details...'` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
||||||
|
- Generate task files with `generate` / `task-master generate` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) after updating tasks.json
|
||||||
|
- Maintain valid dependency structure with `add_dependency`/`remove_dependency` tools or `task-master add-dependency`/`remove-dependency` commands, `validate_dependencies` / `task-master validate-dependencies`, and `fix_dependencies` / `task-master fix-dependencies` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) when needed
|
||||||
|
- Respect dependency chains and task priorities when selecting work
|
||||||
|
- Report progress regularly using `get_tasks` / `task-master list`
|
||||||
|
|
||||||
|
## Task Complexity Analysis
|
||||||
|
|
||||||
|
- Run `analyze_project_complexity` / `task-master analyze-complexity --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) for comprehensive analysis
|
||||||
|
- Review complexity report via `complexity_report` / `task-master complexity-report` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) for a formatted, readable version.
|
||||||
|
- Focus on tasks with highest complexity scores (8-10) for detailed breakdown
|
||||||
|
- Use analysis results to determine appropriate subtask allocation
|
||||||
|
- Note that reports are automatically used by the `expand_task` tool/command
|
||||||
|
|
||||||
|
## Task Breakdown Process
|
||||||
|
|
||||||
|
- Use `expand_task` / `task-master expand --id=<id>`. It automatically uses the complexity report if found, otherwise generates default number of subtasks.
|
||||||
|
- Use `--num=<number>` to specify an explicit number of subtasks, overriding defaults or complexity report recommendations.
|
||||||
|
- Add `--research` flag to leverage Perplexity AI for research-backed expansion.
|
||||||
|
- Add `--force` flag to clear existing subtasks before generating new ones (default is to append).
|
||||||
|
- Use `--prompt="<context>"` to provide additional context when needed.
|
||||||
|
- Review and adjust generated subtasks as necessary.
|
||||||
|
- Use `expand_all` tool or `task-master expand --all` to expand multiple pending tasks at once, respecting flags like `--force` and `--research`.
|
||||||
|
- If subtasks need complete replacement (regardless of the `--force` flag on `expand`), clear them first with `clear_subtasks` / `task-master clear-subtasks --id=<id>`.
|
||||||
|
|
||||||
|
## Implementation Drift Handling
|
||||||
|
|
||||||
|
- When implementation differs significantly from planned approach
|
||||||
|
- When future tasks need modification due to current implementation choices
|
||||||
|
- When new dependencies or requirements emerge
|
||||||
|
- Use `update` / `task-master update --from=<futureTaskId> --prompt='<explanation>\nUpdate context...' --research` to update multiple future tasks.
|
||||||
|
- Use `update_task` / `task-master update-task --id=<taskId> --prompt='<explanation>\nUpdate context...' --research` to update a single specific task.
|
||||||
|
|
||||||
|
## Task Status Management
|
||||||
|
|
||||||
|
- Use 'pending' for tasks ready to be worked on
|
||||||
|
- Use 'done' for completed and verified tasks
|
||||||
|
- Use 'deferred' for postponed tasks
|
||||||
|
- Add custom status values as needed for project-specific workflows
|
||||||
|
|
||||||
|
## Task Structure Fields
|
||||||
|
|
||||||
|
- **id**: Unique identifier for the task (Example: `1`, `1.1`)
|
||||||
|
- **title**: Brief, descriptive title (Example: `"Initialize Repo"`)
|
||||||
|
- **description**: Concise summary of what the task involves (Example: `"Create a new repository, set up initial structure."`)
|
||||||
|
- **status**: Current state of the task (Example: `"pending"`, `"done"`, `"deferred"`)
|
||||||
|
- **dependencies**: IDs of prerequisite tasks (Example: `[1, 2.1]`)
|
||||||
|
- Dependencies are displayed with status indicators (✅ for completed, ⏱️ for pending)
|
||||||
|
- This helps quickly identify which prerequisite tasks are blocking work
|
||||||
|
- **priority**: Importance level (Example: `"high"`, `"medium"`, `"low"`)
|
||||||
|
- **details**: In-depth implementation instructions (Example: `"Use GitHub client ID/secret, handle callback, set session token."`)
|
||||||
|
- **testStrategy**: Verification approach (Example: `"Deploy and call endpoint to confirm 'Hello World' response."`)
|
||||||
|
- **subtasks**: List of smaller, more specific tasks (Example: `[{"id": 1, "title": "Configure OAuth", ...}]`)
|
||||||
|
- Refer to task structure details (previously linked to `tasks.mdc`).
|
||||||
|
|
||||||
|
## Configuration Management (Updated)
|
||||||
|
|
||||||
|
Taskmaster configuration is managed through two main mechanisms:
|
||||||
|
|
||||||
|
1. **`.taskmasterconfig` File (Primary):**
|
||||||
|
* Located in the project root directory.
|
||||||
|
* Stores most configuration settings: AI model selections (main, research, fallback), parameters (max tokens, temperature), logging level, default subtasks/priority, project name, etc.
|
||||||
|
* **Managed via `task-master models --setup` command.** Do not edit manually unless you know what you are doing.
|
||||||
|
* **View/Set specific models via `task-master models` command or `models` MCP tool.**
|
||||||
|
* Created automatically when you run `task-master models --setup` for the first time.
|
||||||
|
|
||||||
|
2. **Environment Variables (`.env` / `mcp.json`):**
|
||||||
|
* Used **only** for sensitive API keys and specific endpoint URLs.
|
||||||
|
* Place API keys (one per provider) in a `.env` file in the project root for CLI usage.
|
||||||
|
* For MCP/Cursor integration, configure these keys in the `env` section of `.cursor/mcp.json`.
|
||||||
|
* Available keys/variables: See `assets/env.example` or the Configuration section in the command reference (previously linked to `taskmaster.mdc`).
|
||||||
|
|
||||||
|
**Important:** Non-API key settings (like model selections, `MAX_TOKENS`, `TASKMASTER_LOG_LEVEL`) are **no longer configured via environment variables**. Use the `task-master models` command (or `--setup` for interactive configuration) or the `models` MCP tool.
|
||||||
|
**If AI commands FAIL in MCP** verify that the API key for the selected provider is present in the `env` section of `.cursor/mcp.json`.
|
||||||
|
**If AI commands FAIL in CLI** verify that the API key for the selected provider is present in the `.env` file in the root of the project.
|
||||||
|
|
||||||
|
## Determining the Next Task
|
||||||
|
|
||||||
|
- Run `next_task` / `task-master next` to show the next task to work on.
|
||||||
|
- The command identifies tasks with all dependencies satisfied
|
||||||
|
- Tasks are prioritized by priority level, dependency count, and ID
|
||||||
|
- The command shows comprehensive task information including:
|
||||||
|
- Basic task details and description
|
||||||
|
- Implementation details
|
||||||
|
- Subtasks (if they exist)
|
||||||
|
- Contextual suggested actions
|
||||||
|
- Recommended before starting any new development work
|
||||||
|
- Respects your project's dependency structure
|
||||||
|
- Ensures tasks are completed in the appropriate sequence
|
||||||
|
- Provides ready-to-use commands for common task actions
|
||||||
|
|
||||||
|
## Viewing Specific Task Details
|
||||||
|
|
||||||
|
- Run `get_task` / `task-master show <id>` to view a specific task.
|
||||||
|
- Use dot notation for subtasks: `task-master show 1.2` (shows subtask 2 of task 1)
|
||||||
|
- Displays comprehensive information similar to the next command, but for a specific task
|
||||||
|
- For parent tasks, shows all subtasks and their current status
|
||||||
|
- For subtasks, shows parent task information and relationship
|
||||||
|
- Provides contextual suggested actions appropriate for the specific task
|
||||||
|
- Useful for examining task details before implementation or checking status
|
||||||
|
|
||||||
|
## Managing Task Dependencies
|
||||||
|
|
||||||
|
- Use `add_dependency` / `task-master add-dependency --id=<id> --depends-on=<id>` to add a dependency.
|
||||||
|
- Use `remove_dependency` / `task-master remove-dependency --id=<id> --depends-on=<id>` to remove a dependency.
|
||||||
|
- The system prevents circular dependencies and duplicate dependency entries
|
||||||
|
- Dependencies are checked for existence before being added or removed
|
||||||
|
- Task files are automatically regenerated after dependency changes
|
||||||
|
- Dependencies are visualized with status indicators in task listings and files
|
||||||
|
|
||||||
|
## Iterative Subtask Implementation
|
||||||
|
|
||||||
|
Once a task has been broken down into subtasks using `expand_task` or similar methods, follow this iterative process for implementation:
|
||||||
|
|
||||||
|
1. **Understand the Goal (Preparation):**
|
||||||
|
* Use `get_task` / `task-master show <subtaskId>` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to thoroughly understand the specific goals and requirements of the subtask.
|
||||||
|
|
||||||
|
2. **Initial Exploration & Planning (Iteration 1):**
|
||||||
|
* This is the first attempt at creating a concrete implementation plan.
|
||||||
|
* Explore the codebase to identify the precise files, functions, and even specific lines of code that will need modification.
|
||||||
|
* Determine the intended code changes (diffs) and their locations.
|
||||||
|
* Gather *all* relevant details from this exploration phase.
|
||||||
|
|
||||||
|
3. **Log the Plan:**
|
||||||
|
* Run `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='<detailed plan>'`.
|
||||||
|
* Provide the *complete and detailed* findings from the exploration phase in the prompt. Include file paths, line numbers, proposed diffs, reasoning, and any potential challenges identified. Do not omit details. The goal is to create a rich, timestamped log within the subtask's `details`.
|
||||||
|
|
||||||
|
4. **Verify the Plan:**
|
||||||
|
* Run `get_task` / `task-master show <subtaskId>` again to confirm that the detailed implementation plan has been successfully appended to the subtask's details.
|
||||||
|
|
||||||
|
5. **Begin Implementation:**
|
||||||
|
* Set the subtask status using `set_task_status` / `task-master set-status --id=<subtaskId> --status=in-progress`.
|
||||||
|
* Start coding based on the logged plan.
|
||||||
|
|
||||||
|
6. **Refine and Log Progress (Iteration 2+):**
|
||||||
|
* As implementation progresses, you will encounter challenges, discover nuances, or confirm successful approaches.
|
||||||
|
* **Before appending new information**: Briefly review the *existing* details logged in the subtask (using `get_task` or recalling from context) to ensure the update adds fresh insights and avoids redundancy.
|
||||||
|
* **Regularly** use `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='<update details>\n- What worked...\n- What didn't work...'` to append new findings.
|
||||||
|
* **Crucially, log:**
|
||||||
|
* What worked ("fundamental truths" discovered).
|
||||||
|
* What didn't work and why (to avoid repeating mistakes).
|
||||||
|
* Specific code snippets or configurations that were successful.
|
||||||
|
* Decisions made, especially if confirmed with user input.
|
||||||
|
* Any deviations from the initial plan and the reasoning.
|
||||||
|
* The objective is to continuously enrich the subtask's details, creating a log of the implementation journey that helps the AI (and human developers) learn, adapt, and avoid repeating errors.
|
||||||
|
|
||||||
|
7. **Review & Update Rules (Post-Implementation):**
|
||||||
|
* Once the implementation for the subtask is functionally complete, review all code changes and the relevant chat history.
|
||||||
|
* Identify any new or modified code patterns, conventions, or best practices established during the implementation.
|
||||||
|
* Create new or update existing rules following internal guidelines (previously linked to `cursor_rules.mdc` and `self_improve.mdc`).
|
||||||
|
|
||||||
|
8. **Mark Task Complete:**
|
||||||
|
* After verifying the implementation and updating any necessary rules, mark the subtask as completed: `set_task_status` / `task-master set-status --id=<subtaskId> --status=done`.
|
||||||
|
|
||||||
|
9. **Commit Changes (If using Git):**
|
||||||
|
* Stage the relevant code changes and any updated/new rule files (`git add .`).
|
||||||
|
* Craft a comprehensive Git commit message summarizing the work done for the subtask, including both code implementation and any rule adjustments.
|
||||||
|
* Execute the commit command directly in the terminal (e.g., `git commit -m 'feat(module): Implement feature X for subtask <subtaskId>\n\n- Details about changes...\n- Updated rule Y for pattern Z'`).
|
||||||
|
* Consider if a Changeset is needed according to internal versioning guidelines (previously linked to `changeset.mdc`). If so, run `npm run changeset`, stage the generated file, and amend the commit or create a new one.
|
||||||
|
|
||||||
|
10. **Proceed to Next Subtask:**
|
||||||
|
* Identify the next subtask (e.g., using `next_task` / `task-master next`).
|
||||||
|
|
||||||
|
## Code Analysis & Refactoring Techniques
|
||||||
|
|
||||||
|
- **Top-Level Function Search**:
|
||||||
|
- Useful for understanding module structure or planning refactors.
|
||||||
|
- Use grep/ripgrep to find exported functions/constants:
|
||||||
|
`rg "export (async function|function|const) \w+"` or similar patterns.
|
||||||
|
- Can help compare functions between files during migrations or identify potential naming conflicts.
|
||||||
|
|
||||||
|
---
|
||||||
|
*This workflow provides a general guideline. Adapt it based on your specific project needs and team practices.*
|
||||||
653
.cursor/rules/development-workflow.mdc
Normal file
653
.cursor/rules/development-workflow.mdc
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
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
|
||||||
319
.cursor/rules/frontend-patterns.mdc
Normal file
319
.cursor/rules/frontend-patterns.mdc
Normal 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
|
||||||
161
.cursor/rules/project-overview.mdc
Normal file
161
.cursor/rules/project-overview.mdc
Normal 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**
|
||||||
788
.cursor/rules/security-patterns.mdc
Normal file
788
.cursor/rules/security-patterns.mdc
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
- Clerk
|
||||||
|
- Authentik
|
||||||
|
- Discord
|
||||||
|
- 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);
|
||||||
|
});
|
||||||
|
```
|
||||||
72
.cursor/rules/self_improve.mdc
Normal file
72
.cursor/rules/self_improve.mdc
Normal 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.
|
||||||
250
.cursor/rules/technology-stack.mdc
Normal file
250
.cursor/rules/technology-stack.mdc
Normal 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, Discord, Clerk
|
||||||
|
- Custom OAuth implementations
|
||||||
|
|
||||||
|
## Background Processing
|
||||||
|
|
||||||
|
### **Laravel Horizon 5.30.3**
|
||||||
|
- **Purpose**: Queue monitoring and management
|
||||||
|
- **Features**: Real-time queue metrics, failed job handling
|
||||||
|
|
||||||
|
### **Queue System**
|
||||||
|
- **Backend**: Redis-based queues
|
||||||
|
- **Jobs**: [app/Jobs/](mdc:app/Jobs/)
|
||||||
|
- **Processing**: Background deployment and monitoring tasks
|
||||||
|
|
||||||
|
## Development Tools
|
||||||
|
|
||||||
|
### **Build Tools**
|
||||||
|
- **Vite 6.2.6**: Modern build tool and dev server
|
||||||
|
- **Laravel Vite Plugin**: Laravel integration
|
||||||
|
- **PostCSS**: CSS processing pipeline
|
||||||
|
|
||||||
|
### **Code Quality**
|
||||||
|
- **Laravel Pint**: PHP code style fixer
|
||||||
|
- **Rector**: PHP automated refactoring
|
||||||
|
- **PHPStan**: Static analysis tool
|
||||||
|
|
||||||
|
### **Testing Framework**
|
||||||
|
- **Pest 3.8.0**: Modern PHP testing framework
|
||||||
|
- **Laravel Dusk**: Browser automation testing
|
||||||
|
- **PHPUnit**: Unit testing foundation
|
||||||
|
|
||||||
|
## External Integrations
|
||||||
|
|
||||||
|
### **Git Providers**
|
||||||
|
- **GitHub**: Repository integration and webhooks
|
||||||
|
- **GitLab**: Self-hosted and cloud GitLab support
|
||||||
|
- **Bitbucket**: Atlassian integration
|
||||||
|
- **Gitea**: Self-hosted Git service
|
||||||
|
|
||||||
|
### **Cloud Storage**
|
||||||
|
- **AWS S3**: [league/flysystem-aws-s3-v3](mdc:composer.json)
|
||||||
|
- **SFTP**: [league/flysystem-sftp-v3](mdc:composer.json)
|
||||||
|
- **Local Storage**: File system integration
|
||||||
|
|
||||||
|
### **Notification Services**
|
||||||
|
- **Email**: [resend/resend-laravel](mdc:composer.json)
|
||||||
|
- **Discord**: Custom webhook integration
|
||||||
|
- **Slack**: Webhook notifications
|
||||||
|
- **Telegram**: Bot API integration
|
||||||
|
- **Pushover**: Push notifications
|
||||||
|
|
||||||
|
### **Monitoring & Logging**
|
||||||
|
- **Sentry**: [sentry/sentry-laravel](mdc:composer.json) - Error tracking
|
||||||
|
- **Laravel Ray**: [spatie/laravel-ray](mdc:composer.json) - Debug tool
|
||||||
|
- **Activity Log**: [spatie/laravel-activitylog](mdc:composer.json)
|
||||||
|
|
||||||
|
## DevOps & Infrastructure
|
||||||
|
|
||||||
|
### **Docker & Containerization**
|
||||||
|
- **Docker**: Container runtime
|
||||||
|
- **Docker Compose**: Multi-container orchestration
|
||||||
|
- **Docker Swarm**: Container clustering (optional)
|
||||||
|
|
||||||
|
### **Web Servers & Proxies**
|
||||||
|
- **Nginx**: Primary web server
|
||||||
|
- **Traefik**: Reverse proxy and load balancer
|
||||||
|
- **Caddy**: Alternative reverse proxy
|
||||||
|
|
||||||
|
### **Process Management**
|
||||||
|
- **S6 Overlay**: Process supervisor
|
||||||
|
- **Supervisor**: Alternative process manager
|
||||||
|
|
||||||
|
### **SSL/TLS**
|
||||||
|
- **Let's Encrypt**: Automatic SSL certificates
|
||||||
|
- **Custom Certificates**: Manual SSL management
|
||||||
|
|
||||||
|
## Terminal & Code Editing
|
||||||
|
|
||||||
|
### **XTerm.js 5.5.0**
|
||||||
|
- **Purpose**: Web-based terminal emulator
|
||||||
|
- **Features**: SSH session management, real-time command execution
|
||||||
|
- **Addons**: Fit addon for responsive terminals
|
||||||
|
|
||||||
|
### **Monaco Editor**
|
||||||
|
- **Purpose**: Code editor component
|
||||||
|
- **Features**: Syntax highlighting, auto-completion
|
||||||
|
- **Integration**: Environment variable editing, configuration files
|
||||||
|
|
||||||
|
## API & Documentation
|
||||||
|
|
||||||
|
### **OpenAPI/Swagger**
|
||||||
|
- **Documentation**: [openapi.json](mdc:openapi.json) (373KB)
|
||||||
|
- **Generator**: [zircote/swagger-php](mdc:composer.json)
|
||||||
|
- **API Routes**: [routes/api.php](mdc:routes/api.php)
|
||||||
|
|
||||||
|
### **WebSocket Communication**
|
||||||
|
- **Laravel Echo**: Real-time event broadcasting
|
||||||
|
- **Pusher**: WebSocket service integration
|
||||||
|
- **Soketi**: Self-hosted WebSocket server
|
||||||
|
|
||||||
|
## Package Management
|
||||||
|
|
||||||
|
### **PHP Dependencies** ([composer.json](mdc:composer.json))
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"require": {
|
||||||
|
"php": "^8.4",
|
||||||
|
"laravel/framework": "12.4.1",
|
||||||
|
"livewire/livewire": "^3.5.20",
|
||||||
|
"spatie/laravel-data": "^4.13.1",
|
||||||
|
"lorisleiva/laravel-actions": "^2.8.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **JavaScript Dependencies** ([package.json](mdc:package.json))
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^6.2.6",
|
||||||
|
"tailwindcss": "^4.1.4",
|
||||||
|
"@vitejs/plugin-vue": "5.2.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"ioredis": "5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
### **Build Configuration**
|
||||||
|
- **[vite.config.js](mdc:vite.config.js)**: Frontend build setup
|
||||||
|
- **[postcss.config.cjs](mdc:postcss.config.cjs)**: CSS processing
|
||||||
|
- **[rector.php](mdc:rector.php)**: PHP refactoring rules
|
||||||
|
- **[pint.json](mdc:pint.json)**: Code style configuration
|
||||||
|
|
||||||
|
### **Testing Configuration**
|
||||||
|
- **[phpunit.xml](mdc:phpunit.xml)**: Unit test configuration
|
||||||
|
- **[phpunit.dusk.xml](mdc:phpunit.dusk.xml)**: Browser test configuration
|
||||||
|
- **[tests/Pest.php](mdc:tests/Pest.php)**: Pest testing setup
|
||||||
|
|
||||||
|
## Version Requirements
|
||||||
|
|
||||||
|
### **Minimum Requirements**
|
||||||
|
- **PHP**: 8.4+
|
||||||
|
- **Node.js**: 18+ (for build tools)
|
||||||
|
- **PostgreSQL**: 15+
|
||||||
|
- **Redis**: 7+
|
||||||
|
- **Docker**: 20.10+
|
||||||
|
- **Docker Compose**: 2.0+
|
||||||
|
|
||||||
|
### **Recommended Versions**
|
||||||
|
- **Ubuntu**: 22.04 LTS or 24.04 LTS
|
||||||
|
- **Memory**: 2GB+ RAM
|
||||||
|
- **Storage**: 20GB+ available space
|
||||||
|
- **Network**: Stable internet connection for deployments
|
||||||
606
.cursor/rules/testing-patterns.mdc
Normal file
606
.cursor/rules/testing-patterns.mdc
Normal 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);
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -14,3 +14,5 @@ PUSHER_APP_SECRET=
|
|||||||
ROOT_USERNAME=
|
ROOT_USERNAME=
|
||||||
ROOT_USER_EMAIL=
|
ROOT_USER_EMAIL=
|
||||||
ROOT_USER_PASSWORD=
|
ROOT_USER_PASSWORD=
|
||||||
|
|
||||||
|
REGISTRY_URL=ghcr.io
|
||||||
|
|||||||
65
.github/workflows/browser-tests.yml
vendored
65
.github/workflows/browser-tests.yml
vendored
@@ -1,65 +0,0 @@
|
|||||||
name: Dusk
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "not-existing" ]
|
|
||||||
jobs:
|
|
||||||
dusk:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis
|
|
||||||
env:
|
|
||||||
REDIS_HOST: localhost
|
|
||||||
REDIS_PORT: 6379
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
options: >-
|
|
||||||
--health-cmd "redis-cli ping"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Set up PostgreSQL
|
|
||||||
run: |
|
|
||||||
sudo systemctl start postgresql
|
|
||||||
sudo -u postgres psql -c "CREATE DATABASE coolify;"
|
|
||||||
sudo -u postgres psql -c "CREATE USER coolify WITH PASSWORD 'password';"
|
|
||||||
sudo -u postgres psql -c "ALTER ROLE coolify SET client_encoding TO 'utf8';"
|
|
||||||
sudo -u postgres psql -c "ALTER ROLE coolify SET default_transaction_isolation TO 'read committed';"
|
|
||||||
sudo -u postgres psql -c "ALTER ROLE coolify SET timezone TO 'UTC';"
|
|
||||||
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE coolify TO coolify;"
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.2'
|
|
||||||
- name: Copy .env
|
|
||||||
run: cp .env.dusk.ci .env
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: composer install --no-progress --prefer-dist --optimize-autoloader
|
|
||||||
- name: Generate key
|
|
||||||
run: php artisan key:generate
|
|
||||||
- name: Install Chrome binaries
|
|
||||||
run: php artisan dusk:chrome-driver --detect
|
|
||||||
- name: Start Chrome Driver
|
|
||||||
run: ./vendor/laravel/dusk/bin/chromedriver-linux --port=4444 &
|
|
||||||
- name: Build assets
|
|
||||||
run: npm install && npm run build
|
|
||||||
- name: Run Laravel Server
|
|
||||||
run: php artisan serve --no-reload &
|
|
||||||
- name: Execute tests
|
|
||||||
run: php artisan dusk
|
|
||||||
- name: Upload Screenshots
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: screenshots
|
|
||||||
path: tests/Browser/screenshots
|
|
||||||
- name: Upload Console Logs
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: console
|
|
||||||
path: tests/Browser/console
|
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
|
|
||||||
async function processIssue(issueNumber, isFromPR = false, prBaseBranch = null) {
|
async function processIssue(issueNumber, isFromPR = false, prBaseBranch = null) {
|
||||||
try {
|
try {
|
||||||
if (isFromPR && prBaseBranch !== 'main') {
|
if (isFromPR && prBaseBranch !== 'v4.x') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') {
|
if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') {
|
||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
await processIssue(pr.number);
|
await processIssue(pr.number);
|
||||||
if (pr.merged && pr.base.ref === 'main' && pr.body) {
|
if (pr.merged && pr.base.ref === 'v4.x' && pr.body) {
|
||||||
const issueReferences = pr.body.match(/#(\d+)/g);
|
const issueReferences = pr.body.match(/#(\d+)/g);
|
||||||
if (issueReferences) {
|
if (issueReferences) {
|
||||||
for (const reference of issueReferences) {
|
for (const reference of issueReferences) {
|
||||||
|
|||||||
2
.github/workflows/coolify-helper.yml
vendored
2
.github/workflows/coolify-helper.yml
vendored
@@ -2,7 +2,7 @@ name: Coolify Helper Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: [ "v4.x" ]
|
||||||
paths:
|
paths:
|
||||||
- .github/workflows/coolify-helper.yml
|
- .github/workflows/coolify-helper.yml
|
||||||
- docker/coolify-helper/Dockerfile
|
- docker/coolify-helper/Dockerfile
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Production Build (v4)
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["v4.x"]
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- .github/workflows/coolify-helper.yml
|
- .github/workflows/coolify-helper.yml
|
||||||
- .github/workflows/coolify-helper-next.yml
|
- .github/workflows/coolify-helper-next.yml
|
||||||
@@ -12,6 +12,7 @@ on:
|
|||||||
- docker/coolify-realtime/Dockerfile
|
- docker/coolify-realtime/Dockerfile
|
||||||
- docker/testing-host/Dockerfile
|
- docker/testing-host/Dockerfile
|
||||||
- templates/**
|
- templates/**
|
||||||
|
- CHANGELOG.md
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITHUB_REGISTRY: ghcr.io
|
GITHUB_REGISTRY: ghcr.io
|
||||||
|
|||||||
2
.github/workflows/coolify-realtime.yml
vendored
2
.github/workflows/coolify-realtime.yml
vendored
@@ -2,7 +2,7 @@ name: Coolify Realtime
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: [ "v4.x" ]
|
||||||
paths:
|
paths:
|
||||||
- .github/workflows/coolify-realtime.yml
|
- .github/workflows/coolify-realtime.yml
|
||||||
- docker/coolify-realtime/Dockerfile
|
- docker/coolify-realtime/Dockerfile
|
||||||
|
|||||||
6
.github/workflows/coolify-staging-build.yml
vendored
6
.github/workflows/coolify-staging-build.yml
vendored
@@ -2,7 +2,10 @@ name: Staging Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches-ignore: ["main", "v3"]
|
branches-ignore:
|
||||||
|
- v4.x
|
||||||
|
- v3.x
|
||||||
|
- '**v5.x**'
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- .github/workflows/coolify-helper.yml
|
- .github/workflows/coolify-helper.yml
|
||||||
- .github/workflows/coolify-helper-next.yml
|
- .github/workflows/coolify-helper-next.yml
|
||||||
@@ -12,6 +15,7 @@ on:
|
|||||||
- docker/coolify-realtime/Dockerfile
|
- docker/coolify-realtime/Dockerfile
|
||||||
- docker/testing-host/Dockerfile
|
- docker/testing-host/Dockerfile
|
||||||
- templates/**
|
- templates/**
|
||||||
|
- CHANGELOG.md
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITHUB_REGISTRY: ghcr.io
|
GITHUB_REGISTRY: ghcr.io
|
||||||
|
|||||||
36
.github/workflows/generate-changelog.yml
vendored
Normal file
36
.github/workflows/generate-changelog.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Generate Changelog
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ v4.x ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changelog:
|
||||||
|
name: Generate changelog
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Generate changelog
|
||||||
|
uses: orhun/git-cliff-action@v4
|
||||||
|
with:
|
||||||
|
config: cliff.toml
|
||||||
|
args: --verbose
|
||||||
|
env:
|
||||||
|
OUTPUT: CHANGELOG.md
|
||||||
|
GITHUB_REPO: ${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Commit
|
||||||
|
run: |
|
||||||
|
git config user.name 'github-actions[bot]'
|
||||||
|
git config user.email 'github-actions[bot]@users.noreply.github.com'
|
||||||
|
git add CHANGELOG.md
|
||||||
|
git commit -m "docs: update changelog"
|
||||||
|
git push https://${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git v4.x
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,3 +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
|
||||||
|
|||||||
7723
CHANGELOG.md
Normal file
7723
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -136,6 +136,7 @@ After installing Docker (or Orbstack) and Spin, verify the installation:
|
|||||||
- Password: `password`
|
- Password: `password`
|
||||||
|
|
||||||
2. Additional development tools:
|
2. Additional development tools:
|
||||||
|
|
||||||
| Tool | URL | Note |
|
| Tool | URL | Note |
|
||||||
|------|-----|------|
|
|------|-----|------|
|
||||||
| Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user |
|
| Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user |
|
||||||
@@ -237,9 +238,9 @@ After completing these steps, you'll have a fresh development setup.
|
|||||||
### Contributing a New Service
|
### Contributing a New Service
|
||||||
|
|
||||||
To add a new service to Coolify, please refer to our documentation:
|
To add a new service to Coolify, please refer to our documentation:
|
||||||
[Adding a New Service](https://coolify.io/docs/knowledge-base/contribute/service)
|
[Adding a New Service](https://coolify.io/docs/get-started/contribute/service)
|
||||||
|
|
||||||
### Contributing to Documentation
|
### Contributing to Documentation
|
||||||
|
|
||||||
To contribute to the Coolify documentation, please refer to this guide:
|
To contribute to the Coolify documentation, please refer to this guide:
|
||||||
[Contributing to the Coolify Documentation](https://github.com/coollabsio/documentation-coolify/blob/main/CONTRIBUTING.md)
|
[Contributing to the Coolify Documentation](https://github.com/coollabsio/documentation-coolify/blob/main/readme.md)
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -186,7 +186,7 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [2022] [Andras Bacsai]
|
Copyright [2025] [Andras Bacsai]
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
186
README.md
186
README.md
@@ -29,98 +29,6 @@ You can find the installation script source [here](./scripts/install.sh).
|
|||||||
|
|
||||||
Contact us at [coolify.io/docs/contact](https://coolify.io/docs/contact).
|
Contact us at [coolify.io/docs/contact](https://coolify.io/docs/contact).
|
||||||
|
|
||||||
# Donations
|
|
||||||
To stay completely free and open-source, with no feature behind the paywall and evolve the project, we need your help. If you like Coolify, please consider donating to help us fund the project's future development.
|
|
||||||
|
|
||||||
[coolify.io/sponsorships](https://coolify.io/sponsorships)
|
|
||||||
|
|
||||||
Thank you so much!
|
|
||||||
|
|
||||||
Special thanks to our biggest sponsors!
|
|
||||||
|
|
||||||
### Special Sponsors
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
* [CCCareers](https://cccareers.org/) - A career development platform connecting coding bootcamp graduates with job opportunities in the tech industry.
|
|
||||||
* [Hetzner](http://htznr.li/CoolifyXHetzner) - A German web hosting company offering affordable dedicated servers, cloud services, and web hosting solutions.
|
|
||||||
* [Logto](https://logto.io/?ref=coolify) - An open-source authentication and authorization solution for building secure login systems and managing user identities.
|
|
||||||
* [Tolgee](https://tolgee.io/?ref=coolify) - Developer & translator friendly web-based localization platform.
|
|
||||||
* [BC Direct](https://bc.direct/?ref=coolify.io) - A digital marketing agency specializing in e-commerce solutions and online business growth strategies.
|
|
||||||
* [QuantCDN](https://www.quantcdn.io/?ref=coolify.io) - A content delivery network (CDN) optimizing website performance through global content distribution.
|
|
||||||
* [Arcjet](https://arcjet.com/?ref=coolify.io) - A cloud-based platform providing real-time protection against API abuse and bot attacks.
|
|
||||||
* [SupaGuide](https://supa.guide/?ref=coolify.io) - A comprehensive resource hub offering guides and tutorials for web development using Supabase.
|
|
||||||
* [GoldenVM](https://billing.goldenvm.com/?ref=coolify.io) - A cloud hosting provider offering scalable infrastructure solutions for businesses of all sizes.
|
|
||||||
* [Tigris](https://tigrisdata.com/?ref=coolify.io) - A fully managed serverless object storage service compatible with Amazon S3 API. Offers high performance, scalability, and built-in search capabilities for efficient data management.
|
|
||||||
* [Cloudify.ro](https://cloudify.ro/?ref=coolify.io) - A cloud hosting provider offering scalable infrastructure solutions for businesses of all sizes.
|
|
||||||
* [Syntaxfm](https://syntax.fm/?ref=coolify.io) - Podcast for web developers.
|
|
||||||
* [PFGlabs](https://pfglabs.com/?ref=coolify.io) - Build real project with Golang.
|
|
||||||
* [Treive](https://trieve.ai/?ref=coolify.io) - An AI-powered search and discovery platform for enhancing information retrieval in large datasets.
|
|
||||||
* [Blacksmith](https://blacksmith.sh/?ref=coolify.io) - A cloud-native platform for automating infrastructure provisioning and management across multiple cloud providers.
|
|
||||||
* [Brand Dev](https://brand.dev/?ref=coolify.io) - The #1 Brand API for B2B software startups - instantly pull logos, fonts, descriptions, social links, slogans, and so much more from any domain via a single api call.
|
|
||||||
* [Jobscollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - A job search platform connecting professionals with remote work opportunities across various industries.
|
|
||||||
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - A web hosting provider offering affordable hosting solutions, domain registration, and website building tools.
|
|
||||||
* [Glueops](https://www.glueops.dev/?ref=coolify.io) - A DevOps consulting company providing infrastructure automation and cloud optimization services.
|
|
||||||
* [Ubicloud](https://ubicloud.com/?ref=coolify.io) - An open-source alternative to hyperscale cloud providers, offering high-performance cloud computing services.
|
|
||||||
* [Juxtdigital](https://juxtdigital.dev/?ref=coolify.io) - A digital agency offering web development, design, and digital marketing services for businesses.
|
|
||||||
* [Saasykit](https://saasykit.com/?ref=coolify.io) - A Laravel-based boilerplate providing essential components and features for building SaaS applications quickly.
|
|
||||||
* [Massivegrid](https://massivegrid.com/?ref=coolify.io) - A cloud hosting provider offering scalable infrastructure solutions for businesses of all sizes.
|
|
||||||
* [LiquidWeb](https://liquidweb.com/?utm_source=coolify.io) - Fast web hosting provider.
|
|
||||||
|
|
||||||
|
|
||||||
## Github Sponsors ($40+)
|
|
||||||
<a href="https://serpapi.com/?ref=coolify.io"><img width="60px" alt="SerpAPI" src="https://github.com/serpapi.png"/></a>
|
|
||||||
<a href="https://typebot.io/?ref=coolify.io"><img src="https://pbs.twimg.com/profile_images/1509194008366657543/9I-C7uWT_400x400.jpg" width="60px" alt="typebot"/></a>
|
|
||||||
<a href="https://www.runpod.io/?ref=coolify.io">
|
|
||||||
<svg style="width:60px;height:60px;background:#fff;" xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 200 200"><g><path d="M74.5 51.1c-25.4 14.9-27 16-29.6 20.2-1.8 3-1.9 5.3-1.9 32.3 0 21.7.3 29.4 1.3 30.6 1.9 2.5 46.7 27.9 48.5 27.6 1.5-.3 1.7-3.1 2-27.7.2-21.9 0-27.8-1.1-29.5-.8-1.2-9.9-6.8-20.2-12.6-10.3-5.8-19.4-11.5-20.2-12.7-1.8-2.6-.9-5.9 1.8-7.4 1.6-.8 6.3 0 21.8 4C87.8 78.7 98 81 99.6 81c4.4 0 49.9-25.9 49.9-28.4 0-1.6-3.4-2.8-24-8.2-13.2-3.5-25.1-6.3-26.5-6.3-1.4.1-12.4 5.9-24.5 13z"></path><path d="m137.2 68.1-3.3 2.1 6.3 3.7c3.5 2 6.3 4.3 6.3 5.1 0 .9-8 6.1-19.4 12.6-10.6 6-20 11.9-20.7 12.9-1.2 1.6-1.4 7.2-1.2 29.4.3 24.8.5 27.6 2 27.9 1.8.3 46.6-25.1 48.6-27.6.9-1.2 1.2-8.8 1.2-30.2s-.3-29-1.2-30.2c-1.6-1.9-12.1-7.8-13.9-7.8-.8 0-2.9 1-4.7 2.1z"></path></g></svg></a>
|
|
||||||
<a href="https://lightspeed.run/?ref=coolify.io"><img src="https://github.com/lightspeedrun.png" width="60px" alt="Lightspeed.run"/></a>
|
|
||||||
<a href="https://dartnode.com/?ref=coolify.io"><img src="https://github.com/DartNode-com.png" width="60px" alt="DartNode"/></a>
|
|
||||||
<a href="https://www.flint.sh/en/home?ref=coolify.io"> <img src="https://github.com/Flint-company.png" width="60px" alt="FlintCompany"/></a>
|
|
||||||
<a href="https://americancloud.com/?ref=coolify.io"><img src="https://github.com/American-Cloud.png" width="60px" alt="American Cloud"/></a>
|
|
||||||
<a href="https://cryptojobslist.com/?ref=coolify.io"><img src="https://github.com/cryptojobslist.png" width="60px" alt="CryptoJobsList" /></a>
|
|
||||||
<a href="https://codext.link/coolify-io?ref=coolify.io"><img src="./other/logos/codext.jpg" width="60px" alt="Codext" /></a>
|
|
||||||
<a href="https://x.com/mrsmith9ja?ref=coolify.io"><img width="60px" alt="Thompson Edolo" src="https://github.com/verygreenboi.png"/></a>
|
|
||||||
<a href="https://www.uxwizz.com/?ref=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
|
|
||||||
<a href="https://github.com/Flowko"><img src="https://barrad.me/_ipx/f_webp&s_300x300/younes.jpg" width="60px" alt="Younes Barrad" /></a>
|
|
||||||
<a href="https://github.com/automazeio"><img src="https://github.com/automazeio.png" width="60px" alt="Automaze" /></a>
|
|
||||||
<a href="https://github.com/corentinclichy"><img src="https://github.com/corentinclichy.png" width="60px" alt="Corentin Clichy" /></a>
|
|
||||||
<a href="https://github.com/Niki2k1"><img src="https://github.com/Niki2k1.png" width="60px" alt="Niklas Lausch" /></a>
|
|
||||||
<a href="https://github.com/pixelinfinito"><img src="https://github.com/pixelinfinito.png" width="60px" alt="Pixel Infinito" /></a>
|
|
||||||
<a href="https://github.com/whitesidest"><img src="https://avatars.githubusercontent.com/u/12365916?s=52&v=4" width="60px" alt="Tyler Whitesides" /></a>
|
|
||||||
<a href="https://github.com/aniftyco"><img src="https://github.com/aniftyco.png" width="60px" alt="NiftyCo" /></a>
|
|
||||||
<a href="https://github.com/iujlaki"><img src="https://github.com/iujlaki.png" width="60px" alt="Imre Ujlaki" /></a>
|
|
||||||
<a href="https://il.ly"><img src="https://github.com/Illyism.png" width="60px" alt="Ilias Ism" /></a>
|
|
||||||
<a href="https://www.breakcold.com/?utm_source=coolify.io"><img src="https://github.com/breakcold.png" width="60px" alt="Breakcold" /></a>
|
|
||||||
<a href="https://github.com/urtho"><img src="https://github.com/urtho.png" width="60px" alt="Paweł Pierścionek" /></a>
|
|
||||||
<a href="https://github.com/monocursive"><img src="https://github.com/monocursive.png" width="60px" alt="Michael Mazurczak" /></a>
|
|
||||||
<a href="https://formbricks.com/?utm_source=coolify.io"><img src="https://github.com/formbricks.png" width="60px" alt="Formbricks" /></a>
|
|
||||||
<a href="https://startupfa.me?utm_source=coolify.io"><img src="https://github.com/startupfame.png" width="60px" alt="StartupFame" /></a>
|
|
||||||
<a href="https://bsky.app/profile/jyc.dev"><img src="https://github.com/jycouet.png" width="60px" alt="jyc.dev" /></a>
|
|
||||||
<a href="https://bitlaunch.io/?utm_source=coolify.io"><img src="https://github.com/bitlaunchio.png" width="60px" alt="BitLaunch" /></a>
|
|
||||||
<a href="https://internetgarden.co/?utm_source=coolify.io"><img src="./other/logos/internetgarden.ico" width="60px" alt="Internet Garden" /></a>
|
|
||||||
<a href="https://jonasjaeger.com?utm_source=coolify.io"><img src="https://github.com/toxin20.png" width="60px" alt="Jonas Jaeger" /></a>
|
|
||||||
<a href="https://github.com/therealjp?utm_source=coolify.io"><img src="https://github.com/therealjp.png" width="60px" alt="JP" /></a>
|
|
||||||
<a href="https://evercam.io/?utm_source=coolify.io"><img src="https://github.com/evercam.png" width="60px" alt="Evercam" /></a>
|
|
||||||
<a href="https://web3.career/?utm_source=coolify.io"><img src="https://web3.career/favicon1.png" width="60px" alt="Web3 Career" /></a>
|
|
||||||
|
|
||||||
## Organizations
|
|
||||||
<a href="https://opencollective.com/coollabsio/organization/0/website"><img src="https://opencollective.com/coollabsio/organization/0/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/coollabsio/organization/1/website"><img src="https://opencollective.com/coollabsio/organization/1/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/coollabsio/organization/2/website"><img src="https://opencollective.com/coollabsio/organization/2/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/coollabsio/organization/3/website"><img src="https://opencollective.com/coollabsio/organization/3/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/coollabsio/organization/4/website"><img src="https://opencollective.com/coollabsio/organization/4/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/coollabsio/organization/5/website"><img src="https://opencollective.com/coollabsio/organization/5/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/coollabsio/organization/6/website"><img src="https://opencollective.com/coollabsio/organization/6/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/coollabsio/organization/7/website"><img src="https://opencollective.com/coollabsio/organization/7/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/coollabsio/organization/8/website"><img src="https://opencollective.com/coollabsio/organization/8/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/coollabsio/organization/9/website"><img src="https://opencollective.com/coollabsio/organization/9/avatar.svg"></a>
|
|
||||||
|
|
||||||
|
|
||||||
## Individuals
|
|
||||||
|
|
||||||
<a href="https://opencollective.com/coollabsio"><img src="https://opencollective.com/coollabsio/individuals.svg?width=890"></a>
|
|
||||||
|
|
||||||
# Cloud
|
# Cloud
|
||||||
|
|
||||||
If you do not want to self-host Coolify, there is a paid cloud version available: [app.coolify.io](https://app.coolify.io)
|
If you do not want to self-host Coolify, there is a paid cloud version available: [app.coolify.io](https://app.coolify.io)
|
||||||
@@ -136,6 +44,100 @@ By subscribing to the cloud version, you get the Coolify server for the same pri
|
|||||||
- Better support
|
- Better support
|
||||||
- Less maintenance for you
|
- Less maintenance for you
|
||||||
|
|
||||||
|
# Donations
|
||||||
|
To stay completely free and open-source, with no feature behind the paywall and evolve the project, we need your help. If you like Coolify, please consider donating to help us fund the project's future development.
|
||||||
|
|
||||||
|
[coolify.io/sponsorships](https://coolify.io/sponsorships)
|
||||||
|
|
||||||
|
Thank you so much!
|
||||||
|
|
||||||
|
## Big Sponsors
|
||||||
|
|
||||||
|
* [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management
|
||||||
|
* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform
|
||||||
|
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
|
||||||
|
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
|
||||||
|
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
|
||||||
|
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
|
||||||
|
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers
|
||||||
|
* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase
|
||||||
|
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
|
||||||
|
* [Trieve](https://trieve.ai?ref=coolify.io) - AI-powered search and analytics
|
||||||
|
* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data
|
||||||
|
* [Darweb](https://darweb.nl/?ref=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions
|
||||||
|
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
|
||||||
|
* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor
|
||||||
|
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
|
||||||
|
* [WZ-IT](https://wz-it.com/?ref=coolify.io) - German agency for customised cloud solutions
|
||||||
|
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
|
||||||
|
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform
|
||||||
|
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
|
||||||
|
* [QuantCDN](https://www.quantcdn.io?ref=coolify.io) - Enterprise-grade content delivery network
|
||||||
|
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
|
||||||
|
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
|
||||||
|
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital transformation and web solutions
|
||||||
|
* [Cloudify.ro](https://cloudify.ro?ref=coolify.io) - Cloud hosting solutions
|
||||||
|
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
|
||||||
|
* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services
|
||||||
|
* [MassiveGrid](https://massivegrid.com?ref=coolify.io) - Enterprise cloud hosting solutions
|
||||||
|
* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers
|
||||||
|
* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform
|
||||||
|
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform
|
||||||
|
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
|
||||||
|
* [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure
|
||||||
|
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
|
||||||
|
|
||||||
|
## Small Sponsors
|
||||||
|
|
||||||
|
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
|
||||||
|
<a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a>
|
||||||
|
<a href="https://github.com/iujlaki"><img width="60px" alt="Imre Ujlaki" src="https://github.com/iujlaki.png"/></a>
|
||||||
|
<a href="https://bsky.app/profile/jyc.dev"><img width="60px" alt="jyc.dev" src="https://github.com/jycouet.png"/></a>
|
||||||
|
<a href="https://github.com/therealjp?utm_source=coolify.io"><img width="60px" alt="TheRealJP" src="https://github.com/therealjp.png"/></a>
|
||||||
|
<a href="https://360creators.com/?utm_source=coolify.io"><img width="60px" alt="360Creators" src="https://opencollective-production.s3.us-west-1.amazonaws.com/account-avatar/503e0953-bff7-4296-b4cc-5e36d40eecc0/icon-360creators.png"/></a>
|
||||||
|
<a href="https://github.com/aniftyco"><img width="60px" alt="NiftyCo" src="https://github.com/aniftyco.png"/></a>
|
||||||
|
<a href="https://dry.software/?utm_source=coolify.io"><img width="60px" alt="Dry Software" src="https://github.com/dry-software.png"/></a>
|
||||||
|
<a href="https://lightspeed.run/?utm_source=coolify.io"><img width="60px" alt="Lightspeed.run" src="https://github.com/lightspeedrun.png"/></a>
|
||||||
|
<a href="https://linkdr.com?utm_source=coolify.io"><img width="60px" alt="LinkDr" src="https://github.com/LLM-Inc.png"/></a>
|
||||||
|
<a href="http://gravitywiz.com/?utm_source=coolify.io"><img width="60px" alt="Gravity Wiz" src="https://github.com/gravitywiz.png"/></a>
|
||||||
|
<a href="https://bitlaunch.io/?utm_source=coolify.io"><img width="60px" alt="BitLaunch" src="https://github.com/bitlaunchio.png"/></a>
|
||||||
|
<a href="https://bestforandroid.com/?utm_source=coolify.io"><img width="60px" alt="Best for Android" src="https://github.com/bestforandroid.png"/></a>
|
||||||
|
<a href="https://il.ly/?utm_source=coolify.io"><img width="60px" alt="Ilias Ism" src="https://github.com/Illyism.png"/></a>
|
||||||
|
<a href="https://formbricks.com/?utm_source=coolify.io"><img width="60px" alt="Formbricks" src="https://github.com/formbricks.png"/></a>
|
||||||
|
<a href="https://www.serversearcher.com/"><img width="60px" alt="Server Searcher" src="https://github.com/serversearcher.png"/></a>
|
||||||
|
<a href="https://www.reshot.ai/?utm_source=coolify.io"><img width="60px" alt="Reshot" src="https://coolify.io/images/reshotai.png"/></a>
|
||||||
|
<a href="https://cirun.io/?utm_source=coolify.io"><img width="60px" alt="Cirun" src="https://coolify.io/images/cirun-logo.png"/></a>
|
||||||
|
<a href="https://typebot.io/?utm_source=coolify.io"><img width="60px" alt="Typebot" src="https://cdn.bsky.app/img/avatar/plain/did:plc:gwxcta3pccyim4z5vuultdqx/bafkreig23hci7e2qpdxicsshnuzujbcbcgmydxhbybkewszdezhdodv42m@jpeg"/></a>
|
||||||
|
<a href="https://cccareers.org/?utm_source=coolify.io"><img width="60px" alt="Creating Coding Careers" src="https://github.com/cccareers.png"/></a>
|
||||||
|
<a href="https://internetgarden.co/?utm_source=coolify.io"><img width="60px" alt="Internet Garden" src="https://coolify.io/images/internetgarden.ico"/></a>
|
||||||
|
<a href="https://web3.career/?utm_source=coolify.io"><img width="60px" alt="Web3 Jobs" src="https://coolify.io/images/web3jobs.png"/></a>
|
||||||
|
<a href="https://codext.link/coolify-io?utm_source=coolify.io"><img width="60px" alt="Codext" src="https://coolify.io/images/codext.jpg"/></a>
|
||||||
|
<a href="https://github.com/monocursive"><img width="60px" alt="Michael Mazurczak" src="https://github.com/monocursive.png"/></a>
|
||||||
|
<a href="https://fider.io/?utm_source=coolify.io"><img width="60px" alt="Fider" src="https://github.com/getfider.png"/></a>
|
||||||
|
<a href="https://www.flint.sh/en/home?utm_source=coolify.io"><img width="60px" alt="Flint" src="https://github.com/Flint-company.png"/></a>
|
||||||
|
<a href="https://github.com/urtho"><img width="60px" alt="Paweł Pierścionek" src="https://github.com/urtho.png"/></a>
|
||||||
|
<a href="https://www.runpod.io/?utm_source=coolify.io"><img width="60px" alt="RunPod" src="https://coolify.io/images/runpod.svg"/></a>
|
||||||
|
<a href="https://dartnode.com/?utm_source=coolify.io"><img width="60px" alt="DartNode" src="https://github.com/dartnode.png"/></a>
|
||||||
|
<a href="https://github.com/whitesidest"><img width="60px" alt="Tyler Whitesides" src="https://avatars.githubusercontent.com/u/12365916?s=52&v=4"/></a>
|
||||||
|
<a href="https://serpapi.com/?utm_source=coolify.io"><img width="60px" alt="SerpAPI" src="https://github.com/serpapi.png"/></a>
|
||||||
|
<a href="https://aquarela.io"><img width="60px" alt="Aquarela" src="https://github.com/aquarela-io.png"/></a>
|
||||||
|
<a href="https://cryptojobslist.com/?utm_source=coolify.io"><img width="60px" alt="Crypto Jobs List" src="https://github.com/cryptojobslist.png"/></a>
|
||||||
|
<a href="https://www.youtube.com/@AlfredNutile?utm_source=coolify.io"><img width="60px" alt="Alfred Nutile" src="https://github.com/alnutile.png"/></a>
|
||||||
|
<a href="https://startupfa.me?utm_source=coolify.io"><img width="60px" alt="Startup Fame" src="https://github.com/startupfame.png"/></a>
|
||||||
|
<a href="https://barrad.me/?utm_source=coolify.io"><img width="60px" alt="Younes Barrad" src="https://github.com/Flowko.png"/></a>
|
||||||
|
<a href="https://jonasjaeger.com?utm_source=coolify.io"><img width="60px" alt="Jonas Jaeger" src="https://github.com/toxin20.png"/></a>
|
||||||
|
<a href="https://pixel.ao/?utm_source=coolify.io"><img width="60px" alt="Pixel Infinito" src="https://github.com/pixelinfinito.png"/></a>
|
||||||
|
<a href="https://github.com/corentinclichy"><img width="60px" alt="Corentin Clichy" src="https://github.com/corentinclichy.png"/></a>
|
||||||
|
<a href="https://x.com/mrsmith9ja?utm_source=coolify.io"><img width="60px" alt="Thompson Edolo" src="https://github.com/verygreenboi.png"/></a>
|
||||||
|
<a href="https://devhuset.no?utm_source=coolify.io"><img width="60px" alt="Devhuset" src="https://github.com/devhuset.png"/></a>
|
||||||
|
<a href="https://arvensis.systems/?utm_source=coolify.io"><img width="60px" alt="Arvensis Systems" src="https://coolify.io/images/arvensis.png"/></a>
|
||||||
|
<a href="https://github.com/Niki2k1"><img width="60px" alt="Niklas Lausch" src="https://github.com/Niki2k1.png"/></a>
|
||||||
|
<a href="https://capgo.app/?utm_source=coolify.io"><img width="60px" alt="Cap-go" src="https://github.com/cap-go.png"/></a>
|
||||||
|
<a href="https://interviewpal.com/?utm_source=coolify.io"><img width="60px" alt="InterviewPal" src="/public/svgs/interviewpal.svg"/></a>
|
||||||
|
|
||||||
|
|
||||||
|
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
|
||||||
|
|
||||||
# Recognitions
|
# Recognitions
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -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->delete_connected_networks($application->uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ class RunRemoteProcess
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$processResult = $process->wait();
|
$processResult = $process->wait();
|
||||||
// $processResult = Process::timeout($timeout)->run($this->getCommand(), $this->handleOutput(...));
|
|
||||||
if ($this->activity->properties->get('status') === ProcessStatus::ERROR->value) {
|
if ($this->activity->properties->get('status') === ProcessStatus::ERROR->value) {
|
||||||
$status = ProcessStatus::ERROR;
|
$status = ProcessStatus::ERROR;
|
||||||
} else {
|
} else {
|
||||||
@@ -105,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());
|
||||||
|
|||||||
@@ -22,75 +22,39 @@ class StartDatabaseProxy
|
|||||||
|
|
||||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database)
|
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database)
|
||||||
{
|
{
|
||||||
$internalPort = null;
|
$databaseType = $database->database_type;
|
||||||
$type = $database->getMorphClass();
|
|
||||||
$network = data_get($database, 'destination.network');
|
$network = data_get($database, 'destination.network');
|
||||||
$server = data_get($database, 'destination.server');
|
$server = data_get($database, 'destination.server');
|
||||||
$containerName = data_get($database, 'uuid');
|
$containerName = data_get($database, 'uuid');
|
||||||
$proxyContainerName = "{$database->uuid}-proxy";
|
$proxyContainerName = "{$database->uuid}-proxy";
|
||||||
|
$isSSLEnabled = $database->enable_ssl ?? false;
|
||||||
|
|
||||||
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||||
$databaseType = $database->databaseType();
|
$databaseType = $database->databaseType();
|
||||||
// $connectPredefined = data_get($database, 'service.connect_to_docker_network');
|
|
||||||
$network = $database->service->uuid;
|
$network = $database->service->uuid;
|
||||||
$server = data_get($database, 'service.destination.server');
|
$server = data_get($database, 'service.destination.server');
|
||||||
$proxyContainerName = "{$database->service->uuid}-proxy";
|
$proxyContainerName = "{$database->service->uuid}-proxy";
|
||||||
switch ($databaseType) {
|
$containerName = "{$database->name}-{$database->service->uuid}";
|
||||||
case 'standalone-mariadb':
|
|
||||||
$type = \App\Models\StandaloneMariadb::class;
|
|
||||||
$containerName = "mariadb-{$database->service->uuid}";
|
|
||||||
break;
|
|
||||||
case 'standalone-mongodb':
|
|
||||||
$type = \App\Models\StandaloneMongodb::class;
|
|
||||||
$containerName = "mongodb-{$database->service->uuid}";
|
|
||||||
break;
|
|
||||||
case 'standalone-mysql':
|
|
||||||
$type = \App\Models\StandaloneMysql::class;
|
|
||||||
$containerName = "mysql-{$database->service->uuid}";
|
|
||||||
break;
|
|
||||||
case 'standalone-postgresql':
|
|
||||||
$type = \App\Models\StandalonePostgresql::class;
|
|
||||||
$containerName = "postgresql-{$database->service->uuid}";
|
|
||||||
break;
|
|
||||||
case 'standalone-redis':
|
|
||||||
$type = \App\Models\StandaloneRedis::class;
|
|
||||||
$containerName = "redis-{$database->service->uuid}";
|
|
||||||
break;
|
|
||||||
case 'standalone-keydb':
|
|
||||||
$type = \App\Models\StandaloneKeydb::class;
|
|
||||||
$containerName = "keydb-{$database->service->uuid}";
|
|
||||||
break;
|
|
||||||
case 'standalone-dragonfly':
|
|
||||||
$type = \App\Models\StandaloneDragonfly::class;
|
|
||||||
$containerName = "dragonfly-{$database->service->uuid}";
|
|
||||||
break;
|
|
||||||
case 'standalone-clickhouse':
|
|
||||||
$type = \App\Models\StandaloneClickhouse::class;
|
|
||||||
$containerName = "clickhouse-{$database->service->uuid}";
|
|
||||||
break;
|
|
||||||
case 'standalone-supabase/postgres':
|
|
||||||
$type = \App\Models\StandalonePostgresql::class;
|
|
||||||
$containerName = "supabase-db-{$database->service->uuid}";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if ($type === \App\Models\StandaloneRedis::class) {
|
$internalPort = match ($databaseType) {
|
||||||
$internalPort = 6379;
|
'standalone-mariadb', 'standalone-mysql' => 3306,
|
||||||
} elseif ($type === \App\Models\StandalonePostgresql::class) {
|
'standalone-postgresql', 'standalone-supabase/postgres' => 5432,
|
||||||
$internalPort = 5432;
|
'standalone-redis', 'standalone-keydb', 'standalone-dragonfly' => 6379,
|
||||||
} elseif ($type === \App\Models\StandaloneMongodb::class) {
|
'standalone-clickhouse' => 9000,
|
||||||
$internalPort = 27017;
|
'standalone-mongodb' => 27017,
|
||||||
} elseif ($type === \App\Models\StandaloneMysql::class) {
|
default => throw new \Exception("Unsupported database type: $databaseType"),
|
||||||
$internalPort = 3306;
|
};
|
||||||
} elseif ($type === \App\Models\StandaloneMariadb::class) {
|
if ($isSSLEnabled) {
|
||||||
$internalPort = 3306;
|
$internalPort = match ($databaseType) {
|
||||||
} elseif ($type === \App\Models\StandaloneKeydb::class) {
|
'standalone-redis', 'standalone-keydb', 'standalone-dragonfly' => 6380,
|
||||||
$internalPort = 6379;
|
default => throw new \Exception("Unsupported database type: $databaseType"),
|
||||||
} elseif ($type === \App\Models\StandaloneDragonfly::class) {
|
};
|
||||||
$internalPort = 6379;
|
|
||||||
} elseif ($type === \App\Models\StandaloneClickhouse::class) {
|
|
||||||
$internalPort = 9000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$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;
|
||||||
@@ -106,19 +70,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,
|
||||||
@@ -128,6 +83,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',
|
||||||
@@ -150,15 +112,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Actions\Database;
|
namespace App\Actions\Database;
|
||||||
|
|
||||||
|
use App\Helpers\SslHelper;
|
||||||
|
use App\Models\SslCertificate;
|
||||||
use App\Models\StandaloneDragonfly;
|
use App\Models\StandaloneDragonfly;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
@@ -16,24 +18,81 @@ class StartDragonfly
|
|||||||
|
|
||||||
public string $configuration_dir;
|
public string $configuration_dir;
|
||||||
|
|
||||||
|
private ?SslCertificate $ssl_certificate = null;
|
||||||
|
|
||||||
public function handle(StandaloneDragonfly $database)
|
public function handle(StandaloneDragonfly $database)
|
||||||
{
|
{
|
||||||
$this->database = $database;
|
$this->database = $database;
|
||||||
|
|
||||||
$startCommand = "dragonfly --requirepass {$this->database->dragonfly_password}";
|
|
||||||
|
|
||||||
$container_name = $this->database->uuid;
|
$container_name = $this->database->uuid;
|
||||||
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||||
|
|
||||||
$this->commands = [
|
$this->commands = [
|
||||||
"echo 'Starting database.'",
|
"echo 'Starting database.'",
|
||||||
|
"echo 'Creating directories.'",
|
||||||
"mkdir -p $this->configuration_dir",
|
"mkdir -p $this->configuration_dir",
|
||||||
|
"echo 'Directories created successfully.'",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (! $this->database->enable_ssl) {
|
||||||
|
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
|
||||||
|
$this->database->sslCertificates()->delete();
|
||||||
|
$this->database->fileStorages()
|
||||||
|
->where('resource_type', $this->database->getMorphClass())
|
||||||
|
->where('resource_id', $this->database->id)
|
||||||
|
->get()
|
||||||
|
->filter(function ($storage) {
|
||||||
|
return in_array($storage->mount_path, [
|
||||||
|
'/etc/dragonfly/certs/server.crt',
|
||||||
|
'/etc/dragonfly/certs/server.key',
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->each(function ($storage) {
|
||||||
|
$storage->delete();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$this->commands[] = "echo 'Setting up SSL for this database.'";
|
||||||
|
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||||
|
|
||||||
|
$server = $this->database->destination->server;
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$server->generateCaCertificate();
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ssl_certificate = $this->database->sslCertificates()->first();
|
||||||
|
|
||||||
|
if (! $this->ssl_certificate) {
|
||||||
|
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
|
||||||
|
$this->ssl_certificate = SslHelper::generateSslCertificate(
|
||||||
|
commonName: $this->database->uuid,
|
||||||
|
resourceType: $this->database->getMorphClass(),
|
||||||
|
resourceId: $this->database->id,
|
||||||
|
serverId: $server->id,
|
||||||
|
caCert: $caCert->ssl_certificate,
|
||||||
|
caKey: $caCert->ssl_private_key,
|
||||||
|
configurationDir: $this->configuration_dir,
|
||||||
|
mountPath: '/etc/dragonfly/certs',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$container_name = $this->database->uuid;
|
||||||
|
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||||
|
|
||||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||||
$environment_variables = $this->generate_environment_variables();
|
$environment_variables = $this->generate_environment_variables();
|
||||||
|
$startCommand = $this->buildStartCommand();
|
||||||
|
|
||||||
$docker_compose = [
|
$docker_compose = [
|
||||||
'services' => [
|
'services' => [
|
||||||
@@ -70,27 +129,55 @@ class StartDragonfly
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! is_null($this->database->limits_cpuset)) {
|
if (! is_null($this->database->limits_cpuset)) {
|
||||||
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
||||||
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($this->database->ports_mappings_array) > 0) {
|
if (count($this->database->ports_mappings_array) > 0) {
|
||||||
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ??= [];
|
||||||
|
|
||||||
if (count($persistent_storages) > 0) {
|
if (count($persistent_storages) > 0) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_storages;
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'],
|
||||||
|
$persistent_storages
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($persistent_file_volumes) > 0) {
|
if (count($persistent_file_volumes) > 0) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) {
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
return "$item->fs_path:$item->mount_path";
|
$docker_compose['services'][$container_name]['volumes'],
|
||||||
})->toArray();
|
$persistent_file_volumes->map(function ($item) {
|
||||||
|
return "$item->fs_path:$item->mount_path";
|
||||||
|
})->toArray()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($volume_names) > 0) {
|
if (count($volume_names) > 0) {
|
||||||
$docker_compose['volumes'] = $volume_names;
|
$docker_compose['volumes'] = $volume_names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'type' => 'bind',
|
||||||
|
'source' => '/data/coolify/ssl/coolify-ca.crt',
|
||||||
|
'target' => '/etc/dragonfly/certs/coolify-ca.crt',
|
||||||
|
'read_only' => true,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Add custom docker run options
|
// Add custom docker run options
|
||||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||||
@@ -102,12 +189,32 @@ class StartDragonfly
|
|||||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||||
|
}
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
$this->commands[] = "echo 'Database started.'";
|
$this->commands[] = "echo 'Database started.'";
|
||||||
|
|
||||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildStartCommand(): string
|
||||||
|
{
|
||||||
|
$command = "dragonfly --requirepass {$this->database->dragonfly_password}";
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$sslArgs = [
|
||||||
|
'--tls',
|
||||||
|
'--tls_cert_file /etc/dragonfly/certs/server.crt',
|
||||||
|
'--tls_key_file /etc/dragonfly/certs/server.key',
|
||||||
|
'--tls_ca_cert_file /etc/dragonfly/certs/coolify-ca.crt',
|
||||||
|
];
|
||||||
|
$command .= ' '.implode(' ', $sslArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $command;
|
||||||
|
}
|
||||||
|
|
||||||
private function generate_local_persistent_volumes()
|
private function generate_local_persistent_volumes()
|
||||||
{
|
{
|
||||||
$local_persistent_volumes = [];
|
$local_persistent_volumes = [];
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Actions\Database;
|
namespace App\Actions\Database;
|
||||||
|
|
||||||
|
use App\Helpers\SslHelper;
|
||||||
|
use App\Models\SslCertificate;
|
||||||
use App\Models\StandaloneKeydb;
|
use App\Models\StandaloneKeydb;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
@@ -17,26 +19,84 @@ class StartKeydb
|
|||||||
|
|
||||||
public string $configuration_dir;
|
public string $configuration_dir;
|
||||||
|
|
||||||
|
private ?SslCertificate $ssl_certificate = null;
|
||||||
|
|
||||||
public function handle(StandaloneKeydb $database)
|
public function handle(StandaloneKeydb $database)
|
||||||
{
|
{
|
||||||
$this->database = $database;
|
$this->database = $database;
|
||||||
|
|
||||||
$startCommand = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes";
|
|
||||||
|
|
||||||
$container_name = $this->database->uuid;
|
$container_name = $this->database->uuid;
|
||||||
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||||
|
|
||||||
$this->commands = [
|
$this->commands = [
|
||||||
"echo 'Starting database.'",
|
"echo 'Starting database.'",
|
||||||
|
"echo 'Creating directories.'",
|
||||||
"mkdir -p $this->configuration_dir",
|
"mkdir -p $this->configuration_dir",
|
||||||
|
"echo 'Directories created successfully.'",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (! $this->database->enable_ssl) {
|
||||||
|
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
|
||||||
|
$this->database->sslCertificates()->delete();
|
||||||
|
$this->database->fileStorages()
|
||||||
|
->where('resource_type', $this->database->getMorphClass())
|
||||||
|
->where('resource_id', $this->database->id)
|
||||||
|
->get()
|
||||||
|
->filter(function ($storage) {
|
||||||
|
return in_array($storage->mount_path, [
|
||||||
|
'/etc/keydb/certs/server.crt',
|
||||||
|
'/etc/keydb/certs/server.key',
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->each(function ($storage) {
|
||||||
|
$storage->delete();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$this->commands[] = "echo 'Setting up SSL for this database.'";
|
||||||
|
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||||
|
|
||||||
|
$server = $this->database->destination->server;
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$server->generateCaCertificate();
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ssl_certificate = $this->database->sslCertificates()->first();
|
||||||
|
|
||||||
|
if (! $this->ssl_certificate) {
|
||||||
|
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
|
||||||
|
$this->ssl_certificate = SslHelper::generateSslCertificate(
|
||||||
|
commonName: $this->database->uuid,
|
||||||
|
resourceType: $this->database->getMorphClass(),
|
||||||
|
resourceId: $this->database->id,
|
||||||
|
serverId: $server->id,
|
||||||
|
caCert: $caCert->ssl_certificate,
|
||||||
|
caKey: $caCert->ssl_private_key,
|
||||||
|
configurationDir: $this->configuration_dir,
|
||||||
|
mountPath: '/etc/keydb/certs',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$container_name = $this->database->uuid;
|
||||||
|
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||||
|
|
||||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||||
$environment_variables = $this->generate_environment_variables();
|
$environment_variables = $this->generate_environment_variables();
|
||||||
$this->add_custom_keydb();
|
$this->add_custom_keydb();
|
||||||
|
|
||||||
|
$startCommand = $this->buildStartCommand();
|
||||||
|
|
||||||
$docker_compose = [
|
$docker_compose = [
|
||||||
'services' => [
|
'services' => [
|
||||||
$container_name => [
|
$container_name => [
|
||||||
@@ -72,34 +132,67 @@ class StartKeydb
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! is_null($this->database->limits_cpuset)) {
|
if (! is_null($this->database->limits_cpuset)) {
|
||||||
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
||||||
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($this->database->ports_mappings_array) > 0) {
|
if (count($this->database->ports_mappings_array) > 0) {
|
||||||
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ??= [];
|
||||||
|
|
||||||
if (count($persistent_storages) > 0) {
|
if (count($persistent_storages) > 0) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_storages;
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
|
$persistent_storages
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($persistent_file_volumes) > 0) {
|
if (count($persistent_file_volumes) > 0) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) {
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
return "$item->fs_path:$item->mount_path";
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
})->toArray();
|
$persistent_file_volumes->map(function ($item) {
|
||||||
|
return "$item->fs_path:$item->mount_path";
|
||||||
|
})->toArray()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($volume_names) > 0) {
|
if (count($volume_names) > 0) {
|
||||||
$docker_compose['volumes'] = $volume_names;
|
$docker_compose['volumes'] = $volume_names;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) {
|
if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) {
|
||||||
$docker_compose['services'][$container_name]['volumes'][] = [
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
'type' => 'bind',
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
'source' => $this->configuration_dir.'/keydb.conf',
|
[
|
||||||
'target' => '/etc/keydb/keydb.conf',
|
[
|
||||||
'read_only' => true,
|
'type' => 'bind',
|
||||||
];
|
'source' => $this->configuration_dir.'/keydb.conf',
|
||||||
$docker_compose['services'][$container_name]['command'] = "keydb-server /etc/keydb/keydb.conf --requirepass {$this->database->keydb_password} --appendonly yes";
|
'target' => '/etc/keydb/keydb.conf',
|
||||||
|
'read_only' => true,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'type' => 'bind',
|
||||||
|
'source' => '/data/coolify/ssl/coolify-ca.crt',
|
||||||
|
'target' => '/etc/keydb/certs/coolify-ca.crt',
|
||||||
|
'read_only' => true,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom docker run options
|
// Add custom docker run options
|
||||||
@@ -112,6 +205,9 @@ class StartKeydb
|
|||||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||||
|
}
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
$this->commands[] = "echo 'Database started.'";
|
$this->commands[] = "echo 'Database started.'";
|
||||||
|
|
||||||
@@ -177,4 +273,36 @@ class StartKeydb
|
|||||||
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
|
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
|
||||||
Storage::disk('local')->delete("tmp/keydb.conf_{$this->database->uuid}");
|
Storage::disk('local')->delete("tmp/keydb.conf_{$this->database->uuid}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildStartCommand(): string
|
||||||
|
{
|
||||||
|
$hasKeydbConf = ! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf);
|
||||||
|
$keydbConfPath = '/etc/keydb/keydb.conf';
|
||||||
|
|
||||||
|
if ($hasKeydbConf) {
|
||||||
|
$confContent = $this->database->keydb_conf;
|
||||||
|
$hasRequirePass = str_contains($confContent, 'requirepass');
|
||||||
|
|
||||||
|
if ($hasRequirePass) {
|
||||||
|
$command = "keydb-server $keydbConfPath";
|
||||||
|
} else {
|
||||||
|
$command = "keydb-server $keydbConfPath --requirepass {$this->database->keydb_password}";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$command = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$sslArgs = [
|
||||||
|
'--tls-port 6380',
|
||||||
|
'--tls-cert-file /etc/keydb/certs/server.crt',
|
||||||
|
'--tls-key-file /etc/keydb/certs/server.key',
|
||||||
|
'--tls-ca-cert-file /etc/keydb/certs/coolify-ca.crt',
|
||||||
|
'--tls-auth-clients optional',
|
||||||
|
];
|
||||||
|
$command .= ' '.implode(' ', $sslArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $command;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Actions\Database;
|
namespace App\Actions\Database;
|
||||||
|
|
||||||
|
use App\Helpers\SslHelper;
|
||||||
|
use App\Models\SslCertificate;
|
||||||
use App\Models\StandaloneMariadb;
|
use App\Models\StandaloneMariadb;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
@@ -16,6 +18,8 @@ class StartMariadb
|
|||||||
|
|
||||||
public string $configuration_dir;
|
public string $configuration_dir;
|
||||||
|
|
||||||
|
private ?SslCertificate $ssl_certificate = null;
|
||||||
|
|
||||||
public function handle(StandaloneMariadb $database)
|
public function handle(StandaloneMariadb $database)
|
||||||
{
|
{
|
||||||
$this->database = $database;
|
$this->database = $database;
|
||||||
@@ -25,9 +29,64 @@ class StartMariadb
|
|||||||
|
|
||||||
$this->commands = [
|
$this->commands = [
|
||||||
"echo 'Starting database.'",
|
"echo 'Starting database.'",
|
||||||
|
"echo 'Creating directories.'",
|
||||||
"mkdir -p $this->configuration_dir",
|
"mkdir -p $this->configuration_dir",
|
||||||
|
"echo 'Directories created successfully.'",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (! $this->database->enable_ssl) {
|
||||||
|
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
|
||||||
|
|
||||||
|
$this->database->sslCertificates()->delete();
|
||||||
|
|
||||||
|
$this->database->fileStorages()
|
||||||
|
->where('resource_type', $this->database->getMorphClass())
|
||||||
|
->where('resource_id', $this->database->id)
|
||||||
|
->get()
|
||||||
|
->filter(function ($storage) {
|
||||||
|
return in_array($storage->mount_path, [
|
||||||
|
'/etc/mysql/certs/server.crt',
|
||||||
|
'/etc/mysql/certs/server.key',
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->each(function ($storage) {
|
||||||
|
$storage->delete();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$this->commands[] = "echo 'Setting up SSL for this database.'";
|
||||||
|
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||||
|
|
||||||
|
$server = $this->database->destination->server;
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$server->generateCaCertificate();
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ssl_certificate = $this->database->sslCertificates()->first();
|
||||||
|
|
||||||
|
if (! $this->ssl_certificate) {
|
||||||
|
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
|
||||||
|
$this->ssl_certificate = SslHelper::generateSslCertificate(
|
||||||
|
commonName: $this->database->uuid,
|
||||||
|
resourceType: $this->database->getMorphClass(),
|
||||||
|
resourceId: $this->database->id,
|
||||||
|
serverId: $server->id,
|
||||||
|
caCert: $caCert->ssl_certificate,
|
||||||
|
caKey: $caCert->ssl_private_key,
|
||||||
|
configurationDir: $this->configuration_dir,
|
||||||
|
mountPath: '/etc/mysql/certs',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||||
@@ -67,38 +126,81 @@ class StartMariadb
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! is_null($this->database->limits_cpuset)) {
|
if (! is_null($this->database->limits_cpuset)) {
|
||||||
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
||||||
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($this->database->ports_mappings_array) > 0) {
|
if (count($this->database->ports_mappings_array) > 0) {
|
||||||
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
||||||
}
|
}
|
||||||
if (count($persistent_storages) > 0) {
|
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_storages;
|
|
||||||
}
|
|
||||||
if (count($persistent_file_volumes) > 0) {
|
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) {
|
|
||||||
return "$item->fs_path:$item->mount_path";
|
|
||||||
})->toArray();
|
|
||||||
}
|
|
||||||
if (count($volume_names) > 0) {
|
if (count($volume_names) > 0) {
|
||||||
$docker_compose['volumes'] = $volume_names;
|
$docker_compose['volumes'] = $volume_names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ??= [];
|
||||||
|
|
||||||
|
if (count($persistent_storages) > 0) {
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'],
|
||||||
|
$persistent_storages
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($persistent_file_volumes) > 0) {
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'],
|
||||||
|
$persistent_file_volumes->map(function ($item) {
|
||||||
|
return "$item->fs_path:$item->mount_path";
|
||||||
|
})->toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'type' => 'bind',
|
||||||
|
'source' => '/data/coolify/ssl/coolify-ca.crt',
|
||||||
|
'target' => '/etc/mysql/certs/coolify-ca.crt',
|
||||||
|
'read_only' => true,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) {
|
if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) {
|
||||||
$docker_compose['services'][$container_name]['volumes'][] = [
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
'type' => 'bind',
|
$docker_compose['services'][$container_name]['volumes'],
|
||||||
'source' => $this->configuration_dir.'/custom-config.cnf',
|
[
|
||||||
'target' => '/etc/mysql/conf.d/custom-config.cnf',
|
[
|
||||||
'read_only' => true,
|
'type' => 'bind',
|
||||||
];
|
'source' => $this->configuration_dir.'/custom-config.cnf',
|
||||||
|
'target' => '/etc/mysql/conf.d/custom-config.cnf',
|
||||||
|
'read_only' => true,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom docker run options
|
// Add custom docker run options
|
||||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$docker_compose['services'][$container_name]['command'] = [
|
||||||
|
'mariadbd',
|
||||||
|
'--ssl-cert=/etc/mysql/certs/server.crt',
|
||||||
|
'--ssl-key=/etc/mysql/certs/server.key',
|
||||||
|
'--ssl-ca=/etc/mysql/certs/coolify-ca.crt',
|
||||||
|
'--require-secure-transport=1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||||
$docker_compose_base64 = base64_encode($docker_compose);
|
$docker_compose_base64 = base64_encode($docker_compose);
|
||||||
@@ -109,6 +211,9 @@ class StartMariadb
|
|||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
$this->commands[] = "echo 'Database started.'";
|
$this->commands[] = "echo 'Database started.'";
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$this->commands[] = executeInDocker($this->database->uuid, 'chown mysql:mysql /etc/mysql/certs/server.crt /etc/mysql/certs/server.key');
|
||||||
|
}
|
||||||
|
|
||||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Actions\Database;
|
namespace App\Actions\Database;
|
||||||
|
|
||||||
|
use App\Helpers\SslHelper;
|
||||||
|
use App\Models\SslCertificate;
|
||||||
use App\Models\StandaloneMongodb;
|
use App\Models\StandaloneMongodb;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
@@ -16,6 +18,8 @@ class StartMongodb
|
|||||||
|
|
||||||
public string $configuration_dir;
|
public string $configuration_dir;
|
||||||
|
|
||||||
|
private ?SslCertificate $ssl_certificate = null;
|
||||||
|
|
||||||
public function handle(StandaloneMongodb $database)
|
public function handle(StandaloneMongodb $database)
|
||||||
{
|
{
|
||||||
$this->database = $database;
|
$this->database = $database;
|
||||||
@@ -24,16 +28,69 @@ class StartMongodb
|
|||||||
|
|
||||||
$container_name = $this->database->uuid;
|
$container_name = $this->database->uuid;
|
||||||
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||||
|
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
$this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name;
|
$this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->commands = [
|
$this->commands = [
|
||||||
"echo 'Starting database.'",
|
"echo 'Starting database.'",
|
||||||
|
"echo 'Creating directories.'",
|
||||||
"mkdir -p $this->configuration_dir",
|
"mkdir -p $this->configuration_dir",
|
||||||
|
"echo 'Directories created successfully.'",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (! $this->database->enable_ssl) {
|
||||||
|
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
|
||||||
|
|
||||||
|
$this->database->sslCertificates()->delete();
|
||||||
|
|
||||||
|
$this->database->fileStorages()
|
||||||
|
->where('resource_type', $this->database->getMorphClass())
|
||||||
|
->where('resource_id', $this->database->id)
|
||||||
|
->get()
|
||||||
|
->filter(function ($storage) {
|
||||||
|
return in_array($storage->mount_path, [
|
||||||
|
'/etc/mongo/certs/server.pem',
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->each(function ($storage) {
|
||||||
|
$storage->delete();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$this->commands[] = "echo 'Setting up SSL for this database.'";
|
||||||
|
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||||
|
|
||||||
|
$server = $this->database->destination->server;
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$server->generateCaCertificate();
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->ssl_certificate = $this->database->sslCertificates()->first();
|
||||||
|
|
||||||
|
if (! $this->ssl_certificate) {
|
||||||
|
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
|
||||||
|
$this->ssl_certificate = SslHelper::generateSslCertificate(
|
||||||
|
commonName: $this->database->uuid,
|
||||||
|
resourceType: $this->database->getMorphClass(),
|
||||||
|
resourceId: $this->database->id,
|
||||||
|
serverId: $server->id,
|
||||||
|
caCert: $caCert->ssl_certificate,
|
||||||
|
caKey: $caCert->ssl_private_key,
|
||||||
|
configurationDir: $this->configuration_dir,
|
||||||
|
mountPath: '/etc/mongo/certs',
|
||||||
|
isPemKeyFileRequired: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||||
@@ -79,47 +136,123 @@ class StartMongodb
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! is_null($this->database->limits_cpuset)) {
|
if (! is_null($this->database->limits_cpuset)) {
|
||||||
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
||||||
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($this->database->ports_mappings_array) > 0) {
|
if (count($this->database->ports_mappings_array) > 0) {
|
||||||
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ??= [];
|
||||||
|
|
||||||
if (count($persistent_storages) > 0) {
|
if (count($persistent_storages) > 0) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_storages;
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
|
$persistent_storages
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($persistent_file_volumes) > 0) {
|
if (count($persistent_file_volumes) > 0) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) {
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
return "$item->fs_path:$item->mount_path";
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
})->toArray();
|
$persistent_file_volumes->map(function ($item) {
|
||||||
|
return "$item->fs_path:$item->mount_path";
|
||||||
|
})->toArray()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($volume_names) > 0) {
|
if (count($volume_names) > 0) {
|
||||||
$docker_compose['volumes'] = $volume_names;
|
$docker_compose['volumes'] = $volume_names;
|
||||||
}
|
}
|
||||||
if (! is_null($this->database->mongo_conf) || ! empty($this->database->mongo_conf)) {
|
|
||||||
$docker_compose['services'][$container_name]['volumes'][] = [
|
if (! empty($this->database->mongo_conf)) {
|
||||||
'type' => 'bind',
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
'source' => $this->configuration_dir.'/mongod.conf',
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
'target' => '/etc/mongo/mongod.conf',
|
[[
|
||||||
'read_only' => true,
|
'type' => 'bind',
|
||||||
];
|
'source' => $this->configuration_dir.'/mongod.conf',
|
||||||
$docker_compose['services'][$container_name]['command'] = $startCommand.' --config /etc/mongo/mongod.conf';
|
'target' => '/etc/mongo/mongod.conf',
|
||||||
|
'read_only' => true,
|
||||||
|
]]
|
||||||
|
);
|
||||||
|
$docker_compose['services'][$container_name]['command'] = ['mongod', '--config', '/etc/mongo/mongod.conf'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->add_default_database();
|
$this->add_default_database();
|
||||||
$docker_compose['services'][$container_name]['volumes'][] = [
|
|
||||||
'type' => 'bind',
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
'source' => $this->configuration_dir.'/docker-entrypoint-initdb.d',
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
'target' => '/docker-entrypoint-initdb.d',
|
[[
|
||||||
'read_only' => true,
|
'type' => 'bind',
|
||||||
];
|
'source' => $this->configuration_dir.'/docker-entrypoint-initdb.d',
|
||||||
|
'target' => '/docker-entrypoint-initdb.d',
|
||||||
|
'read_only' => true,
|
||||||
|
]]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'type' => 'bind',
|
||||||
|
'source' => '/data/coolify/ssl/coolify-ca.crt',
|
||||||
|
'target' => '/etc/mongo/certs/ca.pem',
|
||||||
|
'read_only' => true,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Add custom docker run options
|
// Add custom docker run options
|
||||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$commandParts = ['mongod'];
|
||||||
|
|
||||||
|
if (! empty($this->database->mongo_conf)) {
|
||||||
|
$commandParts = ['mongod', '--config', '/etc/mongo/mongod.conf'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sslConfig = match ($this->database->ssl_mode) {
|
||||||
|
'allow' => [
|
||||||
|
'--tlsMode=allowTLS',
|
||||||
|
'--tlsAllowConnectionsWithoutCertificates',
|
||||||
|
'--tlsAllowInvalidHostnames',
|
||||||
|
],
|
||||||
|
'prefer' => [
|
||||||
|
'--tlsMode=preferTLS',
|
||||||
|
'--tlsAllowConnectionsWithoutCertificates',
|
||||||
|
'--tlsAllowInvalidHostnames',
|
||||||
|
],
|
||||||
|
'require' => [
|
||||||
|
'--tlsMode=requireTLS',
|
||||||
|
'--tlsAllowConnectionsWithoutCertificates',
|
||||||
|
'--tlsAllowInvalidHostnames',
|
||||||
|
],
|
||||||
|
'verify-full' => [
|
||||||
|
'--tlsMode=requireTLS',
|
||||||
|
'--tlsAllowInvalidHostnames',
|
||||||
|
],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
$commandParts = [...$commandParts, ...$sslConfig];
|
||||||
|
$commandParts[] = '--tlsCAFile';
|
||||||
|
$commandParts[] = '/etc/mongo/certs/ca.pem';
|
||||||
|
$commandParts[] = '--tlsCertificateKeyFile';
|
||||||
|
$commandParts[] = '/etc/mongo/certs/server.pem';
|
||||||
|
|
||||||
|
$docker_compose['services'][$container_name]['command'] = $commandParts;
|
||||||
|
}
|
||||||
|
|
||||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||||
$docker_compose_base64 = base64_encode($docker_compose);
|
$docker_compose_base64 = base64_encode($docker_compose);
|
||||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||||
@@ -128,6 +261,9 @@ class StartMongodb
|
|||||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$this->commands[] = executeInDocker($this->database->uuid, 'chown mongodb:mongodb /etc/mongo/certs/server.pem');
|
||||||
|
}
|
||||||
$this->commands[] = "echo 'Database started.'";
|
$this->commands[] = "echo 'Database started.'";
|
||||||
|
|
||||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Actions\Database;
|
namespace App\Actions\Database;
|
||||||
|
|
||||||
|
use App\Helpers\SslHelper;
|
||||||
|
use App\Models\SslCertificate;
|
||||||
use App\Models\StandaloneMysql;
|
use App\Models\StandaloneMysql;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
@@ -16,6 +18,8 @@ class StartMysql
|
|||||||
|
|
||||||
public string $configuration_dir;
|
public string $configuration_dir;
|
||||||
|
|
||||||
|
private ?SslCertificate $ssl_certificate = null;
|
||||||
|
|
||||||
public function handle(StandaloneMysql $database)
|
public function handle(StandaloneMysql $database)
|
||||||
{
|
{
|
||||||
$this->database = $database;
|
$this->database = $database;
|
||||||
@@ -25,9 +29,64 @@ class StartMysql
|
|||||||
|
|
||||||
$this->commands = [
|
$this->commands = [
|
||||||
"echo 'Starting database.'",
|
"echo 'Starting database.'",
|
||||||
|
"echo 'Creating directories.'",
|
||||||
"mkdir -p $this->configuration_dir",
|
"mkdir -p $this->configuration_dir",
|
||||||
|
"echo 'Directories created successfully.'",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (! $this->database->enable_ssl) {
|
||||||
|
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
|
||||||
|
|
||||||
|
$this->database->sslCertificates()->delete();
|
||||||
|
|
||||||
|
$this->database->fileStorages()
|
||||||
|
->where('resource_type', $this->database->getMorphClass())
|
||||||
|
->where('resource_id', $this->database->id)
|
||||||
|
->get()
|
||||||
|
->filter(function ($storage) {
|
||||||
|
return in_array($storage->mount_path, [
|
||||||
|
'/etc/mysql/certs/server.crt',
|
||||||
|
'/etc/mysql/certs/server.key',
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->each(function ($storage) {
|
||||||
|
$storage->delete();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$this->commands[] = "echo 'Setting up SSL for this database.'";
|
||||||
|
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||||
|
|
||||||
|
$server = $this->database->destination->server;
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$server->generateCaCertificate();
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ssl_certificate = $this->database->sslCertificates()->first();
|
||||||
|
|
||||||
|
if (! $this->ssl_certificate) {
|
||||||
|
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
|
||||||
|
$this->ssl_certificate = SslHelper::generateSslCertificate(
|
||||||
|
commonName: $this->database->uuid,
|
||||||
|
resourceType: $this->database->getMorphClass(),
|
||||||
|
resourceId: $this->database->id,
|
||||||
|
serverId: $server->id,
|
||||||
|
caCert: $caCert->ssl_certificate,
|
||||||
|
caKey: $caCert->ssl_private_key,
|
||||||
|
configurationDir: $this->configuration_dir,
|
||||||
|
mountPath: '/etc/mysql/certs',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||||
@@ -67,39 +126,83 @@ class StartMysql
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! is_null($this->database->limits_cpuset)) {
|
if (! is_null($this->database->limits_cpuset)) {
|
||||||
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
||||||
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($this->database->ports_mappings_array) > 0) {
|
if (count($this->database->ports_mappings_array) > 0) {
|
||||||
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ??= [];
|
||||||
|
|
||||||
if (count($persistent_storages) > 0) {
|
if (count($persistent_storages) > 0) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_storages;
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
|
$persistent_storages
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($persistent_file_volumes) > 0) {
|
if (count($persistent_file_volumes) > 0) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) {
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
return "$item->fs_path:$item->mount_path";
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
})->toArray();
|
$persistent_file_volumes->map(function ($item) {
|
||||||
|
return "$item->fs_path:$item->mount_path";
|
||||||
|
})->toArray()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($volume_names) > 0) {
|
if (count($volume_names) > 0) {
|
||||||
$docker_compose['volumes'] = $volume_names;
|
$docker_compose['volumes'] = $volume_names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'type' => 'bind',
|
||||||
|
'source' => '/data/coolify/ssl/coolify-ca.crt',
|
||||||
|
'target' => '/etc/mysql/certs/coolify-ca.crt',
|
||||||
|
'read_only' => true,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) {
|
if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) {
|
||||||
$docker_compose['services'][$container_name]['volumes'][] = [
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
'type' => 'bind',
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
'source' => $this->configuration_dir.'/custom-config.cnf',
|
[
|
||||||
'target' => '/etc/mysql/conf.d/custom-config.cnf',
|
[
|
||||||
'read_only' => true,
|
'type' => 'bind',
|
||||||
];
|
'source' => $this->configuration_dir.'/custom-config.cnf',
|
||||||
|
'target' => '/etc/mysql/conf.d/custom-config.cnf',
|
||||||
|
'read_only' => true,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom docker run options
|
// Add custom docker run options
|
||||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$docker_compose['services'][$container_name]['command'] = [
|
||||||
|
'mysqld',
|
||||||
|
'--ssl-cert=/etc/mysql/certs/server.crt',
|
||||||
|
'--ssl-key=/etc/mysql/certs/server.key',
|
||||||
|
'--ssl-ca=/etc/mysql/certs/coolify-ca.crt',
|
||||||
|
'--require-secure-transport=1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||||
$docker_compose_base64 = base64_encode($docker_compose);
|
$docker_compose_base64 = base64_encode($docker_compose);
|
||||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||||
@@ -108,6 +211,11 @@ class StartMysql
|
|||||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->mysql_user}:{$this->database->mysql_user} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
|
||||||
|
}
|
||||||
|
|
||||||
$this->commands[] = "echo 'Database started.'";
|
$this->commands[] = "echo 'Database started.'";
|
||||||
|
|
||||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Actions\Database;
|
namespace App\Actions\Database;
|
||||||
|
|
||||||
|
use App\Helpers\SslHelper;
|
||||||
|
use App\Models\SslCertificate;
|
||||||
use App\Models\StandalonePostgresql;
|
use App\Models\StandalonePostgresql;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
@@ -18,6 +20,8 @@ class StartPostgresql
|
|||||||
|
|
||||||
public string $configuration_dir;
|
public string $configuration_dir;
|
||||||
|
|
||||||
|
private ?SslCertificate $ssl_certificate = null;
|
||||||
|
|
||||||
public function handle(StandalonePostgresql $database)
|
public function handle(StandalonePostgresql $database)
|
||||||
{
|
{
|
||||||
$this->database = $database;
|
$this->database = $database;
|
||||||
@@ -29,10 +33,65 @@ class StartPostgresql
|
|||||||
|
|
||||||
$this->commands = [
|
$this->commands = [
|
||||||
"echo 'Starting database.'",
|
"echo 'Starting database.'",
|
||||||
|
"echo 'Creating directories.'",
|
||||||
"mkdir -p $this->configuration_dir",
|
"mkdir -p $this->configuration_dir",
|
||||||
"mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d/",
|
"mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d/",
|
||||||
|
"echo 'Directories created successfully.'",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (! $this->database->enable_ssl) {
|
||||||
|
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
|
||||||
|
|
||||||
|
$this->database->sslCertificates()->delete();
|
||||||
|
|
||||||
|
$this->database->fileStorages()
|
||||||
|
->where('resource_type', $this->database->getMorphClass())
|
||||||
|
->where('resource_id', $this->database->id)
|
||||||
|
->get()
|
||||||
|
->filter(function ($storage) {
|
||||||
|
return in_array($storage->mount_path, [
|
||||||
|
'/var/lib/postgresql/certs/server.crt',
|
||||||
|
'/var/lib/postgresql/certs/server.key',
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->each(function ($storage) {
|
||||||
|
$storage->delete();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$this->commands[] = "echo 'Setting up SSL for this database.'";
|
||||||
|
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||||
|
|
||||||
|
$server = $this->database->destination->server;
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$server->generateCaCertificate();
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ssl_certificate = $this->database->sslCertificates()->first();
|
||||||
|
|
||||||
|
if (! $this->ssl_certificate) {
|
||||||
|
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
|
||||||
|
$this->ssl_certificate = SslHelper::generateSslCertificate(
|
||||||
|
commonName: $this->database->uuid,
|
||||||
|
resourceType: $this->database->getMorphClass(),
|
||||||
|
resourceId: $this->database->id,
|
||||||
|
serverId: $server->id,
|
||||||
|
caCert: $caCert->ssl_certificate,
|
||||||
|
caKey: $caCert->ssl_private_key,
|
||||||
|
configurationDir: $this->configuration_dir,
|
||||||
|
mountPath: '/var/lib/postgresql/certs',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||||
@@ -77,49 +136,84 @@ class StartPostgresql
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (filled($this->database->limits_cpuset)) {
|
if (filled($this->database->limits_cpuset)) {
|
||||||
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
||||||
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($this->database->ports_mappings_array) > 0) {
|
if (count($this->database->ports_mappings_array) > 0) {
|
||||||
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ??= [];
|
||||||
|
|
||||||
if (count($persistent_storages) > 0) {
|
if (count($persistent_storages) > 0) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_storages;
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'],
|
||||||
|
$persistent_storages
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($persistent_file_volumes) > 0) {
|
if (count($persistent_file_volumes) > 0) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) {
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
return "$item->fs_path:$item->mount_path";
|
$docker_compose['services'][$container_name]['volumes'],
|
||||||
})->toArray();
|
$persistent_file_volumes->map(function ($item) {
|
||||||
|
return "$item->fs_path:$item->mount_path";
|
||||||
|
})->toArray()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($volume_names) > 0) {
|
if (count($volume_names) > 0) {
|
||||||
$docker_compose['volumes'] = $volume_names;
|
$docker_compose['volumes'] = $volume_names;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($this->init_scripts) > 0) {
|
if (count($this->init_scripts) > 0) {
|
||||||
foreach ($this->init_scripts as $init_script) {
|
foreach ($this->init_scripts as $init_script) {
|
||||||
$docker_compose['services'][$container_name]['volumes'][] = [
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
'type' => 'bind',
|
$docker_compose['services'][$container_name]['volumes'],
|
||||||
'source' => $init_script,
|
[[
|
||||||
'target' => '/docker-entrypoint-initdb.d/'.basename($init_script),
|
'type' => 'bind',
|
||||||
'read_only' => true,
|
'source' => $init_script,
|
||||||
];
|
'target' => '/docker-entrypoint-initdb.d/'.basename($init_script),
|
||||||
|
'read_only' => true,
|
||||||
|
]]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filled($this->database->postgres_conf)) {
|
if (filled($this->database->postgres_conf)) {
|
||||||
$docker_compose['services'][$container_name]['volumes'][] = [
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
'type' => 'bind',
|
$docker_compose['services'][$container_name]['volumes'],
|
||||||
'source' => $this->configuration_dir.'/custom-postgres.conf',
|
[[
|
||||||
'target' => '/etc/postgresql/postgresql.conf',
|
'type' => 'bind',
|
||||||
'read_only' => true,
|
'source' => $this->configuration_dir.'/custom-postgres.conf',
|
||||||
];
|
'target' => '/etc/postgresql/postgresql.conf',
|
||||||
|
'read_only' => true,
|
||||||
|
]]
|
||||||
|
);
|
||||||
$docker_compose['services'][$container_name]['command'] = [
|
$docker_compose['services'][$container_name]['command'] = [
|
||||||
'postgres',
|
'postgres',
|
||||||
'-c',
|
'-c',
|
||||||
'config_file=/etc/postgresql/postgresql.conf',
|
'config_file=/etc/postgresql/postgresql.conf',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$docker_compose['services'][$container_name]['command'] = [
|
||||||
|
'postgres',
|
||||||
|
'-c',
|
||||||
|
'ssl=on',
|
||||||
|
'-c',
|
||||||
|
'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
|
||||||
|
'-c',
|
||||||
|
'ssl_key_file=/var/lib/postgresql/certs/server.key',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Add custom docker run options
|
// Add custom docker run options
|
||||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||||
@@ -132,6 +226,9 @@ class StartPostgresql
|
|||||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
|
||||||
|
}
|
||||||
$this->commands[] = "echo 'Database started.'";
|
$this->commands[] = "echo 'Database started.'";
|
||||||
|
|
||||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Actions\Database;
|
namespace App\Actions\Database;
|
||||||
|
|
||||||
|
use App\Helpers\SslHelper;
|
||||||
|
use App\Models\SslCertificate;
|
||||||
use App\Models\StandaloneRedis;
|
use App\Models\StandaloneRedis;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
@@ -17,6 +19,8 @@ class StartRedis
|
|||||||
|
|
||||||
public string $configuration_dir;
|
public string $configuration_dir;
|
||||||
|
|
||||||
|
private ?SslCertificate $ssl_certificate = null;
|
||||||
|
|
||||||
public function handle(StandaloneRedis $database)
|
public function handle(StandaloneRedis $database)
|
||||||
{
|
{
|
||||||
$this->database = $database;
|
$this->database = $database;
|
||||||
@@ -26,9 +30,62 @@ class StartRedis
|
|||||||
|
|
||||||
$this->commands = [
|
$this->commands = [
|
||||||
"echo 'Starting database.'",
|
"echo 'Starting database.'",
|
||||||
|
"echo 'Creating directories.'",
|
||||||
"mkdir -p $this->configuration_dir",
|
"mkdir -p $this->configuration_dir",
|
||||||
|
"echo 'Directories created successfully.'",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (! $this->database->enable_ssl) {
|
||||||
|
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
|
||||||
|
$this->database->sslCertificates()->delete();
|
||||||
|
$this->database->fileStorages()
|
||||||
|
->where('resource_type', $this->database->getMorphClass())
|
||||||
|
->where('resource_id', $this->database->id)
|
||||||
|
->get()
|
||||||
|
->filter(function ($storage) {
|
||||||
|
return in_array($storage->mount_path, [
|
||||||
|
'/etc/redis/certs/server.crt',
|
||||||
|
'/etc/redis/certs/server.key',
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->each(function ($storage) {
|
||||||
|
$storage->delete();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$this->commands[] = "echo 'Setting up SSL for this database.'";
|
||||||
|
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
||||||
|
|
||||||
|
$server = $this->database->destination->server;
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$server->generateCaCertificate();
|
||||||
|
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $caCert) {
|
||||||
|
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ssl_certificate = $this->database->sslCertificates()->first();
|
||||||
|
|
||||||
|
if (! $this->ssl_certificate) {
|
||||||
|
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
|
||||||
|
$this->ssl_certificate = SslHelper::generateSslCertificate(
|
||||||
|
commonName: $this->database->uuid,
|
||||||
|
resourceType: $this->database->getMorphClass(),
|
||||||
|
resourceId: $this->database->id,
|
||||||
|
serverId: $server->id,
|
||||||
|
caCert: $caCert->ssl_certificate,
|
||||||
|
caKey: $caCert->ssl_private_key,
|
||||||
|
configurationDir: $this->configuration_dir,
|
||||||
|
mountPath: '/etc/redis/certs',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||||
@@ -76,26 +133,55 @@ class StartRedis
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! is_null($this->database->limits_cpuset)) {
|
if (! is_null($this->database->limits_cpuset)) {
|
||||||
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
||||||
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($this->database->ports_mappings_array) > 0) {
|
if (count($this->database->ports_mappings_array) > 0) {
|
||||||
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ??= [];
|
||||||
|
|
||||||
if (count($persistent_storages) > 0) {
|
if (count($persistent_storages) > 0) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_storages;
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'],
|
||||||
|
$persistent_storages
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($persistent_file_volumes) > 0) {
|
if (count($persistent_file_volumes) > 0) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) {
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
return "$item->fs_path:$item->mount_path";
|
$docker_compose['services'][$container_name]['volumes'],
|
||||||
})->toArray();
|
$persistent_file_volumes->map(function ($item) {
|
||||||
|
return "$item->fs_path:$item->mount_path";
|
||||||
|
})->toArray()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($volume_names) > 0) {
|
if (count($volume_names) > 0) {
|
||||||
$docker_compose['volumes'] = $volume_names;
|
$docker_compose['volumes'] = $volume_names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'type' => 'bind',
|
||||||
|
'source' => '/data/coolify/ssl/coolify-ca.crt',
|
||||||
|
'target' => '/etc/redis/certs/coolify-ca.crt',
|
||||||
|
'read_only' => true,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) {
|
if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) {
|
||||||
$docker_compose['services'][$container_name]['volumes'][] = [
|
$docker_compose['services'][$container_name]['volumes'][] = [
|
||||||
'type' => 'bind',
|
'type' => 'bind',
|
||||||
@@ -116,6 +202,9 @@ class StartRedis
|
|||||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||||
|
}
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
$this->commands[] = "echo 'Database started.'";
|
$this->commands[] = "echo 'Database started.'";
|
||||||
|
|
||||||
@@ -202,6 +291,20 @@ class StartRedis
|
|||||||
$command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes";
|
$command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->database->enable_ssl) {
|
||||||
|
$sslArgs = [
|
||||||
|
'--tls-port 6380',
|
||||||
|
'--tls-cert-file /etc/redis/certs/server.crt',
|
||||||
|
'--tls-key-file /etc/redis/certs/server.key',
|
||||||
|
'--tls-ca-cert-file /etc/redis/certs/coolify-ca.crt',
|
||||||
|
'--tls-auth-clients optional',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($sslArgs)) {
|
||||||
|
$command .= ' '.implode(' ', $sslArgs);
|
||||||
|
}
|
||||||
|
|
||||||
return $command;
|
return $command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -11,7 +12,6 @@ use App\Models\StandaloneMongodb;
|
|||||||
use App\Models\StandaloneMysql;
|
use App\Models\StandaloneMysql;
|
||||||
use App\Models\StandalonePostgresql;
|
use App\Models\StandalonePostgresql;
|
||||||
use App\Models\StandaloneRedis;
|
use App\Models\StandaloneRedis;
|
||||||
use Illuminate\Support\Facades\Process;
|
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
|
||||||
class StopDatabase
|
class StopDatabase
|
||||||
@@ -20,56 +20,37 @@ 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, 300);
|
|
||||||
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 = 300): void
|
private function stopContainer($database, string $containerName, int $timeout = 30): void
|
||||||
{
|
{
|
||||||
$server = $database->destination->server;
|
$server = $database->destination->server;
|
||||||
|
instant_remote_process(command: [
|
||||||
$process = Process::timeout($timeout)->start("docker stop --time=$timeout $containerName");
|
"docker stop --time=$timeout $containerName",
|
||||||
|
"docker rm -f $containerName",
|
||||||
$startTime = time();
|
], server: $server, throwError: false);
|
||||||
while ($process->running()) {
|
|
||||||
if (time() - $startTime >= $timeout) {
|
|
||||||
$this->forceStopContainer($containerName, $server);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
usleep(100000);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->removeContainer($containerName, $server);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function forceStopContainer(string $containerName, $server): void
|
|
||||||
{
|
|
||||||
instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function removeContainer(string $containerName, $server): void
|
|
||||||
{
|
|
||||||
instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function deleteConnectedNetworks($uuid, $server)
|
|
||||||
{
|
|
||||||
instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false);
|
|
||||||
instant_remote_process(["docker network rm {$uuid}"], $server, false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ class StopDatabaseProxy
|
|||||||
}
|
}
|
||||||
instant_remote_process(["docker rm -f {$uuid}-proxy"], $server);
|
instant_remote_process(["docker rm -f {$uuid}-proxy"], $server);
|
||||||
|
|
||||||
$database->is_public = false;
|
|
||||||
$database->save();
|
$database->save();
|
||||||
|
|
||||||
DatabaseProxyStopped::dispatch();
|
DatabaseProxyStopped::dispatch();
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -208,7 +209,6 @@ class GetContainersStatus
|
|||||||
$foundServices[] = "$service->id-$service->name";
|
$foundServices[] = "$service->id-$service->name";
|
||||||
$statusFromDb = $service->status;
|
$statusFromDb = $service->status;
|
||||||
if ($statusFromDb !== $containerStatus) {
|
if ($statusFromDb !== $containerStatus) {
|
||||||
// ray('Updating status: ' . $containerStatus);
|
|
||||||
$service->update(['status' => $containerStatus]);
|
$service->update(['status' => $containerStatus]);
|
||||||
} else {
|
} else {
|
||||||
$service->update(['last_online_at' => now()]);
|
$service->update(['last_online_at' => now()]);
|
||||||
@@ -274,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) {
|
||||||
@@ -299,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) {
|
||||||
@@ -342,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ class ResetUserPassword implements ResetsUserPasswords
|
|||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'password' => Hash::make($input['password']),
|
'password' => Hash::make($input['password']),
|
||||||
])->save();
|
])->save();
|
||||||
|
$user->deleteAllSessions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -27,13 +28,9 @@ class CheckProxy
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$proxyType = $server->proxyType();
|
$proxyType = $server->proxyType();
|
||||||
if (is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop) {
|
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop) && ! $fromUI) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
['uptime' => $uptime, 'error' => $error] = $server->validateConnection();
|
|
||||||
if (! $uptime) {
|
|
||||||
throw new \Exception($error);
|
|
||||||
}
|
|
||||||
if (! $server->isProxyShouldRun()) {
|
if (! $server->isProxyShouldRun()) {
|
||||||
if ($fromUI) {
|
if ($fromUI) {
|
||||||
throw new \Exception('Proxy should not run. You selected the Custom Proxy.');
|
throw new \Exception('Proxy should not run. You selected the Custom Proxy.');
|
||||||
@@ -41,8 +38,12 @@ class CheckProxy
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine proxy container name based on environment
|
||||||
|
$proxyContainerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
|
||||||
|
|
||||||
if ($server->isSwarm()) {
|
if ($server->isSwarm()) {
|
||||||
$status = getContainerStatus($server, 'coolify-proxy_traefik');
|
$status = getContainerStatus($server, $proxyContainerName);
|
||||||
$server->proxy->set('status', $status);
|
$server->proxy->set('status', $status);
|
||||||
$server->save();
|
$server->save();
|
||||||
if ($status === 'running') {
|
if ($status === 'running') {
|
||||||
@@ -51,7 +52,7 @@ class CheckProxy
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
$status = getContainerStatus($server, 'coolify-proxy');
|
$status = getContainerStatus($server, $proxyContainerName);
|
||||||
if ($status === 'running') {
|
if ($status === 'running') {
|
||||||
$server->proxy->set('status', 'running');
|
$server->proxy->set('status', 'running');
|
||||||
$server->save();
|
$server->save();
|
||||||
@@ -65,7 +66,6 @@ class CheckProxy
|
|||||||
if ($server->id === 0) {
|
if ($server->id === 0) {
|
||||||
$ip = 'host.docker.internal';
|
$ip = 'host.docker.internal';
|
||||||
}
|
}
|
||||||
|
|
||||||
$portsToCheck = ['80', '443'];
|
$portsToCheck = ['80', '443'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -73,7 +73,7 @@ class CheckProxy
|
|||||||
$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) {
|
||||||
@@ -81,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 = [];
|
||||||
@@ -94,11 +96,13 @@ class CheckProxy
|
|||||||
if (count($portsToCheck) === 0) {
|
if (count($portsToCheck) === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
foreach ($portsToCheck as $port) {
|
$portsToCheck = array_values(array_unique($portsToCheck));
|
||||||
$connection = @fsockopen($ip, $port);
|
// Check port conflicts in parallel
|
||||||
if (is_resource($connection) && fclose($connection)) {
|
$conflicts = $this->checkPortConflictsInParallel($server, $portsToCheck, $proxyContainerName);
|
||||||
|
foreach ($conflicts as $port => $conflict) {
|
||||||
|
if ($conflict) {
|
||||||
if ($fromUI) {
|
if ($fromUI) {
|
||||||
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br>Docs: <a target='_blank' href='https://coolify.io/docs'>https://coolify.io/docs</a><br>Discord: <a target='_blank' href='https://coollabs.io/discord'>https://coollabs.io/discord</a>");
|
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 {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -108,4 +112,306 @@ class CheckProxy
|
|||||||
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
|
||||||
|
* Returns true only if there's a real port conflict (not just dual-stack)
|
||||||
|
*/
|
||||||
|
private function isPortConflict(Server $server, string $port, string $proxyContainerName): bool
|
||||||
|
{
|
||||||
|
// First check if our own proxy is using this port (which is fine)
|
||||||
|
try {
|
||||||
|
$getProxyContainerId = "docker ps -a --filter name=$proxyContainerName --format '{{.ID}}'";
|
||||||
|
$containerId = trim(instant_remote_process([$getProxyContainerId], $server));
|
||||||
|
|
||||||
|
if (! empty($containerId)) {
|
||||||
|
$checkProxyPort = "docker inspect $containerId --format '{{json .NetworkSettings.Ports}}' | grep '\"$port/tcp\"'";
|
||||||
|
try {
|
||||||
|
instant_remote_process([$checkProxyPort], $server);
|
||||||
|
|
||||||
|
// Our proxy is using the port, which is fine
|
||||||
|
return false;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Our container exists but not using this port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Container not found or error checking, continue with regular checks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command sets for different ways to check ports, ordered by preference
|
||||||
|
$commandSets = [
|
||||||
|
// Set 1: Use ss to check listener counts by protocol stack
|
||||||
|
[
|
||||||
|
'available' => 'command -v ss >/dev/null 2>&1',
|
||||||
|
'check' => [
|
||||||
|
// Get listening process details
|
||||||
|
"ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null) && echo \"\$ss_output\"",
|
||||||
|
// Count IPv4 listeners
|
||||||
|
"echo \"\$ss_output\" | grep -c ':$port '",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Set 2: Use netstat as alternative to ss
|
||||||
|
[
|
||||||
|
'available' => 'command -v netstat >/dev/null 2>&1',
|
||||||
|
'check' => [
|
||||||
|
// Get listening process details
|
||||||
|
"netstat_output=\$(netstat -tuln 2>/dev/null) && echo \"\$netstat_output\" | grep ':$port '",
|
||||||
|
// Count listeners
|
||||||
|
"echo \"\$netstat_output\" | grep ':$port ' | grep -c 'LISTEN'",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Set 3: Use lsof as last resort
|
||||||
|
[
|
||||||
|
'available' => 'command -v lsof >/dev/null 2>&1',
|
||||||
|
'check' => [
|
||||||
|
// Get process using the port
|
||||||
|
"lsof -i :$port -P -n | grep 'LISTEN'",
|
||||||
|
// Count listeners
|
||||||
|
"lsof -i :$port -P -n | grep 'LISTEN' | wc -l",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Try each command set until we find one available
|
||||||
|
foreach ($commandSets as $set) {
|
||||||
|
try {
|
||||||
|
// Check if the command is available
|
||||||
|
instant_remote_process([$set['available']], $server);
|
||||||
|
|
||||||
|
// Run the actual check commands
|
||||||
|
$output = instant_remote_process($set['check'], $server, true);
|
||||||
|
// Parse the output lines
|
||||||
|
$lines = explode("\n", trim($output));
|
||||||
|
// Get the detailed output and listener count
|
||||||
|
$details = trim(implode("\n", array_slice($lines, 0, -1)));
|
||||||
|
$count = intval(trim($lines[count($lines) - 1] ?? '0'));
|
||||||
|
// If no listeners or empty result, port is free
|
||||||
|
if ($count == 0 || empty($details)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to detect if this is our coolify-proxy
|
||||||
|
if (strpos($details, 'docker') !== false || strpos($details, $proxyContainerName) !== false) {
|
||||||
|
// It's likely our docker or proxy, which is fine
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dual-stack scenario - typically 1-2 listeners (IPv4+IPv6)
|
||||||
|
// If exactly 2 listeners and both have same port, likely dual-stack
|
||||||
|
if ($count <= 2) {
|
||||||
|
// Check if it looks like a standard dual-stack setup
|
||||||
|
$isDualStack = false;
|
||||||
|
|
||||||
|
// Look for IPv4 and IPv6 in the listing (ss output format)
|
||||||
|
if (preg_match('/LISTEN.*:'.$port.'\s/', $details) &&
|
||||||
|
(preg_match('/\*:'.$port.'\s/', $details) ||
|
||||||
|
preg_match('/:::'.$port.'\s/', $details))) {
|
||||||
|
$isDualStack = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For netstat format
|
||||||
|
if (strpos($details, '0.0.0.0:'.$port) !== false &&
|
||||||
|
strpos($details, ':::'.$port) !== false) {
|
||||||
|
$isDualStack = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For lsof format (IPv4 and IPv6)
|
||||||
|
if (strpos($details, '*:'.$port) !== false &&
|
||||||
|
preg_match('/\*:'.$port.'.*IPv4/', $details) &&
|
||||||
|
preg_match('/\*:'.$port.'.*IPv6/', $details)) {
|
||||||
|
$isDualStack = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isDualStack) {
|
||||||
|
return false; // This is just a normal dual-stack setup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, it's likely a real port conflict
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// This command set failed, try the next one
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to simpler check if all above methods fail
|
||||||
|
try {
|
||||||
|
// Just try to bind to the port directly to see if it's available
|
||||||
|
$checkCommand = "nc -z -w1 127.0.0.1 $port >/dev/null 2>&1 && echo 'in-use' || echo 'free'";
|
||||||
|
$result = instant_remote_process([$checkCommand], $server, true);
|
||||||
|
|
||||||
|
return trim($result) === 'in-use';
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// If everything fails, assume the port is free to avoid false positives
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 --remove-orphans',
|
'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';
|
||||||
}
|
}
|
||||||
|
|||||||
38
app/Actions/Proxy/StopProxy.php
Normal file
38
app/Actions/Proxy/StopProxy.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Proxy;
|
||||||
|
|
||||||
|
use App\Events\ProxyStatusChanged;
|
||||||
|
use App\Events\ProxyStatusChangedUI;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Services\ProxyDashboardCacheService;
|
||||||
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
|
||||||
|
class StopProxy
|
||||||
|
{
|
||||||
|
use AsAction;
|
||||||
|
|
||||||
|
public function handle(Server $server, bool $forceStop = true, int $timeout = 30)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$containerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
|
||||||
|
$server->proxy->status = 'stopping';
|
||||||
|
$server->save();
|
||||||
|
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||||
|
|
||||||
|
instant_remote_process(command: [
|
||||||
|
"docker stop --time=$timeout $containerName",
|
||||||
|
"docker rm -f $containerName",
|
||||||
|
], server: $server, throwError: false);
|
||||||
|
|
||||||
|
$server->proxy->force_stop = $forceStop;
|
||||||
|
$server->proxy->status = 'exited';
|
||||||
|
$server->save();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e);
|
||||||
|
} finally {
|
||||||
|
ProxyDashboardCacheService::clearCache($server);
|
||||||
|
ProxyStatusChanged::dispatch($server->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
223
app/Actions/Server/CheckUpdates.php
Normal file
223
app/Actions/Server/CheckUpdates.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,15 +14,26 @@ class CleanupDocker
|
|||||||
public function handle(Server $server)
|
public function handle(Server $server)
|
||||||
{
|
{
|
||||||
$settings = instanceSettings();
|
$settings = instanceSettings();
|
||||||
|
$realtimeImage = config('constants.coolify.realtime_image');
|
||||||
|
$realtimeImageVersion = config('constants.coolify.realtime_version');
|
||||||
|
$realtimeImageWithVersion = "$realtimeImage:$realtimeImageVersion";
|
||||||
|
$realtimeImageWithoutPrefix = 'coollabsio/coolify-realtime';
|
||||||
|
$realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion";
|
||||||
|
|
||||||
$helperImageVersion = data_get($settings, 'helper_version');
|
$helperImageVersion = data_get($settings, 'helper_version');
|
||||||
$helperImage = config('constants.coolify.helper_image');
|
$helperImage = config('constants.coolify.helper_image');
|
||||||
$helperImageWithVersion = "$helperImage:$helperImageVersion";
|
$helperImageWithVersion = "$helperImage:$helperImageVersion";
|
||||||
|
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
|
||||||
|
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
|
||||||
|
|
||||||
$commands = [
|
$commands = [
|
||||||
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
|
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
|
||||||
'docker image prune -af --filter "label!=coolify.managed=true"',
|
'docker image prune -af --filter "label!=coolify.managed=true"',
|
||||||
'docker builder prune -af',
|
'docker builder prune -af',
|
||||||
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
|
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||||
|
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||||
|
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
|
||||||
|
"docker images --filter before=$realtimeImageWithoutPrefixVersion --filter reference=$realtimeImageWithoutPrefix | grep $realtimeImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($server->settings->delete_unused_volumes) {
|
if ($server->settings->delete_unused_volumes) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Actions\Server;
|
namespace App\Actions\Server;
|
||||||
|
|
||||||
|
use App\Helpers\SslHelper;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Models\SslCertificate;
|
||||||
use App\Models\StandaloneDocker;
|
use App\Models\StandaloneDocker;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
|
||||||
@@ -17,6 +19,27 @@ class InstallDocker
|
|||||||
if (! $supported_os_type) {
|
if (! $supported_os_type) {
|
||||||
throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://coolify.io/docs/installation#manually">documentation</a>.');
|
throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://coolify.io/docs/installation#manually">documentation</a>.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->exists()) {
|
||||||
|
$serverCert = SslHelper::generateSslCertificate(
|
||||||
|
commonName: 'Coolify CA Certificate',
|
||||||
|
serverId: $server->id,
|
||||||
|
isCaCertificate: true,
|
||||||
|
validityDays: 10 * 365
|
||||||
|
);
|
||||||
|
$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 '{$serverCert->ssl_certificate}' > $caCertPath/coolify-ca.crt",
|
||||||
|
"chmod 644 $caCertPath/coolify-ca.crt",
|
||||||
|
]);
|
||||||
|
remote_process($commands, $server);
|
||||||
|
}
|
||||||
|
|
||||||
$config = base64_encode('{
|
$config = base64_encode('{
|
||||||
"log-driver": "json-file",
|
"log-driver": "json-file",
|
||||||
"log-opts": {
|
"log-opts": {
|
||||||
|
|||||||
@@ -99,11 +99,12 @@ class ServerCheck
|
|||||||
return data_get($value, 'Name') === '/coolify-proxy';
|
return data_get($value, 'Name') === '/coolify-proxy';
|
||||||
}
|
}
|
||||||
})->first();
|
})->first();
|
||||||
if (! $foundProxyContainer) {
|
$proxyStatus = data_get($foundProxyContainer, 'State.Status', 'exited');
|
||||||
|
if (! $foundProxyContainer || $proxyStatus !== 'running') {
|
||||||
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) {
|
||||||
|
|||||||
@@ -15,19 +15,18 @@ class StartLogDrain
|
|||||||
{
|
{
|
||||||
if ($server->settings->is_logdrain_newrelic_enabled) {
|
if ($server->settings->is_logdrain_newrelic_enabled) {
|
||||||
$type = 'newrelic';
|
$type = 'newrelic';
|
||||||
StopLogDrain::run($server);
|
|
||||||
} elseif ($server->settings->is_logdrain_highlight_enabled) {
|
} elseif ($server->settings->is_logdrain_highlight_enabled) {
|
||||||
$type = 'highlight';
|
$type = 'highlight';
|
||||||
StopLogDrain::run($server);
|
|
||||||
} elseif ($server->settings->is_logdrain_axiom_enabled) {
|
} elseif ($server->settings->is_logdrain_axiom_enabled) {
|
||||||
$type = 'axiom';
|
$type = 'axiom';
|
||||||
StopLogDrain::run($server);
|
|
||||||
} elseif ($server->settings->is_logdrain_custom_enabled) {
|
} elseif ($server->settings->is_logdrain_custom_enabled) {
|
||||||
$type = 'custom';
|
$type = 'custom';
|
||||||
StopLogDrain::run($server);
|
|
||||||
} else {
|
} else {
|
||||||
$type = 'none';
|
$type = 'none';
|
||||||
}
|
}
|
||||||
|
if ($type !== 'none') {
|
||||||
|
StopLogDrain::run($server);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if ($type === 'none') {
|
if ($type === 'none') {
|
||||||
return 'No log drain is enabled.';
|
return 'No log drain is enabled.';
|
||||||
@@ -186,7 +185,6 @@ Files:
|
|||||||
"echo '{$compose}' | base64 -d | tee $compose_path > /dev/null",
|
"echo '{$compose}' | base64 -d | tee $compose_path > /dev/null",
|
||||||
"echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
|
"echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
|
||||||
"test -f $config_path/.env && rm $config_path/.env",
|
"test -f $config_path/.env && rm $config_path/.env",
|
||||||
|
|
||||||
];
|
];
|
||||||
if ($type === 'newrelic') {
|
if ($type === 'newrelic') {
|
||||||
$add_envs_command = [
|
$add_envs_command = [
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ class StartSentinel
|
|||||||
$endpoint = data_get($server, 'settings.sentinel_custom_url');
|
$endpoint = data_get($server, 'settings.sentinel_custom_url');
|
||||||
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
|
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
|
||||||
$mountDir = '/data/coolify/sentinel';
|
$mountDir = '/data/coolify/sentinel';
|
||||||
$image = "ghcr.io/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,
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ class UpdateCoolify
|
|||||||
{
|
{
|
||||||
PullHelperImageJob::dispatch($this->server);
|
PullHelperImageJob::dispatch($this->server);
|
||||||
|
|
||||||
instant_remote_process(["docker pull -q ghcr.io/coollabsio/coolify:{$this->latestVersion}"], $this->server, false);
|
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
|
||||||
|
instant_remote_process(["docker pull -q $image"], $this->server, false);
|
||||||
|
|
||||||
remote_process([
|
remote_process([
|
||||||
'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh',
|
'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh',
|
||||||
|
|||||||
52
app/Actions/Server/UpdatePackage.php
Normal file
52
app/Actions/Server/UpdatePackage.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Server;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
use Spatie\Activitylog\Contracts\Activity;
|
||||||
|
|
||||||
|
class UpdatePackage
|
||||||
|
{
|
||||||
|
use AsAction;
|
||||||
|
|
||||||
|
public string $jobQueue = 'high';
|
||||||
|
|
||||||
|
public function handle(Server $server, string $osId, ?string $package = null, ?string $packageManager = null, bool $all = false): Activity|array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($server->serverStatus() === false) {
|
||||||
|
return [
|
||||||
|
'error' => 'Server is not reachable or not ready.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
switch ($packageManager) {
|
||||||
|
case 'zypper':
|
||||||
|
$commandAll = 'zypper update -y';
|
||||||
|
$commandInstall = 'zypper install -y '.$package;
|
||||||
|
break;
|
||||||
|
case 'dnf':
|
||||||
|
$commandAll = 'dnf update -y';
|
||||||
|
$commandInstall = 'dnf update -y '.$package;
|
||||||
|
break;
|
||||||
|
case 'apt':
|
||||||
|
$commandAll = 'apt update && apt upgrade -y';
|
||||||
|
$commandInstall = 'apt install -y '.$package;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return [
|
||||||
|
'error' => 'OS not supported',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($all) {
|
||||||
|
return remote_process([$commandAll], $server);
|
||||||
|
}
|
||||||
|
|
||||||
|
return remote_process([$commandInstall], $server);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,15 +48,15 @@ class DeleteService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($deleteConnectedNetworks) {
|
if ($deleteConnectedNetworks) {
|
||||||
$service->delete_connected_networks($service->uuid);
|
$service->deleteConnectedNetworks();
|
||||||
}
|
}
|
||||||
|
|
||||||
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->delete_configurations();
|
$service->deleteConfigurations();
|
||||||
}
|
}
|
||||||
foreach ($service->applications()->get() as $application) {
|
foreach ($service->applications()->get() as $application) {
|
||||||
$application->forceDelete();
|
$application->forceDelete();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,19 +12,25 @@ class StartService
|
|||||||
|
|
||||||
public string $jobQueue = 'high';
|
public string $jobQueue = 'high';
|
||||||
|
|
||||||
public function handle(Service $service)
|
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
|
||||||
{
|
{
|
||||||
|
$service->parse();
|
||||||
|
if ($stopBeforeStart) {
|
||||||
|
StopService::run(service: $service, dockerCleanup: false);
|
||||||
|
}
|
||||||
$service->saveComposeConfigs();
|
$service->saveComposeConfigs();
|
||||||
|
$service->isConfigurationChanged(save: true);
|
||||||
$commands[] = 'cd '.$service->workdir();
|
$commands[] = 'cd '.$service->workdir();
|
||||||
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
|
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
|
||||||
|
if ($pullLatestImages) {
|
||||||
|
$commands[] = "echo 'Pulling images.'";
|
||||||
|
$commands[] = 'docker compose pull';
|
||||||
|
}
|
||||||
if ($service->networks()->count() > 0) {
|
if ($service->networks()->count() > 0) {
|
||||||
$commands[] = "echo 'Creating Docker network.'";
|
$commands[] = "echo 'Creating Docker network.'";
|
||||||
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
|
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
|
||||||
}
|
}
|
||||||
$commands[] = 'echo Starting service.';
|
$commands[] = 'echo Starting service.';
|
||||||
$commands[] = "echo 'Pulling images.'";
|
|
||||||
$commands[] = 'docker compose pull';
|
|
||||||
$commands[] = "echo 'Starting containers.'";
|
|
||||||
$commands[] = 'docker compose up -d --remove-orphans --force-recreate --build';
|
$commands[] = 'docker compose up -d --remove-orphans --force-recreate --build';
|
||||||
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
|
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
|
||||||
if (data_get($service, 'connect_to_docker_network')) {
|
if (data_get($service, 'connect_to_docker_network')) {
|
||||||
|
|||||||
@@ -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 (! $isDeleteOperation) {
|
if (! empty($containersToStop)) {
|
||||||
$service->delete_connected_networks($service->uuid);
|
$this->stopContainersInParallel($containersToStop, $server);
|
||||||
if ($dockerCleanup) {
|
}
|
||||||
CleanupDocker::dispatch($server, true);
|
|
||||||
}
|
if ($isDeleteOperation) {
|
||||||
|
$service->deleteConnectedNetworks();
|
||||||
|
}
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Actions\Shared;
|
|
||||||
|
|
||||||
use App\Models\Service;
|
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
|
||||||
|
|
||||||
class PullImage
|
|
||||||
{
|
|
||||||
use AsAction;
|
|
||||||
|
|
||||||
public function handle(Service $resource)
|
|
||||||
{
|
|
||||||
$resource->saveComposeConfigs();
|
|
||||||
|
|
||||||
$commands[] = 'cd '.$resource->workdir();
|
|
||||||
$commands[] = "echo 'Saved configuration files to {$resource->workdir()}.'";
|
|
||||||
$commands[] = 'docker compose pull';
|
|
||||||
|
|
||||||
$server = data_get($resource, 'server');
|
|
||||||
|
|
||||||
if (! $server) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
instant_remote_process($commands, $resource->server);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,17 +13,20 @@ class CleanupRedis extends Command
|
|||||||
|
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
$prefix = config('database.redis.options.prefix');
|
$redis = Redis::connection('horizon');
|
||||||
|
$keys = $redis->keys('*');
|
||||||
$keys = Redis::connection()->keys('*:laravel*');
|
$prefix = config('horizon.prefix');
|
||||||
collect($keys)->each(function ($key) use ($prefix) {
|
foreach ($keys as $key) {
|
||||||
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
||||||
Redis::connection()->del($keyWithoutPrefix);
|
$type = $redis->command('type', [$keyWithoutPrefix]);
|
||||||
});
|
|
||||||
|
|
||||||
$queueOverlaps = Redis::connection()->keys('*laravel-queue-overlap*');
|
if ($type === 5) {
|
||||||
collect($queueOverlaps)->each(function ($key) {
|
$data = $redis->command('hgetall', [$keyWithoutPrefix]);
|
||||||
Redis::connection()->del($key);
|
$status = data_get($data, 'status');
|
||||||
});
|
if ($status === 'completed') {
|
||||||
|
$redis->command('del', [$keyWithoutPrefix]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Models\StandaloneMongodb;
|
|||||||
use App\Models\StandaloneMysql;
|
use App\Models\StandaloneMysql;
|
||||||
use App\Models\StandalonePostgresql;
|
use App\Models\StandalonePostgresql;
|
||||||
use App\Models\StandaloneRedis;
|
use App\Models\StandaloneRedis;
|
||||||
|
use App\Models\Team;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class CleanupStuckedResources extends Command
|
class CleanupStuckedResources extends Command
|
||||||
@@ -36,6 +37,12 @@ class CleanupStuckedResources extends Command
|
|||||||
private function cleanup_stucked_resources()
|
private function cleanup_stucked_resources()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$teams = Team::all()->filter(function ($team) {
|
||||||
|
return $team->members()->count() === 0 && $team->servers()->count() === 0;
|
||||||
|
});
|
||||||
|
foreach ($teams as $team) {
|
||||||
|
$team->delete();
|
||||||
|
}
|
||||||
$servers = Server::all()->filter(function ($server) {
|
$servers = Server::all()->filter(function ($server) {
|
||||||
return $server->isFunctional();
|
return $server->isFunctional();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class CloudCleanupSubscriptions extends Command
|
|||||||
} else {
|
} else {
|
||||||
$subscription = $stripe->subscriptions->retrieve(data_get($team, 'subscription.stripe_subscription_id'), []);
|
$subscription = $stripe->subscriptions->retrieve(data_get($team, 'subscription.stripe_subscription_id'), []);
|
||||||
$status = data_get($subscription, 'status');
|
$status = data_get($subscription, 'status');
|
||||||
if ($status === 'active' || $status === 'past_due') {
|
if ($status === 'active') {
|
||||||
$team->subscription->update([
|
$team->subscription->update([
|
||||||
'stripe_invoice_paid' => true,
|
'stripe_invoice_paid' => true,
|
||||||
'stripe_trial_already_ended' => false,
|
'stripe_trial_already_ended' => false,
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ namespace App\Console\Commands;
|
|||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Process;
|
|
||||||
use Symfony\Component\Yaml\Yaml;
|
|
||||||
|
|
||||||
class Dev extends Command
|
class Dev extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'dev {--init} {--generate-openapi}';
|
protected $signature = 'dev {--init}';
|
||||||
|
|
||||||
protected $description = 'Helper commands for development.';
|
protected $description = 'Helper commands for development.';
|
||||||
|
|
||||||
@@ -21,36 +19,6 @@ class Dev extends Command
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($this->option('generate-openapi')) {
|
|
||||||
$this->generateOpenApi();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function generateOpenApi()
|
|
||||||
{
|
|
||||||
// Generate OpenAPI documentation
|
|
||||||
echo "Generating OpenAPI documentation.\n";
|
|
||||||
// https://github.com/OAI/OpenAPI-Specification/releases
|
|
||||||
$process = Process::run([
|
|
||||||
'/var/www/html/vendor/bin/openapi',
|
|
||||||
'app',
|
|
||||||
'-o',
|
|
||||||
'openapi.yaml',
|
|
||||||
'--version',
|
|
||||||
'3.1.0',
|
|
||||||
]);
|
|
||||||
$error = $process->errorOutput();
|
|
||||||
$error = preg_replace('/^.*an object literal,.*$/m', '', $error);
|
|
||||||
$error = preg_replace('/^\h*\v+/m', '', $error);
|
|
||||||
echo $error;
|
|
||||||
echo $process->output();
|
|
||||||
// Convert YAML to JSON
|
|
||||||
$yaml = file_get_contents('openapi.yaml');
|
|
||||||
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT);
|
|
||||||
file_put_contents('openapi.json', $json);
|
|
||||||
echo "Converted OpenAPI YAML to JSON.\n";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function init()
|
public function init()
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands\Generate;
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Process;
|
use Illuminate\Support\Facades\Process;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class OpenApi extends Command
|
class OpenApi extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'openapi';
|
protected $signature = 'generate:openapi';
|
||||||
|
|
||||||
protected $description = 'Generate OpenApi file.';
|
protected $description = 'Generate OpenApi file.';
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ class OpenApi extends Command
|
|||||||
echo "Generating OpenAPI documentation.\n";
|
echo "Generating OpenAPI documentation.\n";
|
||||||
// https://github.com/OAI/OpenAPI-Specification/releases
|
// https://github.com/OAI/OpenAPI-Specification/releases
|
||||||
$process = Process::run([
|
$process = Process::run([
|
||||||
'/var/www/html/vendor/bin/openapi',
|
'./vendor/bin/openapi',
|
||||||
'app',
|
'app',
|
||||||
'-o',
|
'-o',
|
||||||
'openapi.yaml',
|
'openapi.yaml',
|
||||||
@@ -29,5 +30,10 @@ class OpenApi extends Command
|
|||||||
$error = preg_replace('/^\h*\v+/m', '', $error);
|
$error = preg_replace('/^\h*\v+/m', '', $error);
|
||||||
echo $error;
|
echo $error;
|
||||||
echo $process->output();
|
echo $process->output();
|
||||||
|
|
||||||
|
$yaml = file_get_contents('openapi.yaml');
|
||||||
|
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT);
|
||||||
|
file_put_contents('openapi.json', $json);
|
||||||
|
echo "Converted OpenAPI YAML to JSON.\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands\Generate;
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class ServicesGenerate extends Command
|
class Services extends Command
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
protected $signature = 'services:generate';
|
protected $signature = 'generate:services';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
@@ -39,7 +39,13 @@ class RootResetPassword extends Command
|
|||||||
}
|
}
|
||||||
$this->info('Updating root password...');
|
$this->info('Updating root password...');
|
||||||
try {
|
try {
|
||||||
User::find(0)->update(['password' => Hash::make($password)]);
|
$user = User::find(0);
|
||||||
|
if (! $user) {
|
||||||
|
$this->error('Root user not found.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$user->update(['password' => Hash::make($password)]);
|
||||||
$this->info('Root password updated successfully.');
|
$this->info('Root password updated successfully.');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->error('Failed to update root password.');
|
$this->error('Failed to update root password.');
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ use App\Jobs\CheckAndStartSentinelJob;
|
|||||||
use App\Jobs\CheckForUpdatesJob;
|
use App\Jobs\CheckForUpdatesJob;
|
||||||
use App\Jobs\CheckHelperImageJob;
|
use App\Jobs\CheckHelperImageJob;
|
||||||
use App\Jobs\CleanupInstanceStuffsJob;
|
use App\Jobs\CleanupInstanceStuffsJob;
|
||||||
use App\Jobs\CleanupStaleMultiplexedConnections;
|
|
||||||
use App\Jobs\DatabaseBackupJob;
|
use App\Jobs\DatabaseBackupJob;
|
||||||
use App\Jobs\DockerCleanupJob;
|
use App\Jobs\DockerCleanupJob;
|
||||||
use App\Jobs\PullTemplatesFromCDN;
|
use App\Jobs\PullTemplatesFromCDN;
|
||||||
|
use App\Jobs\RegenerateSslCertJob;
|
||||||
use App\Jobs\ScheduledTaskJob;
|
use App\Jobs\ScheduledTaskJob;
|
||||||
use App\Jobs\ServerCheckJob;
|
use App\Jobs\ServerCheckJob;
|
||||||
use App\Jobs\ServerCleanupMux;
|
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;
|
||||||
@@ -23,6 +23,7 @@ use App\Models\Team;
|
|||||||
use Illuminate\Console\Scheduling\Schedule;
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class Kernel extends ConsoleKernel
|
class Kernel extends ConsoleKernel
|
||||||
{
|
{
|
||||||
@@ -51,6 +52,7 @@ class Kernel extends ConsoleKernel
|
|||||||
}
|
}
|
||||||
|
|
||||||
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
|
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
|
||||||
|
$this->scheduleInstance->command('cleanup:redis')->hourly();
|
||||||
|
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
// Instance Jobs
|
// Instance Jobs
|
||||||
@@ -84,6 +86,8 @@ class Kernel extends ConsoleKernel
|
|||||||
$this->checkScheduledBackups();
|
$this->checkScheduledBackups();
|
||||||
$this->checkScheduledTasks();
|
$this->checkScheduledTasks();
|
||||||
|
|
||||||
|
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
|
||||||
|
|
||||||
$this->scheduleInstance->command('cleanup:database --yes')->daily();
|
$this->scheduleInstance->command('cleanup:database --yes')->daily();
|
||||||
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
||||||
}
|
}
|
||||||
@@ -99,10 +103,14 @@ class Kernel extends ConsoleKernel
|
|||||||
$servers = $this->allServers->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get();
|
$servers = $this->allServers->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get();
|
||||||
}
|
}
|
||||||
foreach ($servers as $server) {
|
foreach ($servers as $server) {
|
||||||
if ($server->isSentinelEnabled()) {
|
try {
|
||||||
$this->scheduleInstance->job(function () use ($server) {
|
if ($server->isSentinelEnabled()) {
|
||||||
CheckAndStartSentinelJob::dispatch($server);
|
$this->scheduleInstance->job(function () use ($server) {
|
||||||
})->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
|
CheckAndStartSentinelJob::dispatch($server);
|
||||||
|
})->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error pulling images: '.$e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->scheduleInstance->job(new CheckHelperImageJob)
|
$this->scheduleInstance->job(new CheckHelperImageJob)
|
||||||
@@ -138,35 +146,50 @@ class Kernel extends ConsoleKernel
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($servers as $server) {
|
foreach ($servers as $server) {
|
||||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
try {
|
||||||
if (validate_timezone($serverTimezone) === false) {
|
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||||
$serverTimezone = config('app.timezone');
|
if (validate_timezone($serverTimezone) === false) {
|
||||||
}
|
$serverTimezone = config('app.timezone');
|
||||||
|
|
||||||
// Sentinel check
|
|
||||||
$lastSentinelUpdate = $server->sentinel_updated_at;
|
|
||||||
if (Carbon::parse($lastSentinelUpdate)->isBefore(now()->subSeconds($server->waitBeforeDoingSshCheck()))) {
|
|
||||||
// Check container status every minute if Sentinel does not activated
|
|
||||||
if (isCloud()) {
|
|
||||||
$this->scheduleInstance->job(new ServerCheckJob($server))->timezone($serverTimezone)->everyFiveMinutes()->onOneServer();
|
|
||||||
} else {
|
|
||||||
$this->scheduleInstance->job(new ServerCheckJob($server))->timezone($serverTimezone)->everyMinute()->onOneServer();
|
|
||||||
}
|
}
|
||||||
// $this->scheduleInstance->job(new \App\Jobs\ServerCheckNewJob($server))->everyFiveMinutes()->onOneServer();
|
|
||||||
|
|
||||||
$this->scheduleInstance->job(new ServerStorageCheckJob($server))->cron($server->settings->server_disk_usage_check_frequency)->timezone($serverTimezone)->onOneServer();
|
// Sentinel check
|
||||||
}
|
$lastSentinelUpdate = $server->sentinel_updated_at;
|
||||||
|
if (Carbon::parse($lastSentinelUpdate)->isBefore(now()->subSeconds($server->waitBeforeDoingSshCheck()))) {
|
||||||
|
// Check container status every minute if Sentinel does not activated
|
||||||
|
if (isCloud()) {
|
||||||
|
$this->scheduleInstance->job(new ServerCheckJob($server))->timezone($serverTimezone)->everyFiveMinutes()->onOneServer();
|
||||||
|
} else {
|
||||||
|
$this->scheduleInstance->job(new ServerCheckJob($server))->timezone($serverTimezone)->everyMinute()->onOneServer();
|
||||||
|
}
|
||||||
|
// $this->scheduleInstance->job(new \App\Jobs\ServerCheckNewJob($server))->everyFiveMinutes()->onOneServer();
|
||||||
|
|
||||||
$this->scheduleInstance->job(new DockerCleanupJob($server))->cron($server->settings->docker_cleanup_frequency)->timezone($serverTimezone)->onOneServer();
|
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
|
||||||
|
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
|
||||||
|
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
|
||||||
|
}
|
||||||
|
$this->scheduleInstance->job(new ServerStorageCheckJob($server))->cron($serverDiskUsageCheckFrequency)->timezone($serverTimezone)->onOneServer();
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup multiplexed connections every hour
|
$dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
|
||||||
// $this->scheduleInstance->job(new ServerCleanupMux($server))->hourly()->onOneServer();
|
if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) {
|
||||||
|
$dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency];
|
||||||
|
}
|
||||||
|
$this->scheduleInstance->job(new DockerCleanupJob($server))->cron($dockerCleanupFrequency)->timezone($serverTimezone)->onOneServer();
|
||||||
|
|
||||||
// Temporary solution until we have better memory management for Sentinel
|
// Server patch check - weekly
|
||||||
if ($server->isSentinelEnabled()) {
|
$this->scheduleInstance->job(new ServerPatchCheckJob($server))->weekly()->timezone($serverTimezone)->onOneServer();
|
||||||
$this->scheduleInstance->job(function () use ($server) {
|
|
||||||
$server->restartContainer('coolify-sentinel');
|
// Cleanup multiplexed connections every hour
|
||||||
})->daily()->onOneServer();
|
// $this->scheduleInstance->job(new ServerCleanupMux($server))->hourly()->onOneServer();
|
||||||
|
|
||||||
|
// Temporary solution until we have better memory management for Sentinel
|
||||||
|
if ($server->isSentinelEnabled()) {
|
||||||
|
$this->scheduleInstance->job(function () use ($server) {
|
||||||
|
$server->restartContainer('coolify-sentinel');
|
||||||
|
})->daily()->onOneServer();
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error checking resources: '.$e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,24 +223,28 @@ class Kernel extends ConsoleKernel
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($finalScheduledBackups as $scheduled_backup) {
|
foreach ($finalScheduledBackups as $scheduled_backup) {
|
||||||
if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) {
|
try {
|
||||||
$scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency];
|
if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) {
|
||||||
}
|
$scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency];
|
||||||
|
}
|
||||||
|
$server = $scheduled_backup->server();
|
||||||
|
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||||
|
|
||||||
$server = $scheduled_backup->server();
|
if (validate_timezone($serverTimezone) === false) {
|
||||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
$serverTimezone = config('app.timezone');
|
||||||
|
}
|
||||||
|
|
||||||
if (validate_timezone($serverTimezone) === false) {
|
if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) {
|
||||||
$serverTimezone = config('app.timezone');
|
$scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency];
|
||||||
|
}
|
||||||
|
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||||
|
$this->scheduleInstance->job(new DatabaseBackupJob(
|
||||||
|
backup: $scheduled_backup
|
||||||
|
))->cron($scheduled_backup->frequency)->timezone($serverTimezone)->onOneServer();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error scheduling backup: '.$e->getMessage());
|
||||||
|
Log::error($e->getTraceAsString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) {
|
|
||||||
$scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency];
|
|
||||||
}
|
|
||||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
|
||||||
$this->scheduleInstance->job(new DatabaseBackupJob(
|
|
||||||
backup: $scheduled_backup
|
|
||||||
))->cron($scheduled_backup->frequency)->timezone($serverTimezone)->onOneServer();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,18 +291,23 @@ class Kernel extends ConsoleKernel
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($finalScheduledTasks as $scheduled_task) {
|
foreach ($finalScheduledTasks as $scheduled_task) {
|
||||||
$server = $scheduled_task->server();
|
try {
|
||||||
if (isset(VALID_CRON_STRINGS[$scheduled_task->frequency])) {
|
$server = $scheduled_task->server();
|
||||||
$scheduled_task->frequency = VALID_CRON_STRINGS[$scheduled_task->frequency];
|
if (isset(VALID_CRON_STRINGS[$scheduled_task->frequency])) {
|
||||||
}
|
$scheduled_task->frequency = VALID_CRON_STRINGS[$scheduled_task->frequency];
|
||||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
}
|
||||||
|
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||||
|
|
||||||
if (validate_timezone($serverTimezone) === false) {
|
if (validate_timezone($serverTimezone) === false) {
|
||||||
$serverTimezone = config('app.timezone');
|
$serverTimezone = config('app.timezone');
|
||||||
|
}
|
||||||
|
$this->scheduleInstance->job(new ScheduledTaskJob(
|
||||||
|
task: $scheduled_task
|
||||||
|
))->cron($scheduled_task->frequency)->timezone($serverTimezone)->onOneServer();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error scheduling task: '.$e->getMessage());
|
||||||
|
Log::error($e->getTraceAsString());
|
||||||
}
|
}
|
||||||
$this->scheduleInstance->job(new ScheduledTaskJob(
|
|
||||||
task: $scheduled_task
|
|
||||||
))->cron($scheduled_task->frequency)->timezone($serverTimezone)->onOneServer();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,21 +12,22 @@ class ApplicationStatusChanged implements ShouldBroadcast
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public $teamId;
|
public ?int $teamId = null;
|
||||||
|
|
||||||
public function __construct($teamId = null)
|
public function __construct($teamId = null)
|
||||||
{
|
{
|
||||||
if (is_null($teamId)) {
|
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||||
$teamId = auth()->user()->currentTeam()->id ?? null;
|
$teamId = auth()->user()->currentTeam()->id;
|
||||||
}
|
|
||||||
if (is_null($teamId)) {
|
|
||||||
throw new \Exception('Team id is null');
|
|
||||||
}
|
}
|
||||||
$this->teamId = $teamId;
|
$this->teamId = $teamId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function broadcastOn(): array
|
public function broadcastOn(): array
|
||||||
{
|
{
|
||||||
|
if (is_null($this->teamId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new PrivateChannel("team.{$this->teamId}"),
|
new PrivateChannel("team.{$this->teamId}"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,21 +12,22 @@ class BackupCreated implements ShouldBroadcast
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public $teamId;
|
public ?int $teamId = null;
|
||||||
|
|
||||||
public function __construct($teamId = null)
|
public function __construct($teamId = null)
|
||||||
{
|
{
|
||||||
if (is_null($teamId)) {
|
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||||
$teamId = auth()->user()->currentTeam()->id ?? null;
|
$teamId = auth()->user()->currentTeam()->id;
|
||||||
}
|
|
||||||
if (is_null($teamId)) {
|
|
||||||
throw new \Exception('Team id is null');
|
|
||||||
}
|
}
|
||||||
$this->teamId = $teamId;
|
$this->teamId = $teamId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function broadcastOn(): array
|
public function broadcastOn(): array
|
||||||
{
|
{
|
||||||
|
if (is_null($this->teamId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new PrivateChannel("team.{$this->teamId}"),
|
new PrivateChannel("team.{$this->teamId}"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -12,21 +12,22 @@ class CloudflareTunnelConfigured implements ShouldBroadcast
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public $teamId;
|
public ?int $teamId = null;
|
||||||
|
|
||||||
public function __construct($teamId = null)
|
public function __construct($teamId = null)
|
||||||
{
|
{
|
||||||
if (is_null($teamId)) {
|
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||||
$teamId = auth()->user()->currentTeam()->id ?? null;
|
$teamId = auth()->user()->currentTeam()->id;
|
||||||
}
|
|
||||||
if (is_null($teamId)) {
|
|
||||||
throw new \Exception('Team id is null');
|
|
||||||
}
|
}
|
||||||
$this->teamId = $teamId;
|
$this->teamId = $teamId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function broadcastOn(): array
|
public function broadcastOn(): array
|
||||||
{
|
{
|
||||||
|
if (is_null($this->teamId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new PrivateChannel("team.{$this->teamId}"),
|
new PrivateChannel("team.{$this->teamId}"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,27 +7,27 @@ use Illuminate\Broadcasting\PrivateChannel;
|
|||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
|
|
||||||
class DatabaseProxyStopped implements ShouldBroadcast
|
class DatabaseProxyStopped implements ShouldBroadcast
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public $teamId;
|
public ?int $teamId = null;
|
||||||
|
|
||||||
public function __construct($teamId = null)
|
public function __construct($teamId = null)
|
||||||
{
|
{
|
||||||
if (is_null($teamId)) {
|
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||||
$teamId = Auth::user()?->currentTeam()?->id ?? null;
|
$teamId = auth()->user()->currentTeam()->id;
|
||||||
}
|
|
||||||
if (is_null($teamId)) {
|
|
||||||
throw new \Exception('Team id is null');
|
|
||||||
}
|
}
|
||||||
$this->teamId = $teamId;
|
$this->teamId = $teamId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function broadcastOn(): array
|
public function broadcastOn(): array
|
||||||
{
|
{
|
||||||
|
if (is_null($this->teamId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new PrivateChannel("team.{$this->teamId}"),
|
new PrivateChannel("team.{$this->teamId}"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,28 +13,24 @@ class DatabaseStatusChanged implements ShouldBroadcast
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public $userId = null;
|
public int|string|null $userId = null;
|
||||||
|
|
||||||
public function __construct($userId = null)
|
public function __construct($userId = null)
|
||||||
{
|
{
|
||||||
if (is_null($userId)) {
|
if (is_null($userId)) {
|
||||||
$userId = Auth::id() ?? null;
|
$userId = Auth::id() ?? null;
|
||||||
}
|
}
|
||||||
if (is_null($userId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->userId = $userId;
|
$this->userId = $userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function broadcastOn(): ?array
|
public function broadcastOn(): ?array
|
||||||
{
|
{
|
||||||
if (! is_null($this->userId)) {
|
if (is_null($this->userId)) {
|
||||||
return [
|
return [];
|
||||||
new PrivateChannel("user.{$this->userId}"),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return [
|
||||||
|
new PrivateChannel("user.{$this->userId}"),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,18 +12,22 @@ class FileStorageChanged implements ShouldBroadcast
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public $teamId;
|
public ?int $teamId = null;
|
||||||
|
|
||||||
public function __construct($teamId = null)
|
public function __construct($teamId = null)
|
||||||
{
|
{
|
||||||
if (is_null($teamId)) {
|
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||||
throw new \Exception('Team id is null');
|
$teamId = auth()->user()->currentTeam()->id;
|
||||||
}
|
}
|
||||||
$this->teamId = $teamId;
|
$this->teamId = $teamId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function broadcastOn(): array
|
public function broadcastOn(): array
|
||||||
{
|
{
|
||||||
|
if (is_null($this->teamId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new PrivateChannel("team.{$this->teamId}"),
|
new PrivateChannel("team.{$this->teamId}"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,32 +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 $teamId;
|
public function __construct(public $data) {}
|
||||||
|
|
||||||
public function __construct($teamId = null)
|
|
||||||
{
|
|
||||||
if (is_null($teamId)) {
|
|
||||||
$teamId = auth()->user()->currentTeam()->id ?? null;
|
|
||||||
}
|
|
||||||
if (is_null($teamId)) {
|
|
||||||
throw new \Exception('Team id is null');
|
|
||||||
}
|
|
||||||
$this->teamId = $teamId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function broadcastOn(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
new PrivateChannel("team.{$this->teamId}"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
35
app/Events/ProxyStatusChangedUI.php
Normal file
35
app/Events/ProxyStatusChangedUI.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class ProxyStatusChangedUI implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public ?int $teamId = null;
|
||||||
|
|
||||||
|
public function __construct(?int $teamId = null)
|
||||||
|
{
|
||||||
|
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||||
|
$teamId = auth()->user()->currentTeam()->id;
|
||||||
|
}
|
||||||
|
$this->teamId = $teamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
if (is_null($this->teamId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
new PrivateChannel("team.{$this->teamId}"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,21 +12,22 @@ class ScheduledTaskDone implements ShouldBroadcast
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public $teamId;
|
public ?int $teamId = null;
|
||||||
|
|
||||||
public function __construct($teamId = null)
|
public function __construct($teamId = null)
|
||||||
{
|
{
|
||||||
if (is_null($teamId)) {
|
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||||
$teamId = auth()->user()->currentTeam()->id ?? null;
|
$teamId = auth()->user()->currentTeam()->id;
|
||||||
}
|
|
||||||
if (is_null($teamId)) {
|
|
||||||
throw new \Exception('Team id is null');
|
|
||||||
}
|
}
|
||||||
$this->teamId = $teamId;
|
$this->teamId = $teamId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function broadcastOn(): array
|
public function broadcastOn(): array
|
||||||
{
|
{
|
||||||
|
if (is_null($this->teamId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new PrivateChannel("team.{$this->teamId}"),
|
new PrivateChannel("team.{$this->teamId}"),
|
||||||
];
|
];
|
||||||
|
|||||||
35
app/Events/ServerPackageUpdated.php
Normal file
35
app/Events/ServerPackageUpdated.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class ServerPackageUpdated implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public ?int $teamId = null;
|
||||||
|
|
||||||
|
public function __construct($teamId = null)
|
||||||
|
{
|
||||||
|
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||||
|
$teamId = auth()->user()->currentTeam()->id;
|
||||||
|
}
|
||||||
|
$this->teamId = $teamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
if (is_null($this->teamId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
new PrivateChannel("team.{$this->teamId}"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Events/ServiceChecked.php
Normal file
35
app/Events/ServiceChecked.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class 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}"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,27 +13,22 @@ class ServiceStatusChanged implements ShouldBroadcast
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public ?string $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;
|
|
||||||
}
|
}
|
||||||
if (is_null($userId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$this->userId = $userId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function broadcastOn(): ?array
|
public function broadcastOn(): array
|
||||||
{
|
{
|
||||||
if (! is_null($this->userId)) {
|
if (is_null($this->teamId)) {
|
||||||
return [
|
return [];
|
||||||
new PrivateChannel("user.{$this->userId}"),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return [
|
||||||
|
new PrivateChannel("team.{$this->teamId}"),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,21 @@ class TestEvent implements ShouldBroadcast
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public $teamId;
|
public ?int $teamId = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->teamId = auth()->user()->currentTeam()->id;
|
if (auth()->check() && auth()->user()->currentTeam()) {
|
||||||
|
$this->teamId = auth()->user()->currentTeam()->id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function broadcastOn(): array
|
public function broadcastOn(): array
|
||||||
{
|
{
|
||||||
|
if (is_null($this->teamId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new PrivateChannel("team.{$this->teamId}"),
|
new PrivateChannel("team.{$this->teamId}"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -103,7 +103,11 @@ class SshMultiplexingHelper
|
|||||||
}
|
}
|
||||||
|
|
||||||
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
|
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
|
||||||
$scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}";
|
if ($server->isIpv6()) {
|
||||||
|
$scp_command .= "{$source} {$server->user}@[{$server->ip}]:{$dest}";
|
||||||
|
} else {
|
||||||
|
$scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}";
|
||||||
|
}
|
||||||
|
|
||||||
return $scp_command;
|
return $scp_command;
|
||||||
}
|
}
|
||||||
|
|||||||
233
app/Helpers/SslHelper.php
Normal file
233
app/Helpers/SslHelper.php
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Helpers;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\SslCertificate;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
|
class SslHelper
|
||||||
|
{
|
||||||
|
private const DEFAULT_ORGANIZATION_NAME = 'Coolify';
|
||||||
|
|
||||||
|
private const DEFAULT_COUNTRY_NAME = 'XX';
|
||||||
|
|
||||||
|
private const DEFAULT_STATE_NAME = 'Default';
|
||||||
|
|
||||||
|
public static function generateSslCertificate(
|
||||||
|
string $commonName,
|
||||||
|
array $subjectAlternativeNames = [],
|
||||||
|
?string $resourceType = null,
|
||||||
|
?int $resourceId = null,
|
||||||
|
?int $serverId = null,
|
||||||
|
int $validityDays = 365,
|
||||||
|
?string $caCert = null,
|
||||||
|
?string $caKey = null,
|
||||||
|
bool $isCaCertificate = false,
|
||||||
|
?string $configurationDir = null,
|
||||||
|
?string $mountPath = null,
|
||||||
|
bool $isPemKeyFileRequired = false,
|
||||||
|
): SslCertificate {
|
||||||
|
$organizationName = self::DEFAULT_ORGANIZATION_NAME;
|
||||||
|
$countryName = self::DEFAULT_COUNTRY_NAME;
|
||||||
|
$stateName = self::DEFAULT_STATE_NAME;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$privateKey = openssl_pkey_new([
|
||||||
|
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||||
|
'curve_name' => 'secp521r1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($privateKey === false) {
|
||||||
|
throw new \RuntimeException('Failed to generate private key: '.openssl_error_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! openssl_pkey_export($privateKey, $privateKeyStr)) {
|
||||||
|
throw new \RuntimeException('Failed to export private key: '.openssl_error_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_null($serverId) && ! $isCaCertificate) {
|
||||||
|
$server = Server::find($serverId);
|
||||||
|
if ($server) {
|
||||||
|
$ip = $server->getIp;
|
||||||
|
if ($ip) {
|
||||||
|
$type = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6)
|
||||||
|
? 'IP'
|
||||||
|
: 'DNS';
|
||||||
|
$subjectAlternativeNames = array_unique(
|
||||||
|
array_merge($subjectAlternativeNames, ["$type:$ip"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$basicConstraints = $isCaCertificate ? 'critical, CA:TRUE, pathlen:0' : 'critical, CA:FALSE';
|
||||||
|
$keyUsage = $isCaCertificate ? 'critical, keyCertSign, cRLSign' : 'critical, digitalSignature, keyAgreement';
|
||||||
|
|
||||||
|
$subjectAltNameSection = '';
|
||||||
|
$extendedKeyUsageSection = '';
|
||||||
|
|
||||||
|
if (! $isCaCertificate) {
|
||||||
|
$extendedKeyUsageSection = "\nextendedKeyUsage = serverAuth, clientAuth";
|
||||||
|
|
||||||
|
$subjectAlternativeNames = array_values(
|
||||||
|
array_unique(
|
||||||
|
array_merge(["DNS:$commonName"], $subjectAlternativeNames)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$formattedSubjectAltNames = array_map(
|
||||||
|
function ($index, $san) {
|
||||||
|
[$type, $value] = explode(':', $san, 2);
|
||||||
|
|
||||||
|
return "{$type}.".($index + 1)." = $value";
|
||||||
|
},
|
||||||
|
array_keys($subjectAlternativeNames),
|
||||||
|
$subjectAlternativeNames
|
||||||
|
);
|
||||||
|
|
||||||
|
$subjectAltNameSection = "subjectAltName = @subject_alt_names\n\n[ subject_alt_names ]\n"
|
||||||
|
.implode("\n", $formattedSubjectAltNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = <<<CONF
|
||||||
|
[ req ]
|
||||||
|
prompt = no
|
||||||
|
distinguished_name = distinguished_name
|
||||||
|
req_extensions = req_ext
|
||||||
|
|
||||||
|
[ distinguished_name ]
|
||||||
|
CN = $commonName
|
||||||
|
|
||||||
|
[ req_ext ]
|
||||||
|
basicConstraints = $basicConstraints
|
||||||
|
keyUsage = $keyUsage
|
||||||
|
{$extendedKeyUsageSection}
|
||||||
|
|
||||||
|
[ v3_req ]
|
||||||
|
basicConstraints = $basicConstraints
|
||||||
|
keyUsage = $keyUsage
|
||||||
|
{$extendedKeyUsageSection}
|
||||||
|
subjectKeyIdentifier = hash
|
||||||
|
{$subjectAltNameSection}
|
||||||
|
CONF;
|
||||||
|
|
||||||
|
$tempConfig = tmpfile();
|
||||||
|
fwrite($tempConfig, $config);
|
||||||
|
$tempConfigPath = stream_get_meta_data($tempConfig)['uri'];
|
||||||
|
|
||||||
|
$csr = openssl_csr_new([
|
||||||
|
'commonName' => $commonName,
|
||||||
|
'organizationName' => $organizationName,
|
||||||
|
'countryName' => $countryName,
|
||||||
|
'stateOrProvinceName' => $stateName,
|
||||||
|
], $privateKey, [
|
||||||
|
'digest_alg' => 'sha512',
|
||||||
|
'config' => $tempConfigPath,
|
||||||
|
'req_extensions' => 'req_ext',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($csr === false) {
|
||||||
|
throw new \RuntimeException('Failed to generate CSR: '.openssl_error_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
$certificate = openssl_csr_sign(
|
||||||
|
$csr,
|
||||||
|
$caCert ?? null,
|
||||||
|
$caKey ?? $privateKey,
|
||||||
|
$validityDays,
|
||||||
|
[
|
||||||
|
'digest_alg' => 'sha512',
|
||||||
|
'config' => $tempConfigPath,
|
||||||
|
'x509_extensions' => 'v3_req',
|
||||||
|
],
|
||||||
|
random_int(1, PHP_INT_MAX)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($certificate === false) {
|
||||||
|
throw new \RuntimeException('Failed to sign certificate: '.openssl_error_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! openssl_x509_export($certificate, $certificateStr)) {
|
||||||
|
throw new \RuntimeException('Failed to export certificate: '.openssl_error_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
SslCertificate::query()
|
||||||
|
->where('resource_type', $resourceType)
|
||||||
|
->where('resource_id', $resourceId)
|
||||||
|
->where('server_id', $serverId)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$sslCertificate = SslCertificate::create([
|
||||||
|
'ssl_certificate' => $certificateStr,
|
||||||
|
'ssl_private_key' => $privateKeyStr,
|
||||||
|
'resource_type' => $resourceType,
|
||||||
|
'resource_id' => $resourceId,
|
||||||
|
'server_id' => $serverId,
|
||||||
|
'configuration_dir' => $configurationDir,
|
||||||
|
'mount_path' => $mountPath,
|
||||||
|
'valid_until' => CarbonImmutable::now()->addDays($validityDays),
|
||||||
|
'is_ca_certificate' => $isCaCertificate,
|
||||||
|
'common_name' => $commonName,
|
||||||
|
'subject_alternative_names' => $subjectAlternativeNames,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($configurationDir && $mountPath && $resourceType && $resourceId) {
|
||||||
|
$model = app($resourceType)->find($resourceId);
|
||||||
|
|
||||||
|
$model->fileStorages()
|
||||||
|
->where('resource_type', $model->getMorphClass())
|
||||||
|
->where('resource_id', $model->id)
|
||||||
|
->get()
|
||||||
|
->filter(function ($storage) use ($mountPath) {
|
||||||
|
return in_array($storage->mount_path, [
|
||||||
|
$mountPath.'/server.crt',
|
||||||
|
$mountPath.'/server.key',
|
||||||
|
$mountPath.'/server.pem',
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->each(function ($storage) {
|
||||||
|
$storage->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($isPemKeyFileRequired) {
|
||||||
|
$model->fileStorages()->create([
|
||||||
|
'fs_path' => $configurationDir.'/ssl/server.pem',
|
||||||
|
'mount_path' => $mountPath.'/server.pem',
|
||||||
|
'content' => $certificateStr."\n".$privateKeyStr,
|
||||||
|
'is_directory' => false,
|
||||||
|
'chmod' => '600',
|
||||||
|
'resource_type' => $resourceType,
|
||||||
|
'resource_id' => $resourceId,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$model->fileStorages()->create([
|
||||||
|
'fs_path' => $configurationDir.'/ssl/server.crt',
|
||||||
|
'mount_path' => $mountPath.'/server.crt',
|
||||||
|
'content' => $certificateStr,
|
||||||
|
'is_directory' => false,
|
||||||
|
'chmod' => '644',
|
||||||
|
'resource_type' => $resourceType,
|
||||||
|
'resource_id' => $resourceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$model->fileStorages()->create([
|
||||||
|
'fs_path' => $configurationDir.'/ssl/server.key',
|
||||||
|
'mount_path' => $mountPath.'/server.key',
|
||||||
|
'content' => $privateKeyStr,
|
||||||
|
'is_directory' => false,
|
||||||
|
'chmod' => '600',
|
||||||
|
'resource_type' => $resourceType,
|
||||||
|
'resource_id' => $resourceId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sslCertificate;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
throw new \RuntimeException('SSL Certificate generation failed: '.$e->getMessage(), 0, $e);
|
||||||
|
} finally {
|
||||||
|
fclose($tempConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ use App\Models\Service;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
|
use Spatie\Url\Url;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ class ApplicationsController extends Controller
|
|||||||
'private_key_id',
|
'private_key_id',
|
||||||
'value',
|
'value',
|
||||||
'real_value',
|
'real_value',
|
||||||
|
'http_basic_auth_password',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +184,10 @@ 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.'],
|
||||||
|
'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_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.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -298,6 +304,10 @@ 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.'],
|
||||||
|
'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_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.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -414,6 +424,10 @@ 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.'],
|
||||||
|
'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_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.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -514,6 +528,10 @@ class ApplicationsController extends Controller
|
|||||||
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
|
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
|
||||||
'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.'],
|
||||||
|
'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_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.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -611,6 +629,10 @@ class ApplicationsController extends Controller
|
|||||||
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
|
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
|
||||||
'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.'],
|
||||||
|
'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_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.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -674,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.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -710,7 +733,6 @@ class ApplicationsController extends Controller
|
|||||||
|
|
||||||
private function create_application(Request $request, $type)
|
private function create_application(Request $request, $type)
|
||||||
{
|
{
|
||||||
$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'];
|
|
||||||
$teamId = getTeamIdFromToken();
|
$teamId = getTeamIdFromToken();
|
||||||
if (is_null($teamId)) {
|
if (is_null($teamId)) {
|
||||||
return invalidTokenResponse();
|
return invalidTokenResponse();
|
||||||
@@ -720,6 +742,8 @@ class ApplicationsController extends Controller
|
|||||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
return $return;
|
return $return;
|
||||||
}
|
}
|
||||||
|
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network'];
|
||||||
|
|
||||||
$validator = customApiValidator($request->all(), [
|
$validator = customApiValidator($request->all(), [
|
||||||
'name' => 'string|max:255',
|
'name' => 'string|max:255',
|
||||||
'description' => 'string|nullable',
|
'description' => 'string|nullable',
|
||||||
@@ -728,6 +752,9 @@ class ApplicationsController extends Controller
|
|||||||
'environment_uuid' => 'string|nullable',
|
'environment_uuid' => 'string|nullable',
|
||||||
'server_uuid' => 'string|required',
|
'server_uuid' => 'string|required',
|
||||||
'destination_uuid' => 'string',
|
'destination_uuid' => 'string',
|
||||||
|
'is_http_basic_auth_enabled' => 'boolean',
|
||||||
|
'http_basic_auth_username' => 'string|nullable',
|
||||||
|
'http_basic_auth_password' => 'string|nullable',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
@@ -756,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)) {
|
||||||
@@ -811,6 +839,11 @@ class ApplicationsController extends Controller
|
|||||||
'docker_compose_raw' => 'string|nullable',
|
'docker_compose_raw' => 'string|nullable',
|
||||||
'docker_compose_domains' => 'array|nullable',
|
'docker_compose_domains' => 'array|nullable',
|
||||||
];
|
];
|
||||||
|
// ports_exposes is not required for dockercompose
|
||||||
|
if ($request->build_pack === 'dockercompose') {
|
||||||
|
$validationRules['ports_exposes'] = 'string';
|
||||||
|
$request->offsetSet('ports_exposes', '80');
|
||||||
|
}
|
||||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||||
$validator = customApiValidator($request->all(), $validationRules);
|
$validator = customApiValidator($request->all(), $validationRules);
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
@@ -822,10 +855,6 @@ class ApplicationsController extends Controller
|
|||||||
if (! $request->has('name')) {
|
if (! $request->has('name')) {
|
||||||
$request->offsetSet('name', generate_application_name($request->git_repository, $request->git_branch));
|
$request->offsetSet('name', generate_application_name($request->git_repository, $request->git_branch));
|
||||||
}
|
}
|
||||||
if ($request->build_pack === 'dockercompose') {
|
|
||||||
$request->offsetSet('ports_exposes', '80');
|
|
||||||
}
|
|
||||||
|
|
||||||
$return = $this->validateDataApplications($request, $server);
|
$return = $this->validateDataApplications($request, $server);
|
||||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
return $return;
|
return $return;
|
||||||
@@ -848,7 +877,13 @@ class ApplicationsController extends Controller
|
|||||||
if ($dockerComposeDomainsJson->count() > 0) {
|
if ($dockerComposeDomainsJson->count() > 0) {
|
||||||
$application->docker_compose_domains = $dockerComposeDomainsJson;
|
$application->docker_compose_domains = $dockerComposeDomainsJson;
|
||||||
}
|
}
|
||||||
|
$repository_url_parsed = Url::fromString($request->git_repository);
|
||||||
|
$git_host = $repository_url_parsed->getHost();
|
||||||
|
if ($git_host === 'github.com') {
|
||||||
|
$application->source_type = GithubApp::class;
|
||||||
|
$application->source_id = GithubApp::find(0)->id;
|
||||||
|
}
|
||||||
|
$application->git_repository = $repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2);
|
||||||
$application->fqdn = $fqdn;
|
$application->fqdn = $fqdn;
|
||||||
$application->destination_id = $destination->id;
|
$application->destination_id = $destination->id;
|
||||||
$application->destination_type = $destination->getMorphClass();
|
$application->destination_type = $destination->getMorphClass();
|
||||||
@@ -858,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();
|
||||||
@@ -872,12 +911,17 @@ class ApplicationsController extends Controller
|
|||||||
if ($instantDeploy) {
|
if ($instantDeploy) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
no_questions_asked: true,
|
no_questions_asked: true,
|
||||||
is_api: true,
|
is_api: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'skipped') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $result['message'],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if ($application->build_pack === 'dockercompose') {
|
if ($application->build_pack === 'dockercompose') {
|
||||||
LoadComposeFile::dispatch($application);
|
LoadComposeFile::dispatch($application);
|
||||||
@@ -924,10 +968,31 @@ class ApplicationsController extends Controller
|
|||||||
if (! $githubApp) {
|
if (! $githubApp) {
|
||||||
return response()->json(['message' => 'Github App not found.'], 404);
|
return response()->json(['message' => 'Github App not found.'], 404);
|
||||||
}
|
}
|
||||||
|
$token = generateGithubInstallationToken($githubApp);
|
||||||
|
if (! $token) {
|
||||||
|
return response()->json(['message' => 'Failed to generate Github App token.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$repositories = collect();
|
||||||
|
$page = 1;
|
||||||
|
$repositories = loadRepositoryByPage($githubApp, $token, $page);
|
||||||
|
if ($repositories['total_count'] > 0) {
|
||||||
|
while (count($repositories['repositories']) < $repositories['total_count']) {
|
||||||
|
$page++;
|
||||||
|
$repositories = loadRepositoryByPage($githubApp, $token, $page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$gitRepository = $request->git_repository;
|
$gitRepository = $request->git_repository;
|
||||||
if (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) {
|
if (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) {
|
||||||
$gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', '');
|
$gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', '');
|
||||||
}
|
}
|
||||||
|
$gitRepositoryFound = collect($repositories['repositories'])->firstWhere('full_name', $gitRepository);
|
||||||
|
if (! $gitRepositoryFound) {
|
||||||
|
return response()->json(['message' => 'Repository not found.'], 404);
|
||||||
|
}
|
||||||
|
$repository_project_id = data_get($gitRepositoryFound, 'id');
|
||||||
|
|
||||||
$application = new Application;
|
$application = new Application;
|
||||||
removeUnnecessaryFieldsFromRequest($request);
|
removeUnnecessaryFieldsFromRequest($request);
|
||||||
|
|
||||||
@@ -935,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) {
|
||||||
@@ -958,6 +1049,8 @@ class ApplicationsController extends Controller
|
|||||||
$application->environment_id = $environment->id;
|
$application->environment_id = $environment->id;
|
||||||
$application->source_type = $githubApp->getMorphClass();
|
$application->source_type = $githubApp->getMorphClass();
|
||||||
$application->source_id = $githubApp->id;
|
$application->source_id = $githubApp->id;
|
||||||
|
$application->repository_project_id = $repository_project_id;
|
||||||
|
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$application->refresh();
|
||||||
if (isset($useBuildServer)) {
|
if (isset($useBuildServer)) {
|
||||||
@@ -973,12 +1066,17 @@ class ApplicationsController extends Controller
|
|||||||
if ($instantDeploy) {
|
if ($instantDeploy) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
no_questions_asked: true,
|
no_questions_asked: true,
|
||||||
is_api: true,
|
is_api: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'skipped') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $result['message'],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if ($application->build_pack === 'dockercompose') {
|
if ($application->build_pack === 'dockercompose') {
|
||||||
LoadComposeFile::dispatch($application);
|
LoadComposeFile::dispatch($application);
|
||||||
@@ -1034,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) {
|
||||||
@@ -1070,12 +1195,17 @@ class ApplicationsController extends Controller
|
|||||||
if ($instantDeploy) {
|
if ($instantDeploy) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
no_questions_asked: true,
|
no_questions_asked: true,
|
||||||
is_api: true,
|
is_api: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'skipped') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $result['message'],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if ($application->build_pack === 'dockercompose') {
|
if ($application->build_pack === 'dockercompose') {
|
||||||
LoadComposeFile::dispatch($application);
|
LoadComposeFile::dispatch($application);
|
||||||
@@ -1159,12 +1289,17 @@ class ApplicationsController extends Controller
|
|||||||
if ($instantDeploy) {
|
if ($instantDeploy) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
no_questions_asked: true,
|
no_questions_asked: true,
|
||||||
is_api: true,
|
is_api: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'skipped') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $result['message'],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
@@ -1223,12 +1358,17 @@ class ApplicationsController extends Controller
|
|||||||
if ($instantDeploy) {
|
if ($instantDeploy) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
no_questions_asked: true,
|
no_questions_asked: true,
|
||||||
is_api: true,
|
is_api: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'skipped') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $result['message'],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
@@ -1291,11 +1431,6 @@ class ApplicationsController extends Controller
|
|||||||
$dockerCompose = base64_decode($request->docker_compose_raw);
|
$dockerCompose = base64_decode($request->docker_compose_raw);
|
||||||
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||||
|
|
||||||
// $isValid = validateComposeFile($dockerComposeRaw, $server_id);
|
|
||||||
// if ($isValid !== 'OK') {
|
|
||||||
// return $this->dispatch('error', "Invalid docker-compose file.\n$isValid");
|
|
||||||
// }
|
|
||||||
|
|
||||||
$service = new Service;
|
$service = new Service;
|
||||||
removeUnnecessaryFieldsFromRequest($request);
|
removeUnnecessaryFieldsFromRequest($request);
|
||||||
$service->fill($request->all());
|
$service->fill($request->all());
|
||||||
@@ -1307,7 +1442,6 @@ class ApplicationsController extends Controller
|
|||||||
$service->destination_type = $destination->getMorphClass();
|
$service->destination_type = $destination->getMorphClass();
|
||||||
$service->save();
|
$service->save();
|
||||||
|
|
||||||
$service->name = "service-$service->uuid";
|
|
||||||
$service->parse(isNew: true);
|
$service->parse(isNew: true);
|
||||||
if ($instantDeploy) {
|
if ($instantDeploy) {
|
||||||
StartService::dispatch($service);
|
StartService::dispatch($service);
|
||||||
@@ -1388,6 +1522,108 @@ class ApplicationsController extends Controller
|
|||||||
return response()->json($this->removeSensitiveData($application));
|
return response()->json($this->removeSensitiveData($application));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'Get application logs.',
|
||||||
|
description: 'Get application logs by UUID.',
|
||||||
|
path: '/applications/{uuid}/logs',
|
||||||
|
operationId: 'get-application-logs-by-uuid',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Applications'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'uuid',
|
||||||
|
in: 'path',
|
||||||
|
description: 'UUID of the application.',
|
||||||
|
required: true,
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid',
|
||||||
|
)
|
||||||
|
),
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'lines',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Number of lines to show from the end of the logs.',
|
||||||
|
required: false,
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'integer',
|
||||||
|
format: 'int32',
|
||||||
|
default: 100,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Get application logs by UUID.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'logs' => ['type' => 'string'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 400,
|
||||||
|
ref: '#/components/responses/400',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function logs_by_uuid(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
$uuid = $request->route('uuid');
|
||||||
|
if (! $uuid) {
|
||||||
|
return response()->json(['message' => 'UUID is required.'], 400);
|
||||||
|
}
|
||||||
|
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||||
|
if (! $application) {
|
||||||
|
return response()->json(['message' => 'Application not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id);
|
||||||
|
|
||||||
|
if ($containers->count() == 0) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Application is not running.',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$container = $containers->first();
|
||||||
|
|
||||||
|
$status = getContainerStatus($application->destination->server, $container['Names']);
|
||||||
|
if ($status !== 'running') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Application is not running.',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = $request->query->get('lines', 100) ?: 100;
|
||||||
|
$logs = getContainerLogs($application->destination->server, $container['ID'], $lines);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'logs' => $logs,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
#[OA\Delete(
|
#[OA\Delete(
|
||||||
summary: 'Delete',
|
summary: 'Delete',
|
||||||
description: 'Delete application by UUID.',
|
description: 'Delete application by UUID.',
|
||||||
@@ -1483,6 +1719,18 @@ class ApplicationsController extends Controller
|
|||||||
['bearerAuth' => []],
|
['bearerAuth' => []],
|
||||||
],
|
],
|
||||||
tags: ['Applications'],
|
tags: ['Applications'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'uuid',
|
||||||
|
in: 'path',
|
||||||
|
description: 'UUID of the application.',
|
||||||
|
required: true,
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid',
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
requestBody: new OA\RequestBody(
|
requestBody: new OA\RequestBody(
|
||||||
description: 'Application updated.',
|
description: 'Application updated.',
|
||||||
required: true,
|
required: true,
|
||||||
@@ -1553,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.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -1594,25 +1843,19 @@ class ApplicationsController extends Controller
|
|||||||
if (is_null($teamId)) {
|
if (is_null($teamId)) {
|
||||||
return invalidTokenResponse();
|
return invalidTokenResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->collect()->count() == 0) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Invalid request.',
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
$return = validateIncomingRequest($request);
|
$return = validateIncomingRequest($request);
|
||||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
return $return;
|
return $return;
|
||||||
}
|
}
|
||||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
|
||||||
|
|
||||||
|
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||||
if (! $application) {
|
if (! $application) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Application not found',
|
'message' => 'Application not found',
|
||||||
], 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'];
|
$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',
|
||||||
@@ -1625,6 +1868,9 @@ class ApplicationsController extends Controller
|
|||||||
'docker_compose_custom_start_command' => 'string|nullable',
|
'docker_compose_custom_start_command' => 'string|nullable',
|
||||||
'docker_compose_custom_build_command' => 'string|nullable',
|
'docker_compose_custom_build_command' => 'string|nullable',
|
||||||
'custom_nginx_configuration' => 'string|nullable',
|
'custom_nginx_configuration' => 'string|nullable',
|
||||||
|
'is_http_basic_auth_enabled' => 'boolean|nullable',
|
||||||
|
'http_basic_auth_username' => 'string',
|
||||||
|
'http_basic_auth_password' => 'string',
|
||||||
];
|
];
|
||||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||||
$validator = customApiValidator($request->all(), $validationRules);
|
$validator = customApiValidator($request->all(), $validationRules);
|
||||||
@@ -1680,8 +1926,32 @@ class ApplicationsController extends Controller
|
|||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) {
|
||||||
|
if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) {
|
||||||
|
$validationErrors = [];
|
||||||
|
if (blank($request->http_basic_auth_username)) {
|
||||||
|
$validationErrors['http_basic_auth_username'] = 'The http_basic_auth_username is required.';
|
||||||
|
}
|
||||||
|
if (blank($request->http_basic_auth_password)) {
|
||||||
|
$validationErrors['http_basic_auth_password'] = 'The http_basic_auth_password is required.';
|
||||||
|
}
|
||||||
|
if (count($validationErrors) > 0) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => $validationErrors,
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($request->has('is_http_basic_auth_enabled') && $application->is_container_label_readonly_enabled === false) {
|
||||||
|
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||||
|
$application->save();
|
||||||
|
}
|
||||||
|
|
||||||
$domains = $request->domains;
|
$domains = $request->domains;
|
||||||
if ($request->has('domains') && $server->isProxyShouldRun()) {
|
$requestHasDomains = $request->has('domains');
|
||||||
|
if ($requestHasDomains && $server->isProxyShouldRun()) {
|
||||||
$uuid = $request->uuid;
|
$uuid = $request->uuid;
|
||||||
$fqdn = $request->domains;
|
$fqdn = $request->domains;
|
||||||
$fqdn = str($fqdn)->replaceEnd(',', '')->trim();
|
$fqdn = str($fqdn)->replaceEnd(',', '')->trim();
|
||||||
@@ -1713,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) {
|
||||||
@@ -1728,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)) {
|
||||||
@@ -1740,10 +2038,15 @@ 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();
|
||||||
if ($request->has('domains') && $server->isProxyShouldRun()) {
|
if ($requestHasDomains && $server->isProxyShouldRun()) {
|
||||||
data_set($data, 'fqdn', $domains);
|
data_set($data, 'fqdn', $domains);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1756,11 +2059,16 @@ class ApplicationsController extends Controller
|
|||||||
if ($instantDeploy) {
|
if ($instantDeploy) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
is_api: true,
|
is_api: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'skipped') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $result['message'],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -2392,10 +2700,6 @@ class ApplicationsController extends Controller
|
|||||||
])->setStatusCode(201);
|
])->setStatusCode(201);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Something went wrong.',
|
|
||||||
], 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[OA\Delete(
|
#[OA\Delete(
|
||||||
@@ -2577,13 +2881,21 @@ class ApplicationsController extends Controller
|
|||||||
|
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
force_rebuild: $force,
|
force_rebuild: $force,
|
||||||
is_api: true,
|
is_api: true,
|
||||||
no_questions_asked: $instant_deploy
|
no_questions_asked: $instant_deploy
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'skipped') {
|
||||||
|
return response()->json(
|
||||||
|
[
|
||||||
|
'message' => $result['message'],
|
||||||
|
],
|
||||||
|
200
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(
|
return response()->json(
|
||||||
[
|
[
|
||||||
@@ -2738,12 +3050,17 @@ class ApplicationsController extends Controller
|
|||||||
|
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
restart_only: true,
|
restart_only: true,
|
||||||
is_api: true,
|
is_api: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'skipped') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $result['message'],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(
|
return response()->json(
|
||||||
[
|
[
|
||||||
@@ -2753,130 +3070,130 @@ class ApplicationsController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[OA\Post(
|
// #[OA\Post(
|
||||||
summary: 'Execute Command',
|
// summary: 'Execute Command',
|
||||||
description: "Execute a command on the application's current container.",
|
// description: "Execute a command on the application's current container.",
|
||||||
path: '/applications/{uuid}/execute',
|
// path: '/applications/{uuid}/execute',
|
||||||
operationId: 'execute-command-application',
|
// operationId: 'execute-command-application',
|
||||||
security: [
|
// security: [
|
||||||
['bearerAuth' => []],
|
// ['bearerAuth' => []],
|
||||||
],
|
// ],
|
||||||
tags: ['Applications'],
|
// tags: ['Applications'],
|
||||||
parameters: [
|
// parameters: [
|
||||||
new OA\Parameter(
|
// new OA\Parameter(
|
||||||
name: 'uuid',
|
// name: 'uuid',
|
||||||
in: 'path',
|
// in: 'path',
|
||||||
description: 'UUID of the application.',
|
// description: 'UUID of the application.',
|
||||||
required: true,
|
// required: true,
|
||||||
schema: new OA\Schema(
|
// schema: new OA\Schema(
|
||||||
type: 'string',
|
// type: 'string',
|
||||||
format: 'uuid',
|
// format: 'uuid',
|
||||||
)
|
// )
|
||||||
),
|
// ),
|
||||||
],
|
// ],
|
||||||
requestBody: new OA\RequestBody(
|
// requestBody: new OA\RequestBody(
|
||||||
required: true,
|
// required: true,
|
||||||
description: 'Command to execute.',
|
// description: 'Command to execute.',
|
||||||
content: new OA\MediaType(
|
// content: new OA\MediaType(
|
||||||
mediaType: 'application/json',
|
// mediaType: 'application/json',
|
||||||
schema: new OA\Schema(
|
// schema: new OA\Schema(
|
||||||
type: 'object',
|
// type: 'object',
|
||||||
properties: [
|
// properties: [
|
||||||
'command' => ['type' => 'string', 'description' => 'Command to execute.'],
|
// 'command' => ['type' => 'string', 'description' => 'Command to execute.'],
|
||||||
],
|
// ],
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
responses: [
|
// responses: [
|
||||||
new OA\Response(
|
// new OA\Response(
|
||||||
response: 200,
|
// response: 200,
|
||||||
description: "Execute a command on the application's current container.",
|
// description: "Execute a command on the application's current container.",
|
||||||
content: [
|
// content: [
|
||||||
new OA\MediaType(
|
// new OA\MediaType(
|
||||||
mediaType: 'application/json',
|
// mediaType: 'application/json',
|
||||||
schema: new OA\Schema(
|
// schema: new OA\Schema(
|
||||||
type: 'object',
|
// type: 'object',
|
||||||
properties: [
|
// properties: [
|
||||||
'message' => ['type' => 'string', 'example' => 'Command executed.'],
|
// 'message' => ['type' => 'string', 'example' => 'Command executed.'],
|
||||||
'response' => ['type' => 'string'],
|
// 'response' => ['type' => 'string'],
|
||||||
]
|
// ]
|
||||||
)
|
// )
|
||||||
),
|
// ),
|
||||||
]
|
// ]
|
||||||
),
|
// ),
|
||||||
new OA\Response(
|
// new OA\Response(
|
||||||
response: 401,
|
// response: 401,
|
||||||
ref: '#/components/responses/401',
|
// ref: '#/components/responses/401',
|
||||||
),
|
// ),
|
||||||
new OA\Response(
|
// new OA\Response(
|
||||||
response: 400,
|
// response: 400,
|
||||||
ref: '#/components/responses/400',
|
// ref: '#/components/responses/400',
|
||||||
),
|
// ),
|
||||||
new OA\Response(
|
// new OA\Response(
|
||||||
response: 404,
|
// response: 404,
|
||||||
ref: '#/components/responses/404',
|
// ref: '#/components/responses/404',
|
||||||
),
|
// ),
|
||||||
]
|
// ]
|
||||||
)]
|
// )]
|
||||||
public function execute_command_by_uuid(Request $request)
|
// public function execute_command_by_uuid(Request $request)
|
||||||
{
|
// {
|
||||||
// TODO: Need to review this from security perspective, to not allow arbitrary command execution
|
// // TODO: Need to review this from security perspective, to not allow arbitrary command execution
|
||||||
$allowedFields = ['command'];
|
// $allowedFields = ['command'];
|
||||||
$teamId = getTeamIdFromToken();
|
// $teamId = getTeamIdFromToken();
|
||||||
if (is_null($teamId)) {
|
// if (is_null($teamId)) {
|
||||||
return invalidTokenResponse();
|
// return invalidTokenResponse();
|
||||||
}
|
// }
|
||||||
$uuid = $request->route('uuid');
|
// $uuid = $request->route('uuid');
|
||||||
if (! $uuid) {
|
// if (! $uuid) {
|
||||||
return response()->json(['message' => 'UUID is required.'], 400);
|
// return response()->json(['message' => 'UUID is required.'], 400);
|
||||||
}
|
// }
|
||||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
// $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||||
if (! $application) {
|
// if (! $application) {
|
||||||
return response()->json(['message' => 'Application not found.'], 404);
|
// return response()->json(['message' => 'Application not found.'], 404);
|
||||||
}
|
// }
|
||||||
$return = validateIncomingRequest($request);
|
// $return = validateIncomingRequest($request);
|
||||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
// if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
return $return;
|
// return $return;
|
||||||
}
|
// }
|
||||||
$validator = customApiValidator($request->all(), [
|
// $validator = customApiValidator($request->all(), [
|
||||||
'command' => 'string|required',
|
// 'command' => 'string|required',
|
||||||
]);
|
// ]);
|
||||||
|
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
// $extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
if ($validator->fails() || ! empty($extraFields)) {
|
// if ($validator->fails() || ! empty($extraFields)) {
|
||||||
$errors = $validator->errors();
|
// $errors = $validator->errors();
|
||||||
if (! empty($extraFields)) {
|
// if (! empty($extraFields)) {
|
||||||
foreach ($extraFields as $field) {
|
// foreach ($extraFields as $field) {
|
||||||
$errors->add($field, 'This field is not allowed.');
|
// $errors->add($field, 'This field is not allowed.');
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
return response()->json([
|
// return response()->json([
|
||||||
'message' => 'Validation failed.',
|
// 'message' => 'Validation failed.',
|
||||||
'errors' => $errors,
|
// 'errors' => $errors,
|
||||||
], 422);
|
// ], 422);
|
||||||
}
|
// }
|
||||||
|
|
||||||
$container = getCurrentApplicationContainerStatus($application->destination->server, $application->id)->firstOrFail();
|
// $container = getCurrentApplicationContainerStatus($application->destination->server, $application->id)->firstOrFail();
|
||||||
$status = getContainerStatus($application->destination->server, $container['Names']);
|
// $status = getContainerStatus($application->destination->server, $container['Names']);
|
||||||
|
|
||||||
if ($status !== 'running') {
|
// if ($status !== 'running') {
|
||||||
return response()->json([
|
// return response()->json([
|
||||||
'message' => 'Application is not running.',
|
// 'message' => 'Application is not running.',
|
||||||
], 400);
|
// ], 400);
|
||||||
}
|
// }
|
||||||
|
|
||||||
$commands = collect([
|
// $commands = collect([
|
||||||
executeInDocker($container['Names'], $request->command),
|
// executeInDocker($container['Names'], $request->command),
|
||||||
]);
|
// ]);
|
||||||
|
|
||||||
$res = instant_remote_process(command: $commands, server: $application->destination->server);
|
// $res = instant_remote_process(command: $commands, server: $application->destination->server);
|
||||||
|
|
||||||
return response()->json([
|
// return response()->json([
|
||||||
'message' => 'Command executed.',
|
// 'message' => 'Command executed.',
|
||||||
'response' => $res,
|
// 'response' => $res,
|
||||||
]);
|
// ]);
|
||||||
}
|
// }
|
||||||
|
|
||||||
private function validateDataApplications(Request $request, Server $server)
|
private function validateDataApplications(Request $request, Server $server)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Actions\Database\StartDatabase;
|
use App\Actions\Database\StartDatabase;
|
||||||
use App\Actions\Service\StartService;
|
use App\Actions\Service\StartService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationDeploymentQueue;
|
use App\Models\ApplicationDeploymentQueue;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Models\Service;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
@@ -131,7 +133,7 @@ class DeployController extends Controller
|
|||||||
|
|
||||||
#[OA\Get(
|
#[OA\Get(
|
||||||
summary: 'Deploy',
|
summary: 'Deploy',
|
||||||
description: 'Deploy by tag or uuid. `Post` request also accepted.',
|
description: 'Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.',
|
||||||
path: '/deploy',
|
path: '/deploy',
|
||||||
operationId: 'deploy-by-tag-or-uuid',
|
operationId: 'deploy-by-tag-or-uuid',
|
||||||
security: [
|
security: [
|
||||||
@@ -142,6 +144,7 @@ class DeployController extends Controller
|
|||||||
new OA\Parameter(name: 'tag', in: 'query', description: 'Tag name(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
|
new OA\Parameter(name: 'tag', in: 'query', description: 'Tag name(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
|
||||||
new OA\Parameter(name: 'uuid', in: 'query', description: 'Resource UUID(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
|
new OA\Parameter(name: 'uuid', in: 'query', description: 'Resource UUID(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
|
||||||
new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')),
|
new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')),
|
||||||
|
new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer')),
|
||||||
],
|
],
|
||||||
|
|
||||||
responses: [
|
responses: [
|
||||||
@@ -184,26 +187,32 @@ class DeployController extends Controller
|
|||||||
public function deploy(Request $request)
|
public function deploy(Request $request)
|
||||||
{
|
{
|
||||||
$teamId = getTeamIdFromToken();
|
$teamId = getTeamIdFromToken();
|
||||||
$uuids = $request->query->get('uuid');
|
|
||||||
$tags = $request->query->get('tag');
|
if (is_null($teamId)) {
|
||||||
$force = $request->query->get('force') ?? false;
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$uuids = $request->input('uuid');
|
||||||
|
$tags = $request->input('tag');
|
||||||
|
$force = $request->input('force') ?? false;
|
||||||
|
$pr = $request->input('pr') ? max((int) $request->input('pr'), 0) : 0;
|
||||||
|
|
||||||
if ($uuids && $tags) {
|
if ($uuids && $tags) {
|
||||||
return response()->json(['message' => 'You can only use uuid or tag, not both.'], 400);
|
return response()->json(['message' => 'You can only use uuid or tag, not both.'], 400);
|
||||||
}
|
}
|
||||||
if (is_null($teamId)) {
|
if ($tags && $pr) {
|
||||||
return invalidTokenResponse();
|
return response()->json(['message' => 'You can only use tag or pr, not both.'], 400);
|
||||||
}
|
}
|
||||||
if ($tags) {
|
if ($tags) {
|
||||||
return $this->by_tags($tags, $teamId, $force);
|
return $this->by_tags($tags, $teamId, $force);
|
||||||
} elseif ($uuids) {
|
} elseif ($uuids) {
|
||||||
return $this->by_uuids($uuids, $teamId, $force);
|
return $this->by_uuids($uuids, $teamId, $force, $pr);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(['message' => 'You must provide uuid or tag.'], 400);
|
return response()->json(['message' => 'You must provide uuid or tag.'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function by_uuids(string $uuid, int $teamId, bool $force = false)
|
private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0)
|
||||||
{
|
{
|
||||||
$uuids = explode(',', $uuid);
|
$uuids = explode(',', $uuid);
|
||||||
$uuids = collect(array_filter($uuids));
|
$uuids = collect(array_filter($uuids));
|
||||||
@@ -216,7 +225,7 @@ class DeployController extends Controller
|
|||||||
foreach ($uuids as $uuid) {
|
foreach ($uuids as $uuid) {
|
||||||
$resource = getResourceByUuid($uuid, $teamId);
|
$resource = getResourceByUuid($uuid, $teamId);
|
||||||
if ($resource) {
|
if ($resource) {
|
||||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force);
|
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr);
|
||||||
if ($deployment_uuid) {
|
if ($deployment_uuid) {
|
||||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||||
} else {
|
} else {
|
||||||
@@ -281,7 +290,7 @@ class DeployController extends Controller
|
|||||||
return response()->json(['message' => 'No resources found with this tag.'], 404);
|
return response()->json(['message' => 'No resources found with this tag.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deploy_resource($resource, bool $force = false): array
|
public function deploy_resource($resource, bool $force = false, int $pr = 0): array
|
||||||
{
|
{
|
||||||
$message = null;
|
$message = null;
|
||||||
$deployment_uuid = null;
|
$deployment_uuid = null;
|
||||||
@@ -289,29 +298,133 @@ class DeployController extends Controller
|
|||||||
return ['message' => "Resource ($resource) not found.", 'deployment_uuid' => $deployment_uuid];
|
return ['message' => "Resource ($resource) not found.", 'deployment_uuid' => $deployment_uuid];
|
||||||
}
|
}
|
||||||
switch ($resource?->getMorphClass()) {
|
switch ($resource?->getMorphClass()) {
|
||||||
case \App\Models\Application::class:
|
case Application::class:
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $resource,
|
application: $resource,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
force_rebuild: $force,
|
force_rebuild: $force,
|
||||||
|
pull_request_id: $pr,
|
||||||
);
|
);
|
||||||
$message = "Application {$resource->name} deployment queued.";
|
if ($result['status'] === 'skipped') {
|
||||||
|
$message = $result['message'];
|
||||||
|
} else {
|
||||||
|
$message = "Application {$resource->name} deployment queued.";
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case \App\Models\Service::class:
|
case Service::class:
|
||||||
StartService::run($resource);
|
StartService::run($resource);
|
||||||
$message = "Service {$resource->name} started. It could take a while, be patient.";
|
$message = "Service {$resource->name} started. It could take a while, be patient.";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Database resource
|
// Database resource
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['message' => $message, 'deployment_uuid' => $deployment_uuid];
|
return ['message' => $message, 'deployment_uuid' => $deployment_uuid];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'List application deployments',
|
||||||
|
description: 'List application deployments by using the app uuid',
|
||||||
|
path: '/deployments/applications/{uuid}',
|
||||||
|
operationId: 'list-deployments-by-app-uuid',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Deployments'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'uuid',
|
||||||
|
in: 'path',
|
||||||
|
description: 'UUID of the application.',
|
||||||
|
required: true,
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid',
|
||||||
|
)
|
||||||
|
),
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'skip',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Number of records to skip.',
|
||||||
|
required: false,
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 0,
|
||||||
|
default: 0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'take',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Number of records to take.',
|
||||||
|
required: false,
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 1,
|
||||||
|
default: 10,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'List application deployments by using the app uuid.',
|
||||||
|
content: [
|
||||||
|
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'array',
|
||||||
|
items: new OA\Items(ref: '#/components/schemas/Application'),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 400,
|
||||||
|
ref: '#/components/responses/400',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function get_application_deployments(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'skip' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'take' => ['nullable', 'integer', 'min:1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$app_uuid = $request->route('uuid', null);
|
||||||
|
$skip = $request->get('skip', 0);
|
||||||
|
$take = $request->get('take', 10);
|
||||||
|
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
$servers = Server::whereTeamId($teamId)->get();
|
||||||
|
|
||||||
|
if (is_null($app_uuid)) {
|
||||||
|
return response()->json(['message' => 'Application uuid is required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $app_uuid)->first();
|
||||||
|
|
||||||
|
if (is_null($application)) {
|
||||||
|
return response()->json(['message' => 'Application not found'], 404);
|
||||||
|
}
|
||||||
|
$deployments = $application->deployments($skip, $take);
|
||||||
|
|
||||||
|
return response()->json($deployments);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,18 @@ class ProjectController extends Controller
|
|||||||
['bearerAuth' => []],
|
['bearerAuth' => []],
|
||||||
],
|
],
|
||||||
tags: ['Projects'],
|
tags: ['Projects'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'uuid',
|
||||||
|
in: 'path',
|
||||||
|
description: 'UUID of the project.',
|
||||||
|
required: true,
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid',
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
requestBody: new OA\RequestBody(
|
requestBody: new OA\RequestBody(
|
||||||
required: true,
|
required: true,
|
||||||
description: 'Project updated.',
|
description: 'Project updated.',
|
||||||
|
|||||||
@@ -368,6 +368,20 @@ class SecurityController extends Controller
|
|||||||
response: 404,
|
response: 404,
|
||||||
description: 'Private Key not found.',
|
description: 'Private Key not found.',
|
||||||
),
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 422,
|
||||||
|
description: 'Private Key is in use and cannot be deleted.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'message' => ['type' => 'string', 'example' => 'Private Key is in use and cannot be deleted.'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
public function delete_key(Request $request)
|
public function delete_key(Request $request)
|
||||||
@@ -384,6 +398,14 @@ class SecurityController extends Controller
|
|||||||
if (is_null($key)) {
|
if (is_null($key)) {
|
||||||
return response()->json(['message' => 'Private Key not found.'], 404);
|
return response()->json(['message' => 'Private Key not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($key->isInUse()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Private Key is in use and cannot be deleted.',
|
||||||
|
'details' => 'This private key is currently being used by servers, applications, or Git integrations.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
$key->forceDelete();
|
$key->forceDelete();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
@@ -809,6 +809,6 @@ class ServersController extends Controller
|
|||||||
}
|
}
|
||||||
ValidateServer::dispatch($server);
|
ValidateServer::dispatch($server);
|
||||||
|
|
||||||
return response()->json(['message' => 'Validation started.']);
|
return response()->json(['message' => 'Validation started.'], 201);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Models\Server;
|
|||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class ServicesController extends Controller
|
class ServicesController extends Controller
|
||||||
{
|
{
|
||||||
@@ -88,8 +89,8 @@ class ServicesController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[OA\Post(
|
#[OA\Post(
|
||||||
summary: 'Create',
|
summary: 'Create service',
|
||||||
description: 'Create a one-click service',
|
description: 'Create a one-click / custom service',
|
||||||
path: '/services',
|
path: '/services',
|
||||||
operationId: 'create-service',
|
operationId: 'create-service',
|
||||||
security: [
|
security: [
|
||||||
@@ -102,7 +103,7 @@ class ServicesController extends Controller
|
|||||||
mediaType: 'application/json',
|
mediaType: 'application/json',
|
||||||
schema: new OA\Schema(
|
schema: new OA\Schema(
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid', 'type'],
|
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
|
||||||
properties: [
|
properties: [
|
||||||
'type' => [
|
'type' => [
|
||||||
'description' => 'The one-click service type',
|
'description' => 'The one-click service type',
|
||||||
@@ -204,6 +205,7 @@ class ServicesController extends Controller
|
|||||||
'server_uuid' => ['type' => 'string', 'description' => 'Server UUID.'],
|
'server_uuid' => ['type' => 'string', 'description' => 'Server UUID.'],
|
||||||
'destination_uuid' => ['type' => 'string', 'description' => 'Destination UUID. Required if server has multiple destinations.'],
|
'destination_uuid' => ['type' => 'string', 'description' => 'Destination UUID. Required if server has multiple destinations.'],
|
||||||
'instant_deploy' => ['type' => 'boolean', 'default' => false, 'description' => 'Start the service immediately after creation.'],
|
'instant_deploy' => ['type' => 'boolean', 'default' => false, 'description' => 'Start the service immediately after creation.'],
|
||||||
|
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -211,7 +213,7 @@ class ServicesController extends Controller
|
|||||||
responses: [
|
responses: [
|
||||||
new OA\Response(
|
new OA\Response(
|
||||||
response: 201,
|
response: 201,
|
||||||
description: 'Create a service.',
|
description: 'Service created successfully.',
|
||||||
content: [
|
content: [
|
||||||
new OA\MediaType(
|
new OA\MediaType(
|
||||||
mediaType: 'application/json',
|
mediaType: 'application/json',
|
||||||
@@ -237,7 +239,7 @@ class ServicesController extends Controller
|
|||||||
)]
|
)]
|
||||||
public function create_service(Request $request)
|
public function create_service(Request $request)
|
||||||
{
|
{
|
||||||
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy'];
|
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw'];
|
||||||
|
|
||||||
$teamId = getTeamIdFromToken();
|
$teamId = getTeamIdFromToken();
|
||||||
if (is_null($teamId)) {
|
if (is_null($teamId)) {
|
||||||
@@ -249,12 +251,13 @@ class ServicesController extends Controller
|
|||||||
return $return;
|
return $return;
|
||||||
}
|
}
|
||||||
$validator = customApiValidator($request->all(), [
|
$validator = customApiValidator($request->all(), [
|
||||||
'type' => 'string|required',
|
'type' => 'string|required_without:docker_compose_raw',
|
||||||
|
'docker_compose_raw' => 'string|required_without:type',
|
||||||
'project_uuid' => 'string|required',
|
'project_uuid' => 'string|required',
|
||||||
'environment_name' => 'string|nullable',
|
'environment_name' => 'string|nullable',
|
||||||
'environment_uuid' => 'string|nullable',
|
'environment_uuid' => 'string|nullable',
|
||||||
'server_uuid' => 'string|required',
|
'server_uuid' => 'string|required',
|
||||||
'destination_uuid' => 'string',
|
'destination_uuid' => 'string|nullable',
|
||||||
'name' => 'string|max:255',
|
'name' => 'string|max:255',
|
||||||
'description' => 'string|nullable',
|
'description' => 'string|nullable',
|
||||||
'instant_deploy' => 'boolean',
|
'instant_deploy' => 'boolean',
|
||||||
@@ -372,12 +375,19 @@ class ServicesController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(['message' => 'Service not found.'], 404);
|
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
|
||||||
} else {
|
} elseif (filled($request->docker_compose_raw)) {
|
||||||
return response()->json(['message' => 'Invalid service type.', 'valid_service_types' => $serviceKeys], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(['message' => 'Invalid service type.'], 400);
|
$service = new Service;
|
||||||
|
$result = $this->upsert_service($request, $service, $teamId);
|
||||||
|
if ($result instanceof \Illuminate\Http\JsonResponse) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(serializeApiResponse($result))->setStatusCode(201);
|
||||||
|
} else {
|
||||||
|
return response()->json(['message' => 'No service type or docker_compose_raw provided.'], 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[OA\Get(
|
#[OA\Get(
|
||||||
@@ -511,6 +521,220 @@ class ServicesController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[OA\Patch(
|
||||||
|
summary: 'Update',
|
||||||
|
description: 'Update service by UUID.',
|
||||||
|
path: '/services/{uuid}',
|
||||||
|
operationId: 'update-service-by-uuid',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Services'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'uuid',
|
||||||
|
in: 'path',
|
||||||
|
description: 'UUID of the service.',
|
||||||
|
required: true,
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid',
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
description: 'Service updated.',
|
||||||
|
required: true,
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'],
|
||||||
|
properties: [
|
||||||
|
'name' => ['type' => 'string', 'description' => 'The service name.'],
|
||||||
|
'description' => ['type' => 'string', 'description' => 'The service description.'],
|
||||||
|
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
||||||
|
'environment_name' => ['type' => 'string', 'description' => 'The environment name.'],
|
||||||
|
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID.'],
|
||||||
|
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
||||||
|
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
|
||||||
|
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the service should be deployed instantly.'],
|
||||||
|
'connect_to_docker_network' => ['type' => 'boolean', 'default' => false, 'description' => 'Connect the service to the predefined docker network.'],
|
||||||
|
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Service updated.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'uuid' => ['type' => 'string', 'description' => 'Service UUID.'],
|
||||||
|
'domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Service domains.'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 400,
|
||||||
|
ref: '#/components/responses/400',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function update_by_uuid(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$return = validateIncomingRequest($request);
|
||||||
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
|
return $return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
|
||||||
|
if (! $service) {
|
||||||
|
return response()->json(['message' => 'Service not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->upsert_service($request, $service, $teamId);
|
||||||
|
if ($result instanceof \Illuminate\Http\JsonResponse) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(serializeApiResponse($result))->setStatusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function upsert_service(Request $request, Service $service, string $teamId)
|
||||||
|
{
|
||||||
|
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network'];
|
||||||
|
$validator = customApiValidator($request->all(), [
|
||||||
|
'project_uuid' => 'string|required',
|
||||||
|
'environment_name' => 'string|nullable',
|
||||||
|
'environment_uuid' => 'string|nullable',
|
||||||
|
'server_uuid' => 'string|required',
|
||||||
|
'destination_uuid' => 'string',
|
||||||
|
'name' => 'string|max:255',
|
||||||
|
'description' => 'string|nullable',
|
||||||
|
'instant_deploy' => 'boolean',
|
||||||
|
'connect_to_docker_network' => 'boolean',
|
||||||
|
'docker_compose_raw' => 'string|required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
|
if ($validator->fails() || ! empty($extraFields)) {
|
||||||
|
$errors = $validator->errors();
|
||||||
|
if (! empty($extraFields)) {
|
||||||
|
foreach ($extraFields as $field) {
|
||||||
|
$errors->add($field, 'This field is not allowed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => $errors,
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$environmentUuid = $request->environment_uuid;
|
||||||
|
$environmentName = $request->environment_name;
|
||||||
|
if (blank($environmentUuid) && blank($environmentName)) {
|
||||||
|
return response()->json(['message' => 'You need to provide at least one of environment_name or environment_uuid.'], 422);
|
||||||
|
}
|
||||||
|
$serverUuid = $request->server_uuid;
|
||||||
|
$instantDeploy = $request->instant_deploy ?? false;
|
||||||
|
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
|
||||||
|
if (! $project) {
|
||||||
|
return response()->json(['message' => 'Project not found.'], 404);
|
||||||
|
}
|
||||||
|
$environment = $project->environments()->where('name', $environmentName)->first();
|
||||||
|
if (! $environment) {
|
||||||
|
$environment = $project->environments()->where('uuid', $environmentUuid)->first();
|
||||||
|
}
|
||||||
|
if (! $environment) {
|
||||||
|
return response()->json(['message' => 'Environment not found.'], 404);
|
||||||
|
}
|
||||||
|
$server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first();
|
||||||
|
if (! $server) {
|
||||||
|
return response()->json(['message' => 'Server not found.'], 404);
|
||||||
|
}
|
||||||
|
$destinations = $server->destinations();
|
||||||
|
if ($destinations->count() == 0) {
|
||||||
|
return response()->json(['message' => 'Server has no destinations.'], 400);
|
||||||
|
}
|
||||||
|
if ($destinations->count() > 1 && ! $request->has('destination_uuid')) {
|
||||||
|
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
|
||||||
|
}
|
||||||
|
$destination = $destinations->first();
|
||||||
|
if (! isBase64Encoded($request->docker_compose_raw)) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => [
|
||||||
|
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
|
||||||
|
],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
|
||||||
|
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => [
|
||||||
|
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
|
||||||
|
],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
$dockerCompose = base64_decode($request->docker_compose_raw);
|
||||||
|
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||||
|
$connectToDockerNetwork = $request->connect_to_docker_network ?? false;
|
||||||
|
|
||||||
|
$service->name = $request->name ?? null;
|
||||||
|
$service->description = $request->description ?? null;
|
||||||
|
$service->docker_compose_raw = $dockerComposeRaw;
|
||||||
|
$service->environment_id = $environment->id;
|
||||||
|
$service->server_id = $server->id;
|
||||||
|
$service->destination_id = $destination->id;
|
||||||
|
$service->destination_type = $destination->getMorphClass();
|
||||||
|
$service->connect_to_docker_network = $connectToDockerNetwork;
|
||||||
|
$service->save();
|
||||||
|
|
||||||
|
$service->parse();
|
||||||
|
if ($instantDeploy) {
|
||||||
|
StartService::dispatch($service);
|
||||||
|
}
|
||||||
|
|
||||||
|
$domains = $service->applications()->get()->pluck('fqdn')->sort();
|
||||||
|
$domains = $domains->map(function ($domain) {
|
||||||
|
if (count(explode(':', $domain)) > 2) {
|
||||||
|
return str($domain)->beforeLast(':')->value();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $domain;
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'uuid' => $service->uuid,
|
||||||
|
'domains' => $domains,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
#[OA\Get(
|
#[OA\Get(
|
||||||
summary: 'List Envs',
|
summary: 'List Envs',
|
||||||
description: 'List all envs by service UUID.',
|
description: 'List all envs by service UUID.',
|
||||||
@@ -1204,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(
|
||||||
@@ -1249,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(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class Controller extends BaseController
|
|||||||
'email' => Str::lower($arrayOfRequest['email']),
|
'email' => Str::lower($arrayOfRequest['email']),
|
||||||
]);
|
]);
|
||||||
$type = set_transanctional_email_settings();
|
$type = set_transanctional_email_settings();
|
||||||
if (! $type) {
|
if (blank($type)) {
|
||||||
return response()->json(['message' => 'Transactional emails are not active'], 400);
|
return response()->json(['message' => 'Transactional emails are not active'], 400);
|
||||||
}
|
}
|
||||||
$request->validate([Fortify::email() => 'required|email']);
|
$request->validate([Fortify::email() => 'required|email']);
|
||||||
@@ -144,7 +144,7 @@ class Controller extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function revoke_invitation()
|
public function revokeInvitation()
|
||||||
{
|
{
|
||||||
$invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail();
|
$invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail();
|
||||||
$user = User::whereEmail($invitation->email)->firstOrFail();
|
$user = User::whereEmail($invitation->email)->firstOrFail();
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class Bitbucket extends Controller
|
|||||||
$headers = $request->headers->all();
|
$headers = $request->headers->all();
|
||||||
$x_bitbucket_token = data_get($headers, 'x-hub-signature.0', '');
|
$x_bitbucket_token = data_get($headers, 'x-hub-signature.0', '');
|
||||||
$x_bitbucket_event = data_get($headers, 'x-event-key.0', '');
|
$x_bitbucket_event = data_get($headers, 'x-event-key.0', '');
|
||||||
$handled_events = collect(['repo:push', 'pullrequest:created', 'pullrequest:rejected', 'pullrequest:fulfilled']);
|
$handled_events = collect(['repo:push', 'pullrequest:updated', 'pullrequest:created', 'pullrequest:rejected', 'pullrequest:fulfilled']);
|
||||||
if (! $handled_events->contains($x_bitbucket_event)) {
|
if (! $handled_events->contains($x_bitbucket_event)) {
|
||||||
return response([
|
return response([
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
@@ -48,6 +48,7 @@ class Bitbucket extends Controller
|
|||||||
$branch = data_get($payload, 'push.changes.0.new.name');
|
$branch = data_get($payload, 'push.changes.0.new.name');
|
||||||
$full_name = data_get($payload, 'repository.full_name');
|
$full_name = data_get($payload, 'repository.full_name');
|
||||||
$commit = data_get($payload, 'push.changes.0.new.target.hash');
|
$commit = data_get($payload, 'push.changes.0.new.target.hash');
|
||||||
|
|
||||||
if (! $branch) {
|
if (! $branch) {
|
||||||
return response([
|
return response([
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
@@ -55,7 +56,7 @@ class Bitbucket extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:rejected' || $x_bitbucket_event === 'pullrequest:fulfilled') {
|
if ($x_bitbucket_event === 'pullrequest:updated' || $x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:rejected' || $x_bitbucket_event === 'pullrequest:fulfilled') {
|
||||||
$branch = data_get($payload, 'pullrequest.destination.branch.name');
|
$branch = data_get($payload, 'pullrequest.destination.branch.name');
|
||||||
$base_branch = data_get($payload, 'pullrequest.source.branch.name');
|
$base_branch = data_get($payload, 'pullrequest.source.branch.name');
|
||||||
$full_name = data_get($payload, 'repository.full_name');
|
$full_name = data_get($payload, 'repository.full_name');
|
||||||
@@ -99,18 +100,26 @@ class Bitbucket extends Controller
|
|||||||
if ($x_bitbucket_event === 'repo:push') {
|
if ($x_bitbucket_event === 'repo:push') {
|
||||||
if ($application->isDeployable()) {
|
if ($application->isDeployable()) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
commit: $commit,
|
commit: $commit,
|
||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
is_webhook: true
|
is_webhook: true
|
||||||
);
|
);
|
||||||
$return_payloads->push([
|
if ($result['status'] === 'skipped') {
|
||||||
'application' => $application->name,
|
$return_payloads->push([
|
||||||
'status' => 'success',
|
'application' => $application->name,
|
||||||
'message' => 'Preview deployment queued.',
|
'status' => 'skipped',
|
||||||
]);
|
'message' => $result['message'],
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$return_payloads->push([
|
||||||
|
'application' => $application->name,
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Deployment queued.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
@@ -119,7 +128,7 @@ class Bitbucket extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($x_bitbucket_event === 'pullrequest:created') {
|
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') {
|
||||||
if ($application->isPRDeployable()) {
|
if ($application->isPRDeployable()) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||||
@@ -142,7 +151,7 @@ class Bitbucket extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
pull_request_id: $pull_request_id,
|
pull_request_id: $pull_request_id,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
@@ -151,11 +160,19 @@ class Bitbucket extends Controller
|
|||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'bitbucket'
|
git_type: 'bitbucket'
|
||||||
);
|
);
|
||||||
$return_payloads->push([
|
if ($result['status'] === 'skipped') {
|
||||||
'application' => $application->name,
|
$return_payloads->push([
|
||||||
'status' => 'success',
|
'application' => $application->name,
|
||||||
'message' => 'Preview deployment queued.',
|
'status' => 'skipped',
|
||||||
]);
|
'message' => $result['message'],
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$return_payloads->push([
|
||||||
|
'application' => $application->name,
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Preview deployment queued.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
|
|||||||
@@ -116,19 +116,27 @@ class Gitea extends Controller
|
|||||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||||
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
commit: data_get($payload, 'after', 'HEAD'),
|
commit: data_get($payload, 'after', 'HEAD'),
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
$return_payloads->push([
|
if ($result['status'] === 'skipped') {
|
||||||
'status' => 'success',
|
$return_payloads->push([
|
||||||
'message' => 'Deployment queued.',
|
'application' => $application->name,
|
||||||
'application_uuid' => $application->uuid,
|
'status' => 'skipped',
|
||||||
'application_name' => $application->name,
|
'message' => $result['message'],
|
||||||
]);
|
]);
|
||||||
|
} else {
|
||||||
|
$return_payloads->push([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Deployment queued.',
|
||||||
|
'application_uuid' => $application->uuid,
|
||||||
|
'application_name' => $application->name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$paths = str($application->watch_paths)->explode("\n");
|
$paths = str($application->watch_paths)->explode("\n");
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
@@ -152,7 +160,7 @@ class Gitea extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($x_gitea_event === 'pull_request') {
|
if ($x_gitea_event === 'pull_request') {
|
||||||
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
|
if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') {
|
||||||
if ($application->isPRDeployable()) {
|
if ($application->isPRDeployable()) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||||
@@ -175,7 +183,7 @@ class Gitea extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
pull_request_id: $pull_request_id,
|
pull_request_id: $pull_request_id,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
@@ -184,11 +192,19 @@ class Gitea extends Controller
|
|||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'gitea'
|
git_type: 'gitea'
|
||||||
);
|
);
|
||||||
$return_payloads->push([
|
if ($result['status'] === 'skipped') {
|
||||||
'application' => $application->name,
|
$return_payloads->push([
|
||||||
'status' => 'success',
|
'application' => $application->name,
|
||||||
'message' => 'Preview deployment queued.',
|
'status' => 'skipped',
|
||||||
]);
|
'message' => $result['message'],
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$return_payloads->push([
|
||||||
|
'application' => $application->name,
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Preview deployment queued.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
@@ -202,7 +218,6 @@ class Gitea extends Controller
|
|||||||
if ($found) {
|
if ($found) {
|
||||||
$found->delete();
|
$found->delete();
|
||||||
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
||||||
// ray('Stopping container: ' . $container_name);
|
|
||||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
|
|||||||
@@ -122,19 +122,29 @@ class Github extends Controller
|
|||||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||||
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
commit: data_get($payload, 'after', 'HEAD'),
|
commit: data_get($payload, 'after', 'HEAD'),
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
$return_payloads->push([
|
if ($result['status'] === 'skipped') {
|
||||||
'status' => 'success',
|
$return_payloads->push([
|
||||||
'message' => 'Deployment queued.',
|
'application' => $application->name,
|
||||||
'application_uuid' => $application->uuid,
|
'status' => 'skipped',
|
||||||
'application_name' => $application->name,
|
'message' => $result['message'],
|
||||||
]);
|
]);
|
||||||
|
} else {
|
||||||
|
$return_payloads->push([
|
||||||
|
'application' => $application->name,
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Deployment queued.',
|
||||||
|
'application_uuid' => $application->uuid,
|
||||||
|
'application_name' => $application->name,
|
||||||
|
'deployment_uuid' => $result['deployment_uuid'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$paths = str($application->watch_paths)->explode("\n");
|
$paths = str($application->watch_paths)->explode("\n");
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
@@ -181,7 +191,8 @@ class Github extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queue_application_deployment(
|
|
||||||
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
pull_request_id: $pull_request_id,
|
pull_request_id: $pull_request_id,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
@@ -190,11 +201,19 @@ class Github extends Controller
|
|||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'github'
|
git_type: 'github'
|
||||||
);
|
);
|
||||||
$return_payloads->push([
|
if ($result['status'] === 'skipped') {
|
||||||
'application' => $application->name,
|
$return_payloads->push([
|
||||||
'status' => 'success',
|
'application' => $application->name,
|
||||||
'message' => 'Preview deployment queued.',
|
'status' => 'skipped',
|
||||||
]);
|
'message' => $result['message'],
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$return_payloads->push([
|
||||||
|
'application' => $application->name,
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Preview deployment queued.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
@@ -208,7 +227,6 @@ class Github extends Controller
|
|||||||
if ($found) {
|
if ($found) {
|
||||||
$found->delete();
|
$found->delete();
|
||||||
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
||||||
// ray('Stopping container: ' . $container_name);
|
|
||||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
@@ -342,7 +360,7 @@ class Github extends Controller
|
|||||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||||
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
commit: data_get($payload, 'after', 'HEAD'),
|
commit: data_get($payload, 'after', 'HEAD'),
|
||||||
@@ -350,10 +368,11 @@ class Github extends Controller
|
|||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'status' => 'success',
|
'status' => $result['status'],
|
||||||
'message' => 'Deployment queued.',
|
'message' => $result['message'],
|
||||||
'application_uuid' => $application->uuid,
|
'application_uuid' => $application->uuid,
|
||||||
'application_name' => $application->name,
|
'application_name' => $application->name,
|
||||||
|
'deployment_uuid' => $result['deployment_uuid'],
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$paths = str($application->watch_paths)->explode("\n");
|
$paths = str($application->watch_paths)->explode("\n");
|
||||||
@@ -390,7 +409,7 @@ class Github extends Controller
|
|||||||
'pull_request_html_url' => $pull_request_html_url,
|
'pull_request_html_url' => $pull_request_html_url,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
pull_request_id: $pull_request_id,
|
pull_request_id: $pull_request_id,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
@@ -399,11 +418,19 @@ class Github extends Controller
|
|||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'github'
|
git_type: 'github'
|
||||||
);
|
);
|
||||||
$return_payloads->push([
|
if ($result['status'] === 'skipped') {
|
||||||
'application' => $application->name,
|
$return_payloads->push([
|
||||||
'status' => 'success',
|
'application' => $application->name,
|
||||||
'message' => 'Preview deployment queued.',
|
'status' => 'skipped',
|
||||||
]);
|
'message' => $result['message'],
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$return_payloads->push([
|
||||||
|
'application' => $application->name,
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Preview deployment queued.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
|
|||||||
@@ -142,19 +142,28 @@ class Gitlab extends Controller
|
|||||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||||
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
commit: data_get($payload, 'after', 'HEAD'),
|
commit: data_get($payload, 'after', 'HEAD'),
|
||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
$return_payloads->push([
|
if ($result['status'] === 'skipped') {
|
||||||
'status' => 'success',
|
$return_payloads->push([
|
||||||
'message' => 'Deployment queued.',
|
'status' => $result['status'],
|
||||||
'application_uuid' => $application->uuid,
|
'message' => $result['message'],
|
||||||
'application_name' => $application->name,
|
'application_uuid' => $application->uuid,
|
||||||
]);
|
'application_name' => $application->name,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$return_payloads->push([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Deployment queued.',
|
||||||
|
'application_uuid' => $application->uuid,
|
||||||
|
'application_name' => $application->name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$paths = str($application->watch_paths)->explode("\n");
|
$paths = str($application->watch_paths)->explode("\n");
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
@@ -201,7 +210,7 @@ class Gitlab extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $application,
|
application: $application,
|
||||||
pull_request_id: $pull_request_id,
|
pull_request_id: $pull_request_id,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
@@ -210,11 +219,19 @@ class Gitlab extends Controller
|
|||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'gitlab'
|
git_type: 'gitlab'
|
||||||
);
|
);
|
||||||
$return_payloads->push([
|
if ($result['status'] === 'skipped') {
|
||||||
'application' => $application->name,
|
$return_payloads->push([
|
||||||
'status' => 'success',
|
'application' => $application->name,
|
||||||
'message' => 'Preview Deployment queued',
|
'status' => 'skipped',
|
||||||
]);
|
'message' => $result['message'],
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$return_payloads->push([
|
||||||
|
'application' => $application->name,
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Preview Deployment queued',
|
||||||
|
]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
@@ -227,7 +244,6 @@ class Gitlab extends Controller
|
|||||||
if ($found) {
|
if ($found) {
|
||||||
$found->delete();
|
$found->delete();
|
||||||
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
||||||
// ray('Stopping container: ' . $container_name);
|
|
||||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -19,6 +19,7 @@ use App\Notifications\Application\DeploymentFailed;
|
|||||||
use App\Notifications\Application\DeploymentSuccess;
|
use App\Notifications\Application\DeploymentSuccess;
|
||||||
use App\Traits\ExecuteRemoteCommand;
|
use App\Traits\ExecuteRemoteCommand;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Exception;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@@ -26,7 +27,6 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Process;
|
|
||||||
use Illuminate\Support\Sleep;
|
use Illuminate\Support\Sleep;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@@ -253,6 +253,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// Make sure the private key is stored in the filesystem
|
||||||
|
$this->server->privateKey->storeInFileSystem();
|
||||||
|
|
||||||
// Generate custom host<->ip mapping
|
// Generate custom host<->ip mapping
|
||||||
$allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server);
|
$allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server);
|
||||||
|
|
||||||
@@ -325,15 +328,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
} else {
|
} else {
|
||||||
$this->write_deployment_configurations();
|
$this->write_deployment_configurations();
|
||||||
}
|
}
|
||||||
$this->execute_remote_command(
|
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
|
||||||
[
|
$this->graceful_shutdown_container($this->deployment_uuid);
|
||||||
"docker rm -f {$this->deployment_uuid} >/dev/null 2>&1",
|
|
||||||
'hidden' => true,
|
|
||||||
'ignore_errors' => true,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
ApplicationStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
|
ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,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()) {
|
||||||
@@ -509,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],
|
||||||
);
|
);
|
||||||
@@ -900,100 +900,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id');
|
$sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id');
|
||||||
}
|
}
|
||||||
$ports = $this->application->main_port();
|
$ports = $this->application->main_port();
|
||||||
if ($this->pull_request_id !== 0) {
|
$coolify_envs = $this->generate_coolify_env_variables();
|
||||||
$this->env_filename = ".env-pr-$this->pull_request_id";
|
$coolify_envs->each(function ($item, $key) use ($envs) {
|
||||||
// Add SOURCE_COMMIT if not exists
|
$envs->push($key.'='.$item);
|
||||||
if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
});
|
||||||
if (! is_null($this->commit)) {
|
if ($this->pull_request_id === 0) {
|
||||||
$envs->push("SOURCE_COMMIT={$this->commit}");
|
|
||||||
} else {
|
|
||||||
$envs->push('SOURCE_COMMIT=unknown');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
|
||||||
$envs->push("COOLIFY_FQDN={$this->preview->fqdn}");
|
|
||||||
$envs->push("COOLIFY_DOMAIN_URL={$this->preview->fqdn}");
|
|
||||||
}
|
|
||||||
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_URL')->isEmpty()) {
|
|
||||||
$url = str($this->preview->fqdn)->replace('http://', '')->replace('https://', '');
|
|
||||||
$envs->push("COOLIFY_URL={$url}");
|
|
||||||
$envs->push("COOLIFY_DOMAIN_FQDN={$url}");
|
|
||||||
}
|
|
||||||
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()) {
|
|
||||||
$envs->push("COOLIFY_BRANCH=\"{$local_branch}\"");
|
|
||||||
}
|
|
||||||
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
|
||||||
$envs->push("COOLIFY_RESOURCE_UUID={$this->application->uuid}");
|
|
||||||
}
|
|
||||||
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
|
||||||
$envs->push("COOLIFY_CONTAINER_NAME={$this->container_name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
add_coolify_default_environment_variables($this->application, $envs, $this->application->environment_variables_preview);
|
|
||||||
|
|
||||||
foreach ($sorted_environment_variables_preview as $env) {
|
|
||||||
$real_value = $env->real_value;
|
|
||||||
if ($env->version === '4.0.0-beta.239') {
|
|
||||||
$real_value = $env->real_value;
|
|
||||||
} else {
|
|
||||||
if ($env->is_literal || $env->is_multiline) {
|
|
||||||
$real_value = '\''.$real_value.'\'';
|
|
||||||
} else {
|
|
||||||
$real_value = escapeEnvVariables($env->real_value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$envs->push($env->key.'='.$real_value);
|
|
||||||
}
|
|
||||||
// Add PORT if not exists, use the first port as default
|
|
||||||
if ($this->build_pack !== 'dockercompose') {
|
|
||||||
if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) {
|
|
||||||
$envs->push("PORT={$ports[0]}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add HOST if not exists
|
|
||||||
if ($this->application->environment_variables_preview->where('key', 'HOST')->isEmpty()) {
|
|
||||||
$envs->push('HOST=0.0.0.0');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$this->env_filename = '.env';
|
$this->env_filename = '.env';
|
||||||
// Add SOURCE_COMMIT if not exists
|
|
||||||
if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
|
||||||
if (! is_null($this->commit)) {
|
|
||||||
$envs->push("SOURCE_COMMIT={$this->commit}");
|
|
||||||
} else {
|
|
||||||
$envs->push('SOURCE_COMMIT=unknown');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
|
||||||
if ((int) $this->application->compose_parsing_version >= 3) {
|
|
||||||
$envs->push("COOLIFY_URL={$this->application->fqdn}");
|
|
||||||
} else {
|
|
||||||
$envs->push("COOLIFY_FQDN={$this->application->fqdn}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($this->application->environment_variables->where('key', 'COOLIFY_URL')->isEmpty()) {
|
|
||||||
$url = str($this->application->fqdn)->replace('http://', '')->replace('https://', '');
|
|
||||||
if ((int) $this->application->compose_parsing_version >= 3) {
|
|
||||||
$envs->push("COOLIFY_FQDN={$url}");
|
|
||||||
} else {
|
|
||||||
$envs->push("COOLIFY_URL={$url}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($this->application->build_pack !== 'dockercompose' || $this->application->compose_parsing_version === '1' || $this->application->compose_parsing_version === '2') {
|
|
||||||
if ($this->application->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
|
|
||||||
$envs->push("COOLIFY_BRANCH=\"{$local_branch}\"");
|
|
||||||
}
|
|
||||||
if ($this->application->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
|
||||||
$envs->push("COOLIFY_RESOURCE_UUID={$this->application->uuid}");
|
|
||||||
}
|
|
||||||
if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
|
||||||
$envs->push("COOLIFY_CONTAINER_NAME={$this->container_name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
add_coolify_default_environment_variables($this->application, $envs, $this->application->environment_variables);
|
|
||||||
|
|
||||||
foreach ($sorted_environment_variables as $env) {
|
foreach ($sorted_environment_variables as $env) {
|
||||||
$real_value = $env->real_value;
|
$real_value = $env->real_value;
|
||||||
@@ -1018,6 +930,32 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) {
|
if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) {
|
||||||
$envs->push('HOST=0.0.0.0');
|
$envs->push('HOST=0.0.0.0');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
$this->env_filename = ".env-pr-$this->pull_request_id";
|
||||||
|
foreach ($sorted_environment_variables_preview as $env) {
|
||||||
|
$real_value = $env->real_value;
|
||||||
|
if ($env->version === '4.0.0-beta.239') {
|
||||||
|
$real_value = $env->real_value;
|
||||||
|
} else {
|
||||||
|
if ($env->is_literal || $env->is_multiline) {
|
||||||
|
$real_value = '\''.$real_value.'\'';
|
||||||
|
} else {
|
||||||
|
$real_value = escapeEnvVariables($env->real_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$envs->push($env->key.'='.$real_value);
|
||||||
|
}
|
||||||
|
// Add PORT if not exists, use the first port as default
|
||||||
|
if ($this->build_pack !== 'dockercompose') {
|
||||||
|
if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) {
|
||||||
|
$envs->push("PORT={$ports[0]}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add HOST if not exists
|
||||||
|
if ($this->application->environment_variables_preview->where('key', 'HOST')->isEmpty()) {
|
||||||
|
$envs->push('HOST=0.0.0.0');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
if ($envs->isEmpty()) {
|
if ($envs->isEmpty()) {
|
||||||
$this->env_filename = null;
|
$this->env_filename = null;
|
||||||
@@ -1204,11 +1142,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
if ($this->application->custom_healthcheck_found) {
|
if ($this->application->custom_healthcheck_found) {
|
||||||
$this->application_deployment_queue->addLogEntry('Custom healthcheck found, skipping default healthcheck.');
|
$this->application_deployment_queue->addLogEntry('Custom healthcheck found, skipping default healthcheck.');
|
||||||
}
|
}
|
||||||
// ray('New container name: ', $this->container_name);
|
|
||||||
if ($this->container_name) {
|
if ($this->container_name) {
|
||||||
$counter = 1;
|
$counter = 1;
|
||||||
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
|
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
|
||||||
if ($this->full_healthcheck_url) {
|
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
|
||||||
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
|
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
|
||||||
}
|
}
|
||||||
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
|
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
|
||||||
@@ -1363,13 +1300,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage.");
|
$this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage.");
|
||||||
$this->execute_remote_command(
|
$this->graceful_shutdown_container($this->deployment_uuid);
|
||||||
[
|
|
||||||
'command' => "docker rm -f {$this->deployment_uuid}",
|
|
||||||
'ignore_errors' => true,
|
|
||||||
'hidden' => true,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
$this->execute_remote_command(
|
$this->execute_remote_command(
|
||||||
[
|
[
|
||||||
$runCommand,
|
$runCommand,
|
||||||
@@ -1401,13 +1332,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
}
|
}
|
||||||
foreach ($destination_ids as $destination_id) {
|
foreach ($destination_ids as $destination_id) {
|
||||||
$destination = StandaloneDocker::find($destination_id);
|
$destination = StandaloneDocker::find($destination_id);
|
||||||
|
if (! $destination) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$server = $destination->server;
|
$server = $destination->server;
|
||||||
if ($server->team_id !== $this->mainServer->team_id) {
|
if ($server->team_id !== $this->mainServer->team_id) {
|
||||||
$this->application_deployment_queue->addLogEntry("Skipping deployment to {$server->name}. Not in the same team?!");
|
$this->application_deployment_queue->addLogEntry("Skipping deployment to {$server->name}. Not in the same team?!");
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// ray('Deploying to additional destination: ', $server->name);
|
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
queue_application_deployment(
|
queue_application_deployment(
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
@@ -1445,6 +1378,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
private function check_git_if_build_needed()
|
private function check_git_if_build_needed()
|
||||||
{
|
{
|
||||||
|
if (is_object($this->source) && $this->source->getMorphClass() === \App\Models\GithubApp::class && $this->source->is_public === false) {
|
||||||
|
$repository = githubApi($this->source, "repos/{$this->customRepository}");
|
||||||
|
$data = data_get($repository, 'data');
|
||||||
|
$repository_project_id = data_get($data, 'id');
|
||||||
|
if (isset($repository_project_id)) {
|
||||||
|
if (blank($this->application->repository_project_id) || $this->application->repository_project_id !== $repository_project_id) {
|
||||||
|
$this->application->repository_project_id = $repository_project_id;
|
||||||
|
$this->application->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
$this->generate_git_import_commands();
|
$this->generate_git_import_commands();
|
||||||
$local_branch = $this->branch;
|
$local_branch = $this->branch;
|
||||||
if ($this->pull_request_id !== 0) {
|
if ($this->pull_request_id !== 0) {
|
||||||
@@ -1634,20 +1578,134 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
|
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function generate_coolify_env_variables(): Collection
|
||||||
|
{
|
||||||
|
$coolify_envs = collect([]);
|
||||||
|
$local_branch = $this->branch;
|
||||||
|
if ($this->pull_request_id !== 0) {
|
||||||
|
// Add SOURCE_COMMIT if not exists
|
||||||
|
if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
||||||
|
if (! is_null($this->commit)) {
|
||||||
|
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
||||||
|
} else {
|
||||||
|
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
||||||
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
||||||
|
$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()) {
|
||||||
|
$url = str($this->preview->fqdn)->replace('http://', '')->replace('https://', '');
|
||||||
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
||||||
|
$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->environment_variables_preview->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
|
||||||
|
$coolify_envs->put('COOLIFY_BRANCH', $local_branch);
|
||||||
|
}
|
||||||
|
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
||||||
|
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
|
||||||
|
}
|
||||||
|
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
||||||
|
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add_coolify_default_environment_variables($this->application, $coolify_envs, $this->application->environment_variables_preview);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Add SOURCE_COMMIT if not exists
|
||||||
|
if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
||||||
|
if (! is_null($this->commit)) {
|
||||||
|
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
||||||
|
} else {
|
||||||
|
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
||||||
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
||||||
|
$coolify_envs->put('COOLIFY_URL', $this->application->fqdn);
|
||||||
|
} else {
|
||||||
|
$coolify_envs->put('COOLIFY_FQDN', $this->application->fqdn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->application->environment_variables->where('key', 'COOLIFY_URL')->isEmpty()) {
|
||||||
|
$url = str($this->application->fqdn)->replace('http://', '')->replace('https://', '');
|
||||||
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
||||||
|
$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->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
|
||||||
|
$coolify_envs->put('COOLIFY_BRANCH', $local_branch);
|
||||||
|
}
|
||||||
|
if ($this->application->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
||||||
|
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
|
||||||
|
}
|
||||||
|
if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
||||||
|
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add_coolify_default_environment_variables($this->application, $coolify_envs, $this->application->environment_variables);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return $coolify_envs;
|
||||||
|
}
|
||||||
|
|
||||||
private function generate_env_variables()
|
private function generate_env_variables()
|
||||||
{
|
{
|
||||||
$this->env_args = collect([]);
|
$this->env_args = collect([]);
|
||||||
$this->env_args->put('SOURCE_COMMIT', $this->commit);
|
$this->env_args->put('SOURCE_COMMIT', $this->commit);
|
||||||
|
$coolify_envs = $this->generate_coolify_env_variables();
|
||||||
if ($this->pull_request_id === 0) {
|
if ($this->pull_request_id === 0) {
|
||||||
foreach ($this->application->build_environment_variables as $env) {
|
foreach ($this->application->build_environment_variables as $env) {
|
||||||
if (! is_null($env->real_value)) {
|
if (! is_null($env->real_value)) {
|
||||||
$this->env_args->put($env->key, $env->real_value);
|
$this->env_args->put($env->key, $env->real_value);
|
||||||
|
if (str($env->real_value)->startsWith('$')) {
|
||||||
|
$variable_key = str($env->real_value)->after('$');
|
||||||
|
if ($variable_key->startsWith('COOLIFY_')) {
|
||||||
|
$variable = $coolify_envs->get($variable_key->value());
|
||||||
|
if (filled($variable)) {
|
||||||
|
$this->env_args->prepend($variable, $variable_key->value());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$variable = $this->application->environment_variables()->where('key', $variable_key)->first();
|
||||||
|
if ($variable) {
|
||||||
|
$this->env_args->prepend($variable->real_value, $env->key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
foreach ($this->application->build_environment_variables_preview as $env) {
|
foreach ($this->application->build_environment_variables_preview as $env) {
|
||||||
if (! is_null($env->real_value)) {
|
if (! is_null($env->real_value)) {
|
||||||
$this->env_args->put($env->key, $env->real_value);
|
$this->env_args->put($env->key, $env->real_value);
|
||||||
|
if (str($env->real_value)->startsWith('$')) {
|
||||||
|
$variable_key = str($env->real_value)->after('$');
|
||||||
|
if ($variable_key->startsWith('COOLIFY_')) {
|
||||||
|
$variable = $coolify_envs->get($variable_key->value());
|
||||||
|
if (filled($variable)) {
|
||||||
|
$this->env_args->prepend($variable, $variable_key->value());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$variable = $this->application->environment_variables_preview()->where('key', $variable_key)->first();
|
||||||
|
if ($variable) {
|
||||||
|
$this->env_args->prepend($variable->real_value, $env->key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1672,25 +1730,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$labels = $labels->filter(function ($value, $key) {
|
$labels = $labels->filter(function ($value, $key) {
|
||||||
return ! Str::startsWith($value, 'coolify.');
|
return ! Str::startsWith($value, 'coolify.');
|
||||||
});
|
});
|
||||||
$found_caddy_labels = $labels->filter(function ($value, $key) {
|
|
||||||
return Str::startsWith($value, 'caddy_');
|
|
||||||
});
|
|
||||||
if ($found_caddy_labels->count() === 0) {
|
|
||||||
if ($this->pull_request_id !== 0) {
|
|
||||||
$domains = str(data_get($this->preview, 'fqdn'))->explode(',');
|
|
||||||
} else {
|
|
||||||
$domains = str(data_get($this->application, 'fqdn'))->explode(',');
|
|
||||||
}
|
|
||||||
$labels = $labels->merge(fqdnLabelsForCaddy(
|
|
||||||
network: $this->application->destination->network,
|
|
||||||
uuid: $this->application->uuid,
|
|
||||||
domains: $domains,
|
|
||||||
onlyPort: $onlyPort,
|
|
||||||
is_force_https_enabled: $this->application->isForceHttpsEnabled(),
|
|
||||||
is_gzip_enabled: $this->application->isGzipEnabled(),
|
|
||||||
is_stripprefix_enabled: $this->application->isStripprefixEnabled()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
$this->application->custom_labels = base64_encode($labels->implode("\n"));
|
$this->application->custom_labels = base64_encode($labels->implode("\n"));
|
||||||
$this->application->save();
|
$this->application->save();
|
||||||
} else {
|
} else {
|
||||||
@@ -1716,8 +1755,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
'save' => 'dockerfile_from_repo',
|
'save' => 'dockerfile_from_repo',
|
||||||
'ignore_errors' => true,
|
'ignore_errors' => true,
|
||||||
]);
|
]);
|
||||||
$dockerfile = collect(str($this->saved_outputs->get('dockerfile_from_repo'))->trim()->explode("\n"));
|
$this->application->parseHealthcheckFromDockerfile($this->saved_outputs->get('dockerfile_from_repo'));
|
||||||
$this->application->parseHealthcheckFromDockerfile($dockerfile);
|
}
|
||||||
|
$custom_network_aliases = [];
|
||||||
|
if (is_array($this->application->custom_network_aliases) && count($this->application->custom_network_aliases) > 0) {
|
||||||
|
$custom_network_aliases = $this->application->custom_network_aliases;
|
||||||
}
|
}
|
||||||
$docker_compose = [
|
$docker_compose = [
|
||||||
'services' => [
|
'services' => [
|
||||||
@@ -1728,9 +1770,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
'expose' => $ports,
|
'expose' => $ports,
|
||||||
'networks' => [
|
'networks' => [
|
||||||
$this->destination->network => [
|
$this->destination->network => [
|
||||||
'aliases' => [
|
'aliases' => array_merge(
|
||||||
$this->container_name,
|
[$this->container_name],
|
||||||
],
|
$custom_network_aliases
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'mem_limit' => $this->application->limits_memory,
|
'mem_limit' => $this->application->limits_memory,
|
||||||
@@ -2020,11 +2063,17 @@ LABEL coolify.deploymentId={$this->deployment_uuid}
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN rm -f /usr/share/nginx/html/nginx.conf
|
RUN rm -f /usr/share/nginx/html/nginx.conf
|
||||||
RUN rm -f /usr/share/nginx/html/Dockerfile
|
RUN rm -f /usr/share/nginx/html/Dockerfile
|
||||||
|
RUN rm -f /usr/share/nginx/html/docker-compose.yaml
|
||||||
|
RUN rm -f /usr/share/nginx/html/.env
|
||||||
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||||
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
|
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
|
||||||
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
|
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
|
||||||
} else {
|
} else {
|
||||||
$nginx_config = base64_encode(defaultNginxConfiguration());
|
if ($this->application->settings->is_spa) {
|
||||||
|
$nginx_config = base64_encode(defaultNginxConfiguration('spa'));
|
||||||
|
} else {
|
||||||
|
$nginx_config = base64_encode(defaultNginxConfiguration());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if ($this->application->build_pack === 'nixpacks') {
|
if ($this->application->build_pack === 'nixpacks') {
|
||||||
@@ -2091,7 +2140,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||||||
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
|
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
|
||||||
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
|
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
|
||||||
} else {
|
} else {
|
||||||
$nginx_config = base64_encode(defaultNginxConfiguration());
|
if ($this->application->settings->is_spa) {
|
||||||
|
$nginx_config = base64_encode(defaultNginxConfiguration('spa'));
|
||||||
|
} else {
|
||||||
|
$nginx_config = base64_encode(defaultNginxConfiguration());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
|
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
|
||||||
@@ -2200,43 +2253,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||||||
$this->application_deployment_queue->addLogEntry('Building docker image completed.');
|
$this->application_deployment_queue->addLogEntry('Building docker image completed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function graceful_shutdown_container(string $containerName, int $timeout = 300)
|
private function graceful_shutdown_container(string $containerName, int $timeout = 30)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$process = Process::timeout($timeout)->start("docker stop --time=$timeout $containerName");
|
$this->execute_remote_command(
|
||||||
|
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
|
||||||
$startTime = time();
|
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
|
||||||
while ($process->running()) {
|
);
|
||||||
if (time() - $startTime >= $timeout) {
|
} catch (Exception $error) {
|
||||||
$this->execute_remote_command(
|
|
||||||
["docker kill $containerName", 'hidden' => true, 'ignore_errors' => true]
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
usleep(100000);
|
|
||||||
}
|
|
||||||
|
|
||||||
$isRunning = $this->execute_remote_command(
|
|
||||||
["docker inspect -f '{{.State.Running}}' $containerName", 'hidden' => true, 'ignore_errors' => true]
|
|
||||||
) === 'true';
|
|
||||||
|
|
||||||
if ($isRunning) {
|
|
||||||
$this->execute_remote_command(
|
|
||||||
["docker kill $containerName", 'hidden' => true, 'ignore_errors' => true]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (\Exception $error) {
|
|
||||||
$this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr');
|
$this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->remove_container($containerName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function remove_container(string $containerName)
|
|
||||||
{
|
|
||||||
$this->execute_remote_command(
|
|
||||||
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function stop_running_container(bool $force = false)
|
private function stop_running_container(bool $force = false)
|
||||||
@@ -2281,7 +2307,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||||||
} else {
|
} else {
|
||||||
if ($this->use_build_server) {
|
if ($this->use_build_server) {
|
||||||
$this->execute_remote_command(
|
$this->execute_remote_command(
|
||||||
["{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --build -d", 'hidden' => true],
|
["{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
$this->execute_remote_command(
|
$this->execute_remote_command(
|
||||||
@@ -2408,20 +2434,21 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||||||
private function next(string $status)
|
private function next(string $status)
|
||||||
{
|
{
|
||||||
queue_next_deployment($this->application);
|
queue_next_deployment($this->application);
|
||||||
// If the deployment is cancelled by the user, don't update the status
|
|
||||||
if (
|
// Never allow changing status from FAILED or CANCELLED_BY_USER to anything else
|
||||||
$this->application_deployment_queue->status !== ApplicationDeploymentStatus::CANCELLED_BY_USER->value &&
|
|
||||||
$this->application_deployment_queue->status !== ApplicationDeploymentStatus::FAILED->value
|
|
||||||
) {
|
|
||||||
$this->application_deployment_queue->update([
|
|
||||||
'status' => $status,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
|
||||||
$this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview));
|
$this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->application_deployment_queue->update([
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
|
|||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("ghcr.io/coollabsio/coolify-helper")))\''], $this->server, false);
|
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
|
||||||
$containerIds = collect(json_decode($containers))->pluck('ID');
|
$containerIds = collect(json_decode($containers))->pluck('ID');
|
||||||
if ($containerIds->count() > 0) {
|
if ($containerIds->count() > 0) {
|
||||||
foreach ($containerIds as $containerId) {
|
foreach ($containerIds as $containerId) {
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public $timeout = 60;
|
||||||
|
|
||||||
public function __construct() {}
|
public function __construct() {}
|
||||||
|
|
||||||
public function middleware(): array
|
public function middleware(): array
|
||||||
{
|
{
|
||||||
return [(new WithoutOverlapping('cleanup-instance-stuffs'))->dontRelease()];
|
return [(new WithoutOverlapping('cleanup-instance-stuffs'))->expireAfter(60)->dontRelease()];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
|
|||||||
@@ -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,12 +384,23 @@ 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')) {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$url --gzip --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --gzip --archive > $this->backup_location";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (str($databaseWithCollections)->contains(':')) {
|
if (str($databaseWithCollections)->contains(':')) {
|
||||||
@@ -361,15 +413,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$commands[] = 'mkdir -p '.$this->backup_dir;
|
$commands[] = 'mkdir -p '.$this->backup_dir;
|
||||||
if ($collectionsToExclude->count() === 0) {
|
if ($collectionsToExclude->count() === 0) {
|
||||||
if (str($this->database->image)->startsWith('mongo:4')) {
|
if (str($this->database->image)->startsWith('mongo:4')) {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$url --db $databaseName --gzip --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --archive > $this->backup_location";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (str($this->database->image)->startsWith('mongo:4')) {
|
if (str($this->database->image)->startsWith('mongo:4')) {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$url --db $databaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,7 +442,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$commands[] = 'mkdir -p '.$this->backup_dir;
|
$commands[] = 'mkdir -p '.$this->backup_dir;
|
||||||
$backupCommand = 'docker exec';
|
$backupCommand = 'docker exec';
|
||||||
if ($this->postgres_password) {
|
if ($this->postgres_password) {
|
||||||
$backupCommand .= " -e PGPASSWORD=$this->postgres_password";
|
$backupCommand .= " -e PGPASSWORD=\"{$this->postgres_password}\"";
|
||||||
}
|
}
|
||||||
if ($this->backup->dump_all) {
|
if ($this->backup->dump_all) {
|
||||||
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
|
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
|
||||||
@@ -415,9 +467,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
try {
|
try {
|
||||||
$commands[] = 'mkdir -p '.$this->backup_dir;
|
$commands[] = 'mkdir -p '.$this->backup_dir;
|
||||||
if ($this->backup->dump_all) {
|
if ($this->backup->dump_all) {
|
||||||
$commands[] = "docker exec $this->container_name mysqldump -u root -p{$this->database->mysql_root_password} --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mysqldump -u root -p{$this->database->mysql_root_password} $database > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $database > $this->backup_location";
|
||||||
}
|
}
|
||||||
$this->backup_output = instant_remote_process($commands, $this->server);
|
$this->backup_output = instant_remote_process($commands, $this->server);
|
||||||
$this->backup_output = trim($this->backup_output);
|
$this->backup_output = trim($this->backup_output);
|
||||||
@@ -435,9 +487,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
try {
|
try {
|
||||||
$commands[] = 'mkdir -p '.$this->backup_dir;
|
$commands[] = 'mkdir -p '.$this->backup_dir;
|
||||||
if ($this->backup->dump_all) {
|
if ($this->backup->dump_all) {
|
||||||
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p{$this->database->mariadb_root_password} --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p{$this->database->mariadb_root_password} $database > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $database > $this->backup_location";
|
||||||
}
|
}
|
||||||
$this->backup_output = instant_remote_process($commands, $this->server);
|
$this->backup_output = instant_remote_process($commands, $this->server);
|
||||||
$this->backup_output = trim($this->backup_output);
|
$this->backup_output = trim($this->backup_output);
|
||||||
@@ -484,6 +536,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
$fullImageName = $this->getFullImageName();
|
$fullImageName = $this->getFullImageName();
|
||||||
|
|
||||||
|
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup->uuid}"], $this->server, false);
|
||||||
|
if (filled($containerExists)) {
|
||||||
|
instant_remote_process(["docker rm -f backup-of-{$this->backup->uuid}"], $this->server, false);
|
||||||
|
}
|
||||||
|
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
if ($this->database->name === 'coolify-db') {
|
if ($this->database->name === 'coolify-db') {
|
||||||
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file;
|
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file;
|
||||||
@@ -495,7 +552,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
} else {
|
} else {
|
||||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
|
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
|
||||||
}
|
}
|
||||||
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc config host add temporary {$endpoint} $key $secret";
|
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc config host add temporary {$endpoint} $key \"$secret\"";
|
||||||
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
||||||
instant_remote_process($commands, $this->server);
|
instant_remote_process($commands, $this->server);
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,8 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$persistentStorages = collect();
|
|
||||||
switch ($this->resource->type()) {
|
switch ($this->resource->type()) {
|
||||||
case 'application':
|
case 'application':
|
||||||
$persistentStorages = $this->resource?->persistentStorages()?->get();
|
|
||||||
StopApplication::run($this->resource, previewDeployments: true);
|
StopApplication::run($this->resource, previewDeployments: true);
|
||||||
break;
|
break;
|
||||||
case 'standalone-postgresql':
|
case 'standalone-postgresql':
|
||||||
@@ -56,44 +54,52 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
case 'standalone-keydb':
|
case 'standalone-keydb':
|
||||||
case 'standalone-dragonfly':
|
case 'standalone-dragonfly':
|
||||||
case 'standalone-clickhouse':
|
case 'standalone-clickhouse':
|
||||||
$persistentStorages = $this->resource?->persistentStorages()?->get();
|
|
||||||
StopDatabase::run($this->resource, true);
|
StopDatabase::run($this->resource, true);
|
||||||
break;
|
break;
|
||||||
case 'service':
|
case 'service':
|
||||||
StopService::run($this->resource, true);
|
StopService::run($this->resource, true);
|
||||||
DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks);
|
DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks);
|
||||||
break;
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->deleteVolumes && $this->resource->type() !== 'service') {
|
|
||||||
$this->resource?->delete_volumes($persistentStorages);
|
|
||||||
}
|
|
||||||
if ($this->deleteConfigurations) {
|
if ($this->deleteConfigurations) {
|
||||||
$this->resource?->delete_configurations();
|
$this->resource->deleteConfigurations();
|
||||||
}
|
}
|
||||||
|
if ($this->deleteVolumes) {
|
||||||
|
$this->resource->deleteVolumes();
|
||||||
|
$this->resource->persistentStorages()->delete();
|
||||||
|
}
|
||||||
|
$this->resource->fileStorages()->delete();
|
||||||
|
|
||||||
$isDatabase = $this->resource instanceof StandalonePostgresql
|
$isDatabase = $this->resource instanceof StandalonePostgresql
|
||||||
|| $this->resource instanceof StandaloneRedis
|
|| $this->resource instanceof StandaloneRedis
|
||||||
|| $this->resource instanceof StandaloneMongodb
|
|| $this->resource instanceof StandaloneMongodb
|
||||||
|| $this->resource instanceof StandaloneMysql
|
|| $this->resource instanceof StandaloneMysql
|
||||||
|| $this->resource instanceof StandaloneMariadb
|
|| $this->resource instanceof StandaloneMariadb
|
||||||
|| $this->resource instanceof StandaloneKeydb
|
|| $this->resource instanceof StandaloneKeydb
|
||||||
|| $this->resource instanceof StandaloneDragonfly
|
|| $this->resource instanceof StandaloneDragonfly
|
||||||
|| $this->resource instanceof StandaloneClickhouse;
|
|| $this->resource instanceof StandaloneClickhouse;
|
||||||
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
|
|
||||||
if (($this->dockerCleanup || $isDatabase) && $server) {
|
|
||||||
CleanupDocker::dispatch($server, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->deleteConnectedNetworks && ! $isDatabase) {
|
if ($isDatabase) {
|
||||||
$this->resource?->delete_connected_networks($this->resource->uuid);
|
$this->resource->sslCertificates()->delete();
|
||||||
|
$this->resource->scheduledBackups()->delete();
|
||||||
|
$this->resource->tags()->detach();
|
||||||
|
}
|
||||||
|
$this->resource->environment_variables()->delete();
|
||||||
|
|
||||||
|
if ($this->deleteConnectedNetworks && $this->resource->type() === 'application') {
|
||||||
|
$this->resource->deleteConnectedNetworks();
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
throw $e;
|
throw $e;
|
||||||
} finally {
|
} finally {
|
||||||
$this->resource->forceDelete();
|
$this->resource->forceDelete();
|
||||||
if ($this->dockerCleanup) {
|
if ($this->dockerCleanup) {
|
||||||
CleanupDocker::dispatch($server, true);
|
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
|
||||||
|
if ($server) {
|
||||||
|
CleanupDocker::dispatch($server, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Artisan::queue('cleanup:stucked-resources');
|
Artisan::queue('cleanup:stucked-resources');
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user