Merge branch 'next' into fix_breadcrumb
This commit is contained in:
commit
651e19fefe
1346 changed files with 111142 additions and 18825 deletions
41
.AI_INSTRUCTIONS_SYNC.md
Normal file
41
.AI_INSTRUCTIONS_SYNC.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# AI Instructions Synchronization Guide
|
||||
|
||||
**This file has moved!**
|
||||
|
||||
All AI documentation and synchronization guidelines are now in the `.ai/` directory.
|
||||
|
||||
## New Locations
|
||||
|
||||
- **Sync Guide**: [.ai/meta/sync-guide.md](.ai/meta/sync-guide.md)
|
||||
- **Maintaining Docs**: [.ai/meta/maintaining-docs.md](.ai/meta/maintaining-docs.md)
|
||||
- **Documentation Hub**: [.ai/README.md](.ai/README.md)
|
||||
|
||||
## Quick Overview
|
||||
|
||||
All AI instructions are now organized in `.ai/` directory:
|
||||
|
||||
```
|
||||
.ai/
|
||||
├── README.md # Navigation hub
|
||||
├── core/ # Project information
|
||||
├── development/ # Dev workflows
|
||||
├── patterns/ # Code patterns
|
||||
└── meta/ # Documentation guides
|
||||
```
|
||||
|
||||
### For AI Assistants
|
||||
|
||||
- **Claude Code**: Use `CLAUDE.md` (references `.ai/` files)
|
||||
- **Cursor IDE**: Use `.cursor/rules/coolify-ai-docs.mdc` (references `.ai/` files)
|
||||
- **All Tools**: Browse `.ai/` directory for detailed documentation
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Single Source of Truth**: Each piece of information exists in ONE file only
|
||||
2. **Cross-Reference**: Other files reference the source, don't duplicate
|
||||
3. **Organized by Topic**: Core, Development, Patterns, Meta
|
||||
4. **Version Consistency**: All versions in `.ai/core/technology-stack.md`
|
||||
|
||||
## For More Information
|
||||
|
||||
See [.ai/meta/sync-guide.md](.ai/meta/sync-guide.md) for complete synchronization guidelines and [.ai/meta/maintaining-docs.md](.ai/meta/maintaining-docs.md) for documentation maintenance instructions.
|
||||
148
.ai/README.md
Normal file
148
.ai/README.md
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# Coolify AI Documentation
|
||||
|
||||
Welcome to the Coolify AI documentation hub. This directory contains all AI assistant instructions organized by topic for easy navigation and maintenance.
|
||||
|
||||
## Quick Start
|
||||
|
||||
- **For Claude Code**: Start with [CLAUDE.md in root directory](../CLAUDE.md)
|
||||
- **For Cursor IDE**: Check `.cursor/rules/coolify-ai-docs.mdc` which references this directory
|
||||
- **For Other AI Tools**: Continue reading below
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
### 📚 Core Documentation
|
||||
Essential project information and architecture:
|
||||
|
||||
- **[Technology Stack](core/technology-stack.md)** - All versions, packages, and dependencies (Laravel 12.4.1, PHP 8.4.7, etc.)
|
||||
- **[Project Overview](core/project-overview.md)** - What Coolify is and how it works
|
||||
- **[Application Architecture](core/application-architecture.md)** - System design and component relationships
|
||||
- **[Deployment Architecture](core/deployment-architecture.md)** - How deployments work end-to-end, including Coolify Docker Compose extensions (custom fields)
|
||||
|
||||
### 💻 Development
|
||||
Day-to-day development practices:
|
||||
|
||||
- **[Workflow](development/development-workflow.md)** - Development setup, commands, and daily workflows
|
||||
- **[Testing Patterns](development/testing-patterns.md)** - How to write and run tests (Unit vs Feature, Docker requirements)
|
||||
- **[Laravel Boost](development/laravel-boost.md)** - Laravel-specific guidelines and best practices
|
||||
|
||||
### 🎨 Patterns
|
||||
Code patterns and best practices by domain:
|
||||
|
||||
- **[Database Patterns](patterns/database-patterns.md)** - Eloquent, migrations, relationships
|
||||
- **[Frontend Patterns](patterns/frontend-patterns.md)** - Livewire, Alpine.js, Tailwind CSS
|
||||
- **[Security Patterns](patterns/security-patterns.md)** - Authentication, authorization, security best practices
|
||||
- **[Form Components](patterns/form-components.md)** - Enhanced form components with authorization
|
||||
- **[API & Routing](patterns/api-and-routing.md)** - API design, routing conventions, REST patterns
|
||||
|
||||
### 📖 Meta
|
||||
Documentation about documentation:
|
||||
|
||||
- **[Maintaining Docs](meta/maintaining-docs.md)** - How to update and improve this documentation
|
||||
- **[Sync Guide](meta/sync-guide.md)** - Keeping documentation synchronized across tools
|
||||
|
||||
## Quick Decision Tree
|
||||
|
||||
**What do you need help with?**
|
||||
|
||||
### Running Commands
|
||||
→ [development/development-workflow.md](development/development-workflow.md)
|
||||
- Frontend: `npm run dev`, `npm run build`
|
||||
- Backend: `php artisan serve`, `php artisan migrate`
|
||||
- Tests: Docker for Feature tests, mocking for Unit tests
|
||||
- Code quality: `./vendor/bin/pint`, `./vendor/bin/phpstan`
|
||||
|
||||
### Writing Tests
|
||||
→ [development/testing-patterns.md](development/testing-patterns.md)
|
||||
- **Unit tests**: No database, use mocking, run outside Docker
|
||||
- **Feature tests**: Can use database, must run inside Docker
|
||||
- Command: `docker exec coolify php artisan test`
|
||||
|
||||
### Building UI
|
||||
→ [patterns/frontend-patterns.md](patterns/frontend-patterns.md) or [patterns/form-components.md](patterns/form-components.md)
|
||||
- Livewire components with server-side state
|
||||
- Alpine.js for client-side interactivity
|
||||
- Tailwind CSS 4.1.4 for styling
|
||||
- Form components with built-in authorization
|
||||
|
||||
### Database Work
|
||||
→ [patterns/database-patterns.md](patterns/database-patterns.md)
|
||||
- Eloquent ORM patterns
|
||||
- Migration best practices
|
||||
- Relationship definitions
|
||||
- Query optimization
|
||||
|
||||
### Security & Auth
|
||||
→ [patterns/security-patterns.md](patterns/security-patterns.md)
|
||||
- Team-based access control
|
||||
- Policy and gate patterns
|
||||
- Form authorization (canGate, canResource)
|
||||
- API security
|
||||
|
||||
### Laravel-Specific Questions
|
||||
→ [development/laravel-boost.md](development/laravel-boost.md)
|
||||
- Laravel 12 patterns
|
||||
- Livewire 3 best practices
|
||||
- Pest testing patterns
|
||||
- Laravel conventions
|
||||
|
||||
### Docker Compose Extensions
|
||||
→ [core/deployment-architecture.md](core/deployment-architecture.md#coolify-docker-compose-extensions)
|
||||
- Custom fields: `exclude_from_hc`, `content`, `isDirectory`
|
||||
- How to use inline file content
|
||||
- Health check exclusion patterns
|
||||
- Volume creation control
|
||||
|
||||
### Version Numbers
|
||||
→ [core/technology-stack.md](core/technology-stack.md)
|
||||
- **Single source of truth** for all version numbers
|
||||
- Don't duplicate versions elsewhere, reference this file
|
||||
|
||||
## Navigation Tips
|
||||
|
||||
1. **Start broad**: Begin with project-overview or ../CLAUDE.md
|
||||
2. **Get specific**: Navigate to topic-specific files for details
|
||||
3. **Cross-reference**: Files link to related topics
|
||||
4. **Single source**: Version numbers and critical data exist in ONE place only
|
||||
|
||||
## For AI Assistants
|
||||
|
||||
### Important Patterns to Follow
|
||||
|
||||
**Testing Commands:**
|
||||
- Unit tests: `./vendor/bin/pest tests/Unit` (no database, outside Docker)
|
||||
- Feature tests: `docker exec coolify php artisan test` (requires database, inside Docker)
|
||||
- NEVER run Feature tests outside Docker - they will fail with database connection errors
|
||||
|
||||
**Version Numbers:**
|
||||
- Always use exact versions from [technology-stack.md](core/technology-stack.md)
|
||||
- Laravel 12.4.1, PHP 8.4.7, Tailwind 4.1.4
|
||||
- Don't use "v12" or "8.4" - be precise
|
||||
|
||||
**Form Authorization:**
|
||||
- ALWAYS include `canGate` and `:canResource` on form components
|
||||
- See [form-components.md](patterns/form-components.md) for examples
|
||||
|
||||
**Livewire Components:**
|
||||
- MUST have exactly ONE root element
|
||||
- See [frontend-patterns.md](patterns/frontend-patterns.md) for details
|
||||
|
||||
**Code Style:**
|
||||
- Run `./vendor/bin/pint` before finalizing changes
|
||||
- Follow PSR-12 standards
|
||||
- Use PHP 8.4 features (constructor promotion, typed properties, etc.)
|
||||
|
||||
## Contributing
|
||||
|
||||
When updating documentation:
|
||||
1. Read [meta/maintaining-docs.md](meta/maintaining-docs.md)
|
||||
2. Follow the single source of truth principle
|
||||
3. Update cross-references when moving content
|
||||
4. Test all links work
|
||||
5. Run Pint on markdown files if applicable
|
||||
|
||||
## Questions?
|
||||
|
||||
- **Claude Code users**: Check [../CLAUDE.md](../CLAUDE.md) first
|
||||
- **Cursor IDE users**: Check `.cursor/rules/coolify-ai-docs.mdc`
|
||||
- **Documentation issues**: See [meta/maintaining-docs.md](meta/maintaining-docs.md)
|
||||
- **Sync issues**: See [meta/sync-guide.md](meta/sync-guide.md)
|
||||
612
.ai/core/application-architecture.md
Normal file
612
.ai/core/application-architecture.md
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
# 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 with request-level caching:
|
||||
|
||||
```php
|
||||
// ✅ CORRECT - Use cached methods (request-level cache via once())
|
||||
$applications = Application::ownedByCurrentTeamCached();
|
||||
$servers = Server::ownedByCurrentTeamCached();
|
||||
|
||||
// ✅ CORRECT - Filter cached collection in memory
|
||||
$activeServers = Server::ownedByCurrentTeamCached()->where('is_active', true);
|
||||
|
||||
// Only use query builder when you need eager loading or fresh data
|
||||
$projects = Project::ownedByCurrentTeam()->with('environments')->get();
|
||||
```
|
||||
|
||||
See [Database Patterns](.ai/patterns/database-patterns.md#request-level-caching-with-ownedbycurrentteamcached) for full documentation.
|
||||
|
||||
### **Configuration Inheritance**
|
||||
|
||||
Environment variables cascade from:
|
||||
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
|
||||
|
||||
## Container Status Monitoring System
|
||||
|
||||
### **Overview**
|
||||
|
||||
Container health status is monitored and updated through **multiple independent paths**. When modifying status logic, **ALL paths must be updated** to ensure consistency.
|
||||
|
||||
### **Critical Implementation Locations**
|
||||
|
||||
#### **1. SSH-Based Status Updates (Scheduled)**
|
||||
**File**: [app/Actions/Docker/GetContainersStatus.php](mdc:app/Actions/Docker/GetContainersStatus.php)
|
||||
**Method**: `aggregateApplicationStatus()` (lines 487-540)
|
||||
**Trigger**: Scheduled job or manual refresh
|
||||
**Frequency**: Every minute (via `ServerCheckJob`)
|
||||
|
||||
**Status Aggregation Logic**:
|
||||
```php
|
||||
// Tracks multiple status flags
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasUnknown = false; // ⚠️ CRITICAL: Must track unknown
|
||||
$hasExited = false;
|
||||
// ... more states
|
||||
|
||||
// Priority: restarting > degraded > running (unhealthy > unknown > healthy)
|
||||
if ($hasRunning) {
|
||||
if ($hasUnhealthy) return 'running (unhealthy)';
|
||||
elseif ($hasUnknown) return 'running (unknown)';
|
||||
else return 'running (healthy)';
|
||||
}
|
||||
```
|
||||
|
||||
#### **2. Sentinel-Based Status Updates (Real-time)**
|
||||
**File**: [app/Jobs/PushServerUpdateJob.php](mdc:app/Jobs/PushServerUpdateJob.php)
|
||||
**Method**: `aggregateMultiContainerStatuses()` (lines 269-298)
|
||||
**Trigger**: Sentinel push updates from remote servers
|
||||
**Frequency**: Every ~30 seconds (real-time)
|
||||
|
||||
**Status Aggregation Logic**:
|
||||
```php
|
||||
// ⚠️ MUST match GetContainersStatus logic
|
||||
$hasRunning = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasUnknown = false; // ⚠️ CRITICAL: Added to fix bug
|
||||
|
||||
foreach ($relevantStatuses as $status) {
|
||||
if (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) $hasUnhealthy = true;
|
||||
if (str($status)->contains('unknown')) $hasUnknown = true; // ⚠️ CRITICAL
|
||||
}
|
||||
}
|
||||
|
||||
// Priority: unhealthy > unknown > healthy
|
||||
if ($hasRunning) {
|
||||
if ($hasUnhealthy) $aggregatedStatus = 'running (unhealthy)';
|
||||
elseif ($hasUnknown) $aggregatedStatus = 'running (unknown)';
|
||||
else $aggregatedStatus = 'running (healthy)';
|
||||
}
|
||||
```
|
||||
|
||||
#### **3. Multi-Server Status Aggregation**
|
||||
**File**: [app/Actions/Shared/ComplexStatusCheck.php](mdc:app/Actions/Shared/ComplexStatusCheck.php)
|
||||
**Method**: `resource()` (lines 48-210)
|
||||
**Purpose**: Aggregates status across multiple servers for applications
|
||||
**Used by**: Applications with multiple destinations
|
||||
|
||||
**Key Features**:
|
||||
- Aggregates statuses from main + additional servers
|
||||
- Handles excluded containers (`:excluded` suffix)
|
||||
- Calculates overall application health from all containers
|
||||
|
||||
**Status Format with Excluded Containers**:
|
||||
```php
|
||||
// When all containers excluded from health checks:
|
||||
return 'running:unhealthy:excluded'; // Container running but unhealthy, monitoring disabled
|
||||
return 'running:unknown:excluded'; // Container running, health unknown, monitoring disabled
|
||||
return 'running:healthy:excluded'; // Container running and healthy, monitoring disabled
|
||||
return 'degraded:excluded'; // Some containers down, monitoring disabled
|
||||
return 'exited:excluded'; // All containers stopped, monitoring disabled
|
||||
```
|
||||
|
||||
#### **4. Service-Level Status Aggregation**
|
||||
**File**: [app/Models/Service.php](mdc:app/Models/Service.php)
|
||||
**Method**: `complexStatus()` (lines 176-288)
|
||||
**Purpose**: Aggregates status for multi-container services
|
||||
**Used by**: Docker Compose services
|
||||
|
||||
**Status Calculation**:
|
||||
```php
|
||||
// Aggregates status from all service applications and databases
|
||||
// Handles excluded containers separately
|
||||
// Returns status with :excluded suffix when all containers excluded
|
||||
if (!$hasNonExcluded && $complexStatus === null && $complexHealth === null) {
|
||||
// All services excluded - calculate from excluded containers
|
||||
return "{$excludedStatus}:excluded";
|
||||
}
|
||||
```
|
||||
|
||||
### **Status Flow Diagram**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Container Status Sources │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌─────────────────┐ ┌──────────────┐
|
||||
│ SSH-Based │ │ Sentinel-Based │ │ Multi-Server │
|
||||
│ (Scheduled) │ │ (Real-time) │ │ Aggregation │
|
||||
├───────────────┤ ├─────────────────┤ ├──────────────┤
|
||||
│ ServerCheck │ │ PushServerUp- │ │ ComplexStatus│
|
||||
│ Job │ │ dateJob │ │ Check │
|
||||
│ │ │ │ │ │
|
||||
│ Every ~1min │ │ Every ~30sec │ │ On demand │
|
||||
└───────┬───────┘ └────────┬────────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└────────────────────┼────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ Application/Service │
|
||||
│ Status Property │
|
||||
└───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ UI Display (Livewire) │
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
### **Status Priority System**
|
||||
|
||||
All status aggregation locations **MUST** follow the same priority:
|
||||
|
||||
**For Running Containers**:
|
||||
1. **unhealthy** - Container has failing health checks
|
||||
2. **unknown** - Container health status cannot be determined
|
||||
3. **healthy** - Container is healthy
|
||||
|
||||
**For Non-Running States**:
|
||||
1. **restarting** → `degraded (unhealthy)`
|
||||
2. **running + exited** → `degraded (unhealthy)`
|
||||
3. **dead/removing** → `degraded (unhealthy)`
|
||||
4. **paused** → `paused`
|
||||
5. **created/starting** → `starting`
|
||||
6. **exited** → `exited (unhealthy)`
|
||||
|
||||
### **Excluded Containers**
|
||||
|
||||
When containers have `exclude_from_hc: true` flag or `restart: no`:
|
||||
|
||||
**Behavior**:
|
||||
- Status is still calculated from container state
|
||||
- `:excluded` suffix is appended to indicate monitoring disabled
|
||||
- UI shows "(Monitoring Disabled)" badge
|
||||
- Action buttons respect the actual container state
|
||||
|
||||
**Format**: `{actual-status}:excluded`
|
||||
**Examples**: `running:unknown:excluded`, `degraded:excluded`, `exited:excluded`
|
||||
|
||||
**All-Excluded Scenario**:
|
||||
When ALL containers are excluded from health checks:
|
||||
- All three status update paths (PushServerUpdateJob, GetContainersStatus, ComplexStatusCheck) **MUST** calculate status from excluded containers
|
||||
- Status is returned with `:excluded` suffix (e.g., `running:healthy:excluded`)
|
||||
- **NEVER** skip status updates - always calculate from excluded containers
|
||||
- This ensures consistent status regardless of which update mechanism runs
|
||||
- Shared logic is in `app/Traits/CalculatesExcludedStatus.php`
|
||||
|
||||
### **Important Notes for Developers**
|
||||
|
||||
✅ **Container Status Aggregation Service**:
|
||||
|
||||
The container status aggregation logic is centralized in `App\Services\ContainerStatusAggregator`.
|
||||
|
||||
**Status Format Standard**:
|
||||
- **Backend/Storage**: Colon format (`running:healthy`, `degraded:unhealthy`)
|
||||
- **UI/Display**: Transform to human format (`Running (Healthy)`, `Degraded (Unhealthy)`)
|
||||
|
||||
1. **Using the ContainerStatusAggregator Service**:
|
||||
- Import `App\Services\ContainerStatusAggregator` in any class needing status aggregation
|
||||
- Two methods available:
|
||||
- `aggregateFromStrings(Collection $statusStrings, int $maxRestartCount = 0)` - For pre-formatted status strings
|
||||
- `aggregateFromContainers(Collection $containers, int $maxRestartCount = 0)` - For raw Docker container objects
|
||||
- Returns colon format: `running:healthy`, `degraded:unhealthy`, etc.
|
||||
- Automatically handles crash loop detection via `$maxRestartCount` parameter
|
||||
|
||||
2. **State Machine Priority** (handled by service):
|
||||
- Restarting → `degraded:unhealthy` (highest priority)
|
||||
- Crash loop (exited with restarts) → `degraded:unhealthy`
|
||||
- Mixed state (running + exited) → `degraded:unhealthy`
|
||||
- Running → `running:unhealthy` / `running:unknown` / `running:healthy`
|
||||
- Dead/Removing → `degraded:unhealthy`
|
||||
- Paused → `paused:unknown`
|
||||
- Starting/Created → `starting:unknown`
|
||||
- Exited → `exited:unhealthy` (lowest priority)
|
||||
|
||||
3. **Test both update paths**:
|
||||
- Run unit tests: `./vendor/bin/pest tests/Unit/ContainerStatusAggregatorTest.php`
|
||||
- Run integration tests: `./vendor/bin/pest tests/Unit/`
|
||||
- Test SSH updates (manual refresh)
|
||||
- Test Sentinel updates (wait 30 seconds)
|
||||
|
||||
4. **Handle excluded containers**:
|
||||
- All containers excluded (`exclude_from_hc: true`) - Use `CalculatesExcludedStatus` trait
|
||||
- Mixed excluded/non-excluded containers - Filter then use `ContainerStatusAggregator`
|
||||
- Containers with `restart: no` - Treated same as `exclude_from_hc: true`
|
||||
|
||||
5. **Use shared trait for excluded containers**:
|
||||
- Import `App\Traits\CalculatesExcludedStatus` in status calculation classes
|
||||
- Use `getExcludedContainersFromDockerCompose()` to parse exclusions
|
||||
- Use `calculateExcludedStatus()` for full Docker inspect objects (ComplexStatusCheck)
|
||||
- Use `calculateExcludedStatusFromStrings()` for status strings (PushServerUpdateJob, GetContainersStatus)
|
||||
|
||||
### **Related Tests**
|
||||
|
||||
- **[tests/Unit/ContainerStatusAggregatorTest.php](mdc:tests/Unit/ContainerStatusAggregatorTest.php)**: Core state machine logic (42 comprehensive tests)
|
||||
- **[tests/Unit/ContainerHealthStatusTest.php](mdc:tests/Unit/ContainerHealthStatusTest.php)**: Health status aggregation integration
|
||||
- **[tests/Unit/PushServerUpdateJobStatusAggregationTest.php](mdc:tests/Unit/PushServerUpdateJobStatusAggregationTest.php)**: Sentinel update logic
|
||||
- **[tests/Unit/ExcludeFromHealthCheckTest.php](mdc:tests/Unit/ExcludeFromHealthCheckTest.php)**: Excluded container handling
|
||||
|
||||
### **Common Bugs to Avoid**
|
||||
|
||||
✅ **Prevented by ContainerStatusAggregator Service**:
|
||||
- ❌ **Old Bug**: Forgetting to track `$hasUnknown` flag → ✅ Now centralized in service
|
||||
- ❌ **Old Bug**: Inconsistent priority across paths → ✅ Single source of truth
|
||||
- ❌ **Old Bug**: Forgetting to update all 4 locations → ✅ Only one location to update
|
||||
|
||||
**Still Relevant**:
|
||||
|
||||
❌ **Bug**: Forgetting to filter excluded containers before aggregation
|
||||
✅ **Fix**: Always use `CalculatesExcludedStatus` trait to filter before calling `ContainerStatusAggregator`
|
||||
|
||||
❌ **Bug**: Not passing `$maxRestartCount` for crash loop detection
|
||||
✅ **Fix**: Calculate max restart count from containers and pass to `aggregateFromStrings()`/`aggregateFromContainers()`
|
||||
|
||||
❌ **Bug**: Not handling excluded containers with `:excluded` suffix
|
||||
✅ **Fix**: Check for `:excluded` suffix in UI logic and button visibility
|
||||
666
.ai/core/deployment-architecture.md
Normal file
666
.ai/core/deployment-architecture.md
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
# 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
|
||||
|
||||
### Docker Build Cache Preservation
|
||||
|
||||
Coolify provides settings to preserve Docker build cache across deployments, addressing cache invalidation issues.
|
||||
|
||||
#### The Problem
|
||||
|
||||
By default, Coolify injects `ARG` statements into user Dockerfiles for build-time variables. This breaks Docker's cache mechanism because:
|
||||
1. **ARG declarations invalidate cache** - Any change in ARG values after the `ARG` instruction invalidates all subsequent layers
|
||||
2. **SOURCE_COMMIT changes every commit** - Causes full rebuilds even when code changes are minimal
|
||||
|
||||
#### Application Settings
|
||||
|
||||
Two toggles in **Advanced Settings** control this behavior:
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `inject_build_args_to_dockerfile` | `true` | Controls whether Coolify adds `ARG` statements to Dockerfile |
|
||||
| `include_source_commit_in_build` | `false` | Controls whether `SOURCE_COMMIT` is included in build context |
|
||||
|
||||
**Database columns:** `application_settings.inject_build_args_to_dockerfile`, `application_settings.include_source_commit_in_build`
|
||||
|
||||
#### Buildpack Coverage
|
||||
|
||||
| Build Pack | ARG Injection | Method |
|
||||
|------------|---------------|--------|
|
||||
| **Dockerfile** | ✅ Yes | `add_build_env_variables_to_dockerfile()` |
|
||||
| **Docker Compose** (with `build:`) | ✅ Yes | `modify_dockerfiles_for_compose()` |
|
||||
| **PR Deployments** (Dockerfile only) | ✅ Yes | `add_build_env_variables_to_dockerfile()` |
|
||||
| **Nixpacks** | ❌ No | Generates its own Dockerfile internally |
|
||||
| **Static** | ❌ No | Uses internal Dockerfile |
|
||||
| **Docker Image** | ❌ No | No build phase |
|
||||
|
||||
#### How It Works
|
||||
|
||||
**When `inject_build_args_to_dockerfile` is enabled (default):**
|
||||
```dockerfile
|
||||
# Coolify modifies your Dockerfile to add:
|
||||
FROM node:20
|
||||
ARG MY_VAR=value
|
||||
ARG COOLIFY_URL=...
|
||||
ARG SOURCE_COMMIT=abc123 # (if include_source_commit_in_build is true)
|
||||
# ... rest of your Dockerfile
|
||||
```
|
||||
|
||||
**When `inject_build_args_to_dockerfile` is disabled:**
|
||||
- Coolify does NOT modify the Dockerfile
|
||||
- `--build-arg` flags are still passed (harmless without matching `ARG` in Dockerfile)
|
||||
- User must manually add `ARG` statements for any build-time variables they need
|
||||
|
||||
**When `include_source_commit_in_build` is disabled (default):**
|
||||
- `SOURCE_COMMIT` is NOT included in build-time variables
|
||||
- `SOURCE_COMMIT` is still available at **runtime** (in container environment)
|
||||
- Docker cache preserved across different commits
|
||||
|
||||
#### Recommended Configuration
|
||||
|
||||
| Use Case | inject_build_args | include_source_commit | Cache Behavior |
|
||||
|----------|-------------------|----------------------|----------------|
|
||||
| Maximum cache preservation | `false` | `false` | Best cache retention |
|
||||
| Need build-time vars, no commit | `true` | `false` | Cache breaks on var changes |
|
||||
| Need commit at build-time | `true` | `true` | Cache breaks every commit |
|
||||
| Manual ARG management | `false` | `true` | Cache preserved (no ARG in Dockerfile) |
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
**Files:**
|
||||
- `app/Jobs/ApplicationDeploymentJob.php`:
|
||||
- `set_coolify_variables()` - Conditionally adds SOURCE_COMMIT to Docker build context based on `include_source_commit_in_build` setting
|
||||
- `generate_coolify_env_variables(bool $forBuildTime)` - Distinguishes build-time vs. runtime variables; excludes cache-busting variables like SOURCE_COMMIT from build context unless explicitly enabled
|
||||
- `generate_env_variables()` - Populates `$this->env_args` with build-time ARG values, respecting `include_source_commit_in_build` toggle
|
||||
- `add_build_env_variables_to_dockerfile()` - Injects ARG statements into Dockerfiles after FROM instructions; skips injection if `inject_build_args_to_dockerfile` is disabled
|
||||
- `modify_dockerfiles_for_compose()` - Applies ARG injection to Docker Compose service Dockerfiles; respects `inject_build_args_to_dockerfile` toggle
|
||||
- `app/Models/ApplicationSetting.php` - Defines `inject_build_args_to_dockerfile` and `include_source_commit_in_build` boolean properties
|
||||
- `app/Livewire/Project/Application/Advanced.php` - Livewire component providing UI bindings for cache preservation toggles
|
||||
- `resources/views/livewire/project/application/advanced.blade.php` - Checkbox UI elements for user-facing toggles
|
||||
|
||||
**Note:** Docker Compose services without a `build:` section (image-only) are automatically skipped.
|
||||
|
||||
### Runtime Optimization
|
||||
- **Container resource** limits
|
||||
- **Auto-scaling** based on metrics
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## Coolify Docker Compose Extensions
|
||||
|
||||
Coolify extends standard Docker Compose with custom fields (often called "magic fields") that provide Coolify-specific functionality. These extensions are processed during deployment and stripped before sending the final compose file to Docker, maintaining full compatibility with Docker's compose specification.
|
||||
|
||||
### Overview
|
||||
|
||||
**Why Custom Fields?**
|
||||
- Enable Coolify-specific features without breaking Docker Compose compatibility
|
||||
- Simplify configuration by embedding content directly in compose files
|
||||
- Allow fine-grained control over health check monitoring
|
||||
- Reduce external file dependencies
|
||||
|
||||
**Processing Flow:**
|
||||
1. User defines compose file with custom fields
|
||||
2. Coolify parses and processes custom fields (creates files, stores settings)
|
||||
3. Custom fields are stripped from final compose sent to Docker
|
||||
4. Docker receives standard, valid compose file
|
||||
|
||||
### Service-Level Extensions
|
||||
|
||||
#### `exclude_from_hc`
|
||||
|
||||
**Type:** Boolean
|
||||
**Default:** `false`
|
||||
**Purpose:** Exclude specific services from health check monitoring while still showing their status
|
||||
|
||||
**Example Usage:**
|
||||
```yaml
|
||||
services:
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
exclude_from_hc: true # Don't monitor this service's health
|
||||
|
||||
backup:
|
||||
image: postgres:16
|
||||
exclude_from_hc: true # Backup containers don't need monitoring
|
||||
restart: always
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Container status is still calculated from Docker state (running, exited, etc.)
|
||||
- Status displays with `:excluded` suffix (e.g., `running:healthy:excluded`)
|
||||
- UI shows "Monitoring Disabled" indicator
|
||||
- Functionally equivalent to `restart: no` for health check purposes
|
||||
- See [Container Status with All Excluded](application-architecture.md#container-status-when-all-containers-excluded) for detailed status handling
|
||||
|
||||
**Use Cases:**
|
||||
- Sidecar containers (watchtower, log collectors)
|
||||
- Backup/maintenance containers
|
||||
- One-time initialization containers
|
||||
- Containers that intentionally restart frequently
|
||||
|
||||
**Implementation:**
|
||||
- Parsed: `bootstrap/helpers/parsers.php`
|
||||
- Status logic: `app/Traits/CalculatesExcludedStatus.php`
|
||||
- Validation: `tests/Unit/ExcludeFromHealthCheckTest.php`
|
||||
|
||||
### Volume-Level Extensions
|
||||
|
||||
Volume extensions only work with **long syntax** (array/object format), not short syntax (string format).
|
||||
|
||||
#### `content`
|
||||
|
||||
**Type:** String (supports multiline with `|` or `>`)
|
||||
**Purpose:** Embed file content directly in compose file for automatic creation during deployment
|
||||
|
||||
**Example Usage:**
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: node:20
|
||||
volumes:
|
||||
# Inline entrypoint script
|
||||
- type: bind
|
||||
source: ./entrypoint.sh
|
||||
target: /app/entrypoint.sh
|
||||
content: |
|
||||
#!/bin/sh
|
||||
set -e
|
||||
echo "Starting application..."
|
||||
npm run migrate
|
||||
exec "$@"
|
||||
|
||||
# Configuration file with environment variables
|
||||
- type: bind
|
||||
source: ./config.xml
|
||||
target: /etc/app/config.xml
|
||||
content: |
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<config>
|
||||
<database>
|
||||
<host>${DB_HOST}</host>
|
||||
<port>${DB_PORT}</port>
|
||||
</database>
|
||||
</config>
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Content is written to the host at `source` path before container starts
|
||||
- File is created with mode `644` (readable by all, writable by owner)
|
||||
- Environment variables in content are interpolated at deployment time
|
||||
- Content is stored in `LocalFileVolume` model (encrypted at rest)
|
||||
- Original `docker_compose_raw` retains content for editing
|
||||
|
||||
**Use Cases:**
|
||||
- Entrypoint scripts
|
||||
- Configuration files
|
||||
- Environment-specific settings
|
||||
- Small initialization scripts
|
||||
- Templates that require dynamic content
|
||||
|
||||
**Limitations:**
|
||||
- Not suitable for large files (use git repo or external storage instead)
|
||||
- Binary files not supported
|
||||
- Changes require redeployment
|
||||
|
||||
**Real-World Examples:**
|
||||
- `templates/compose/traccar.yaml` - XML configuration file
|
||||
- `templates/compose/supabase.yaml` - Multiple config files
|
||||
- `templates/compose/chaskiq.yaml` - Entrypoint script
|
||||
|
||||
**Implementation:**
|
||||
- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `content` field extraction)
|
||||
- Storage: `app/Models/LocalFileVolume.php`
|
||||
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
|
||||
|
||||
#### `is_directory` / `isDirectory`
|
||||
|
||||
**Type:** Boolean
|
||||
**Default:** `true` (if neither `content` nor explicit flag provided)
|
||||
**Purpose:** Indicate whether bind mount source should be created as directory or file
|
||||
|
||||
**Example Usage:**
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
volumes:
|
||||
# Explicit file
|
||||
- type: bind
|
||||
source: ./config.json
|
||||
target: /app/config.json
|
||||
is_directory: false # Create as file
|
||||
|
||||
# Explicit directory
|
||||
- type: bind
|
||||
source: ./logs
|
||||
target: /var/log/app
|
||||
is_directory: true # Create as directory
|
||||
|
||||
# Auto-detected as file (has content)
|
||||
- type: bind
|
||||
source: ./script.sh
|
||||
target: /entrypoint.sh
|
||||
content: |
|
||||
#!/bin/sh
|
||||
echo "Hello"
|
||||
# is_directory: false implied by content presence
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- If `is_directory: true` → Creates directory with `mkdir -p`
|
||||
- If `is_directory: false` → Creates empty file with `touch`
|
||||
- If `content` provided → Implies `is_directory: false`
|
||||
- If neither specified → Defaults to `true` (directory)
|
||||
|
||||
**Naming Conventions:**
|
||||
- `is_directory` (snake_case) - **Preferred**, consistent with PHP/Laravel conventions
|
||||
- `isDirectory` (camelCase) - **Legacy support**, both work identically
|
||||
|
||||
**Use Cases:**
|
||||
- Disambiguating files vs directories when no content provided
|
||||
- Ensuring correct bind mount type for Docker
|
||||
- Pre-creating mount points before container starts
|
||||
|
||||
**Implementation:**
|
||||
- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `is_directory`/`isDirectory` field extraction)
|
||||
- Storage: `app/Models/LocalFileVolume.php` (`is_directory` column)
|
||||
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
|
||||
|
||||
### Custom Field Stripping
|
||||
|
||||
**Function:** `stripCoolifyCustomFields()` in `bootstrap/helpers/docker.php`
|
||||
|
||||
All custom fields are removed before the compose file is sent to Docker. This happens in two contexts:
|
||||
|
||||
**1. Validation (User-Triggered)**
|
||||
```php
|
||||
// In validateComposeFile() - Edit Docker Compose modal
|
||||
$yaml_compose = Yaml::parse($compose);
|
||||
$yaml_compose = stripCoolifyCustomFields($yaml_compose); // Strip custom fields
|
||||
// Send to docker compose config for validation
|
||||
```
|
||||
|
||||
**2. Deployment (Automatic)**
|
||||
```php
|
||||
// In Service::parse() - During deployment
|
||||
$docker_compose = parseCompose($docker_compose_raw);
|
||||
// Custom fields are processed and then stripped
|
||||
// Final compose sent to Docker has no custom fields
|
||||
```
|
||||
|
||||
**What Gets Stripped:**
|
||||
- Service-level: `exclude_from_hc`
|
||||
- Volume-level: `content`, `isDirectory`, `is_directory`
|
||||
|
||||
**What's Preserved:**
|
||||
- All standard Docker Compose fields
|
||||
- Environment variables
|
||||
- Standard volume definitions (after custom fields removed)
|
||||
|
||||
### Important Notes
|
||||
|
||||
#### Long vs Short Volume Syntax
|
||||
|
||||
**✅ Long Syntax (Works with Custom Fields):**
|
||||
```yaml
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./data
|
||||
target: /app/data
|
||||
content: "Hello" # ✅ Custom fields work here
|
||||
```
|
||||
|
||||
**❌ Short Syntax (Custom Fields Ignored):**
|
||||
```yaml
|
||||
volumes:
|
||||
- "./data:/app/data" # ❌ Cannot add custom fields to strings
|
||||
```
|
||||
|
||||
#### Docker Compose Compatibility
|
||||
|
||||
Custom fields are **Coolify-specific** and won't work with standalone `docker compose` CLI:
|
||||
|
||||
```bash
|
||||
# ❌ Won't work - Docker doesn't recognize custom fields
|
||||
docker compose -f compose.yaml up
|
||||
|
||||
# ✅ Works - Use Coolify's deployment (strips custom fields first)
|
||||
# Deploy through Coolify UI or API
|
||||
```
|
||||
|
||||
#### Editing Custom Fields
|
||||
|
||||
When editing in "Edit Docker Compose" modal:
|
||||
- Custom fields are preserved in the editor
|
||||
- "Validate" button strips them temporarily for Docker validation
|
||||
- "Save" button preserves them in `docker_compose_raw`
|
||||
- They're processed again on next deployment
|
||||
|
||||
### Template Examples
|
||||
|
||||
See these templates for real-world usage:
|
||||
|
||||
**Service Exclusions:**
|
||||
- `templates/compose/budibase.yaml` - Excludes watchtower from monitoring
|
||||
- `templates/compose/pgbackweb.yaml` - Excludes backup service
|
||||
- `templates/compose/elasticsearch-with-kibana.yaml` - Excludes elasticsearch
|
||||
|
||||
**Inline Content:**
|
||||
- `templates/compose/traccar.yaml` - XML configuration (multiline)
|
||||
- `templates/compose/supabase.yaml` - Multiple config files
|
||||
- `templates/compose/searxng.yaml` - Settings file
|
||||
- `templates/compose/invoice-ninja.yaml` - Nginx config
|
||||
|
||||
**Directory Flags:**
|
||||
- `templates/compose/paperless.yaml` - Explicit directory creation
|
||||
|
||||
### Testing
|
||||
|
||||
**Unit Tests:**
|
||||
- `tests/Unit/StripCoolifyCustomFieldsTest.php` - Custom field stripping logic
|
||||
- `tests/Unit/ExcludeFromHealthCheckTest.php` - Health check exclusion behavior
|
||||
- `tests/Unit/ContainerStatusAggregatorTest.php` - Status aggregation with exclusions
|
||||
|
||||
**Test Coverage:**
|
||||
- ✅ All custom fields (exclude_from_hc, content, isDirectory, is_directory)
|
||||
- ✅ Multiline content (YAML `|` syntax)
|
||||
- ✅ Short vs long volume syntax
|
||||
- ✅ Field stripping without data loss
|
||||
- ✅ Standard Docker Compose field preservation
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Project Overview
|
||||
|
||||
## What is Coolify?
|
||||
|
|
@ -1,23 +1,19 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Technology Stack
|
||||
|
||||
Complete technology stack, dependencies, and infrastructure components.
|
||||
|
||||
## Backend Framework
|
||||
|
||||
### **Laravel 12.4.1** (PHP Framework)
|
||||
- **Location**: [composer.json](mdc:composer.json)
|
||||
- **Purpose**: Core application framework
|
||||
- **Key Features**:
|
||||
- **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)
|
||||
### **PHP 8.4.7**
|
||||
- **Requirement**: `^8.4` in composer.json
|
||||
- **Features Used**:
|
||||
- Typed properties and return types
|
||||
- Attributes for validation and configuration
|
||||
|
|
@ -28,11 +24,11 @@ ## Frontend Stack
|
|||
|
||||
### **Livewire 3.5.20** (Primary Frontend Framework)
|
||||
- **Purpose**: Server-side rendering with reactive components
|
||||
- **Location**: [app/Livewire/](mdc:app/Livewire/)
|
||||
- **Location**: `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
|
||||
- Dashboard - Main interface
|
||||
- ActivityMonitor - Real-time monitoring
|
||||
- MonacoEditor - Code editor
|
||||
|
||||
### **Alpine.js** (Client-Side Interactivity)
|
||||
- **Purpose**: Lightweight JavaScript for DOM manipulation
|
||||
|
|
@ -40,8 +36,7 @@ ### **Alpine.js** (Client-Side Interactivity)
|
|||
- **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)
|
||||
- **Configuration**: `postcss.config.cjs`
|
||||
- **Extensions**:
|
||||
- `@tailwindcss/forms` - Form styling
|
||||
- `@tailwindcss/typography` - Content typography
|
||||
|
|
@ -57,24 +52,24 @@ ## Database & Caching
|
|||
### **PostgreSQL 15** (Primary Database)
|
||||
- **Purpose**: Main application data storage
|
||||
- **Features**: JSONB support, advanced indexing
|
||||
- **Models**: [app/Models/](mdc:app/Models/)
|
||||
- **Models**: `app/Models/`
|
||||
|
||||
### **Redis 7** (Caching & Real-time)
|
||||
- **Purpose**:
|
||||
- **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)
|
||||
- **PostgreSQL**: StandalonePostgresql
|
||||
- **MySQL**: StandaloneMysql
|
||||
- **MariaDB**: StandaloneMariadb
|
||||
- **MongoDB**: StandaloneMongodb
|
||||
- **Redis**: StandaloneRedis
|
||||
- **KeyDB**: StandaloneKeydb
|
||||
- **Dragonfly**: StandaloneDragonfly
|
||||
- **ClickHouse**: StandaloneClickhouse
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
|
|
@ -101,7 +96,7 @@ ### **Laravel Horizon 5.30.3**
|
|||
|
||||
### **Queue System**
|
||||
- **Backend**: Redis-based queues
|
||||
- **Jobs**: [app/Jobs/](mdc:app/Jobs/)
|
||||
- **Jobs**: `app/Jobs/`
|
||||
- **Processing**: Background deployment and monitoring tasks
|
||||
|
||||
## Development Tools
|
||||
|
|
@ -130,21 +125,21 @@ ### **Git Providers**
|
|||
- **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)
|
||||
- **AWS S3**: league/flysystem-aws-s3-v3
|
||||
- **SFTP**: league/flysystem-sftp-v3
|
||||
- **Local Storage**: File system integration
|
||||
|
||||
### **Notification Services**
|
||||
- **Email**: [resend/resend-laravel](mdc:composer.json)
|
||||
- **Email**: resend/resend-laravel
|
||||
- **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)
|
||||
- **Sentry**: sentry/sentry-laravel - Error tracking
|
||||
- **Laravel Ray**: spatie/laravel-ray - Debug tool
|
||||
- **Activity Log**: spatie/laravel-activitylog
|
||||
|
||||
## DevOps & Infrastructure
|
||||
|
||||
|
|
@ -181,9 +176,9 @@ ### **Monaco Editor**
|
|||
## 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)
|
||||
- **Documentation**: openapi.json (373KB)
|
||||
- **Generator**: zircote/swagger-php
|
||||
- **API Routes**: `routes/api.php`
|
||||
|
||||
### **WebSocket Communication**
|
||||
- **Laravel Echo**: Real-time event broadcasting
|
||||
|
|
@ -192,7 +187,7 @@ ### **WebSocket Communication**
|
|||
|
||||
## Package Management
|
||||
|
||||
### **PHP Dependencies** ([composer.json](mdc:composer.json))
|
||||
### **PHP Dependencies** (composer.json)
|
||||
```json
|
||||
{
|
||||
"require": {
|
||||
|
|
@ -205,7 +200,7 @@ ### **PHP Dependencies** ([composer.json](mdc:composer.json))
|
|||
}
|
||||
```
|
||||
|
||||
### **JavaScript Dependencies** ([package.json](mdc:package.json))
|
||||
### **JavaScript Dependencies** (package.json)
|
||||
```json
|
||||
{
|
||||
"devDependencies": {
|
||||
|
|
@ -223,15 +218,15 @@ ### **JavaScript Dependencies** ([package.json](mdc:package.json))
|
|||
## 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
|
||||
- **vite.config.js**: Frontend build setup
|
||||
- **postcss.config.cjs**: CSS processing
|
||||
- **rector.php**: PHP refactoring rules
|
||||
- **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
|
||||
- **phpunit.xml**: Unit test configuration
|
||||
- **phpunit.dusk.xml**: Browser test configuration
|
||||
- **tests/Pest.php**: Pest testing setup
|
||||
|
||||
## Version Requirements
|
||||
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Development Workflow
|
||||
|
||||
## Development Environment Setup
|
||||
402
.ai/development/laravel-boost.md
Normal file
402
.ai/development/laravel-boost.md
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.7
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/horizon (HORIZON) - v5
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/sanctum (SANCTUM) - v4
|
||||
- laravel/socialite (SOCIALITE) - v5
|
||||
- livewire/livewire (LIVEWIRE) - v3
|
||||
- laravel/dusk (DUSK) - v8
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/telescope (TELESCOPE) - v5
|
||||
- pestphp/pest (PEST) - v3
|
||||
- phpunit/phpunit (PHPUNIT) - v11
|
||||
- rector/rector (RECTOR) - v2
|
||||
- laravel-echo (ECHO) - v2
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
- vue (VUE) - v3
|
||||
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure - don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
## Documentation Files
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Database
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
### Controllers & Validation
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
### Queues
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
### Authentication & Authorization
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
### URL Generation
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
### Configuration
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
### Testing
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version specific documentation.
|
||||
- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
|
||||
- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that.
|
||||
|
||||
### Laravel 10 Structure
|
||||
- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
|
||||
- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
|
||||
- Middleware registration happens in `app/Http/Kernel.php`
|
||||
- Exception handling is in `app/Exceptions/Handler.php`
|
||||
- Console commands and schedule register in `app/Console/Kernel.php`
|
||||
- Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
## Livewire Core
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
|
||||
|
||||
## Livewire Best Practices
|
||||
- Livewire components require a single root element.
|
||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
||||
- Add `wire:key` in loops:
|
||||
|
||||
```blade
|
||||
@foreach ($items as $item)
|
||||
<div wire:key="item-{{ $item->id }}">
|
||||
{{ $item->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1)
|
||||
->assertSee(1)
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== livewire/v3 rules ===
|
||||
|
||||
## Livewire 3
|
||||
|
||||
### Key Changes From Livewire 2
|
||||
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
||||
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
|
||||
|
||||
### New Directives
|
||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
||||
|
||||
### Alpine
|
||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
||||
|
||||
### Lifecycle Hooks
|
||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
||||
|
||||
<code-snippet name="livewire:load example" lang="js">
|
||||
document.addEventListener('livewire:init', function () {
|
||||
Livewire.hook('request', ({ fail }) => {
|
||||
if (fail && fail.status === 419) {
|
||||
alert('Your session expired');
|
||||
}
|
||||
});
|
||||
|
||||
Livewire.hook('message.failed', (message, component) => {
|
||||
console.error(message);
|
||||
});
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
- Pest tests look and behave like this:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `php artisan test`.
|
||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### Pest Assertions
|
||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Mocking
|
||||
- Mocking can be very helpful when appropriate.
|
||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### Datasets
|
||||
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
## Tailwind Core
|
||||
|
||||
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
|
||||
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
|
||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||
|
||||
### Spacing
|
||||
- When listing items, use gap utilities for spacing, don't use margins.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Dark Mode
|
||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||
|
||||
|
||||
=== tailwindcss/v4 rules ===
|
||||
|
||||
## Tailwind 4
|
||||
|
||||
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||
|
||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff"
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Replaced Utilities
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
||||
- Opacity values are still numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------+--------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||
</laravel-boost-guidelines>
|
||||
|
|
@ -1,14 +1,56 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Testing Architecture & Patterns
|
||||
|
||||
> **Cross-Reference**: These detailed testing patterns align with the testing guidelines in **[CLAUDE.md](mdc:CLAUDE.md)**. Both documents share the same core principles about Docker execution and mocking preferences.
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions.
|
||||
|
||||
### Test Execution Rules
|
||||
|
||||
**CRITICAL**: Tests are categorized by database dependency:
|
||||
|
||||
#### Unit Tests (`tests/Unit/`)
|
||||
- **MUST NOT** use database connections
|
||||
- **MUST** use mocking for models and external dependencies
|
||||
- **CAN** run outside Docker: `./vendor/bin/pest tests/Unit`
|
||||
- Purpose: Test isolated logic, helper functions, and business rules
|
||||
|
||||
#### Feature Tests (`tests/Feature/`)
|
||||
- **MAY** use database connections (factories, migrations, models)
|
||||
- **MUST** run inside Docker container: `docker exec coolify php artisan test`
|
||||
- **MUST** use `RefreshDatabase` trait if touching database
|
||||
- Purpose: Test API endpoints, workflows, and integration scenarios
|
||||
|
||||
**Rule of thumb**: If your test needs `Server::factory()->create()` or any database operation, it's a Feature test and MUST run in Docker.
|
||||
|
||||
### Prefer Mocking Over Database
|
||||
|
||||
When writing tests, always prefer mocking over real database operations:
|
||||
|
||||
```php
|
||||
// ❌ BAD: Unit test using database
|
||||
it('extracts custom commands', function () {
|
||||
$server = Server::factory()->create(['ip' => '1.2.3.4']);
|
||||
$commands = extract_custom_proxy_commands($server, $yaml);
|
||||
expect($commands)->toBeArray();
|
||||
});
|
||||
|
||||
// ✅ GOOD: Unit test using mocking
|
||||
it('extracts custom commands', function () {
|
||||
$server = Mockery::mock('App\Models\Server');
|
||||
$server->shouldReceive('proxyType')->andReturn('traefik');
|
||||
$commands = extract_custom_proxy_commands($server, $yaml);
|
||||
expect($commands)->toBeArray();
|
||||
});
|
||||
```
|
||||
|
||||
**Design principles for testable code:**
|
||||
- Use dependency injection instead of global state
|
||||
- Create interfaces for external dependencies (SSH, Docker, etc.)
|
||||
- Separate business logic from data persistence
|
||||
- Make functions accept interfaces instead of concrete models when possible
|
||||
|
||||
## Testing Framework Stack
|
||||
|
||||
### Core Testing Tools
|
||||
172
.ai/meta/maintaining-docs.md
Normal file
172
.ai/meta/maintaining-docs.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Maintaining AI Documentation
|
||||
|
||||
Guidelines for creating and maintaining AI documentation to ensure consistency and effectiveness across all AI tools (Claude Code, Cursor IDE, etc.).
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
All AI documentation lives in the `.ai/` directory with the following structure:
|
||||
|
||||
```
|
||||
.ai/
|
||||
├── README.md # Navigation hub
|
||||
├── core/ # Core project information
|
||||
├── development/ # Development practices
|
||||
├── patterns/ # Code patterns and best practices
|
||||
└── meta/ # Documentation maintenance guides
|
||||
```
|
||||
|
||||
> **Note**: `CLAUDE.md` is in the repository root, not in the `.ai/` directory.
|
||||
|
||||
## Required File Structure
|
||||
|
||||
When creating new documentation files:
|
||||
|
||||
```markdown
|
||||
# Title
|
||||
|
||||
Brief description of what this document covers.
|
||||
|
||||
## Section 1
|
||||
|
||||
- **Main Points in Bold**
|
||||
- Sub-points with details
|
||||
- Examples and explanations
|
||||
|
||||
## Section 2
|
||||
|
||||
### Subsection
|
||||
|
||||
Content with code examples:
|
||||
|
||||
```language
|
||||
// ✅ DO: Show good examples
|
||||
const goodExample = true;
|
||||
|
||||
// ❌ DON'T: Show anti-patterns
|
||||
const badExample = false;
|
||||
```
|
||||
```
|
||||
|
||||
## File References
|
||||
|
||||
- Use relative paths: `See [technology-stack.md](../core/technology-stack.md)`
|
||||
- For code references: `` `app/Models/Application.php` ``
|
||||
- Keep links working across different tools
|
||||
|
||||
## Content Guidelines
|
||||
|
||||
### DO:
|
||||
- Start with high-level overview
|
||||
- Include specific, actionable requirements
|
||||
- Show examples of correct implementation
|
||||
- Reference existing code when possible
|
||||
- Keep documentation DRY by cross-referencing
|
||||
- Use bullet points for clarity
|
||||
- Include both DO and DON'T examples
|
||||
|
||||
### DON'T:
|
||||
- Create theoretical examples when real code exists
|
||||
- Duplicate content across multiple files
|
||||
- Use tool-specific formatting that won't work elsewhere
|
||||
- Make assumptions about versions - specify exact versions
|
||||
|
||||
## Rule Improvement Triggers
|
||||
|
||||
Update documentation when you notice:
|
||||
- New code patterns not covered by existing docs
|
||||
- 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
|
||||
|
||||
When updating documentation:
|
||||
1. Compare new code with existing rules
|
||||
2. Identify patterns that should be standardized
|
||||
3. Look for references to external documentation
|
||||
4. Check for consistent error handling patterns
|
||||
5. Monitor test patterns and coverage
|
||||
|
||||
## Rule Updates
|
||||
|
||||
### Add New Documentation When:
|
||||
- A new technology/pattern is used in 3+ files
|
||||
- Common bugs could be prevented by documentation
|
||||
- Code reviews repeatedly mention the same feedback
|
||||
- New security or performance patterns emerge
|
||||
|
||||
### Modify Existing Documentation When:
|
||||
- Better examples exist in the codebase
|
||||
- Additional edge cases are discovered
|
||||
- Related documentation has been updated
|
||||
- Implementation details have changed
|
||||
|
||||
## Quality Checks
|
||||
|
||||
Before committing documentation changes:
|
||||
- [ ] Documentation is actionable and specific
|
||||
- [ ] Examples come from actual code
|
||||
- [ ] References are up to date
|
||||
- [ ] Patterns are consistently enforced
|
||||
- [ ] Cross-references work correctly
|
||||
- [ ] Version numbers are exact and current
|
||||
|
||||
## Continuous Improvement
|
||||
|
||||
- Monitor code review comments
|
||||
- Track common development questions
|
||||
- Update docs after major refactors
|
||||
- Add links to relevant documentation
|
||||
- Cross-reference related docs
|
||||
|
||||
## Deprecation
|
||||
|
||||
When patterns become outdated:
|
||||
1. Mark outdated patterns as deprecated
|
||||
2. Remove docs that no longer apply
|
||||
3. Update references to deprecated patterns
|
||||
4. Document migration paths for old patterns
|
||||
|
||||
## Synchronization
|
||||
|
||||
### Single Source of Truth
|
||||
- Each piece of information should exist in exactly ONE location
|
||||
- Other files should reference the source, not duplicate it
|
||||
- Example: Version numbers live in `core/technology-stack.md`, other files reference it
|
||||
|
||||
### Cross-Tool Compatibility
|
||||
- **CLAUDE.md**: Main instructions for Claude Code users (references `.ai/` files)
|
||||
- **.cursor/rules/**: Single master file pointing to `.ai/` documentation
|
||||
- **Both tools**: Should get same information from `.ai/` directory
|
||||
|
||||
### When to Update What
|
||||
|
||||
**Version Changes** (Laravel, PHP, packages):
|
||||
1. Update `core/technology-stack.md` (single source)
|
||||
2. Verify CLAUDE.md references it correctly
|
||||
3. No other files should duplicate version numbers
|
||||
|
||||
**Workflow Changes** (commands, setup):
|
||||
1. Update `development/workflow.md`
|
||||
2. Ensure CLAUDE.md quick reference is updated
|
||||
3. Verify all cross-references work
|
||||
|
||||
**Pattern Changes** (how to write code):
|
||||
1. Update appropriate file in `patterns/`
|
||||
2. Add/update examples from real codebase
|
||||
3. Cross-reference from related docs
|
||||
|
||||
## Documentation Files
|
||||
|
||||
Keep documentation files only when explicitly needed. Don't create docs that merely describe obvious functionality - the code itself should be clear.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
When making breaking changes to documentation structure:
|
||||
1. Update this maintaining-docs.md file
|
||||
2. Update `.ai/README.md` navigation
|
||||
3. Update CLAUDE.md references
|
||||
4. Update `.cursor/rules/coolify-ai-docs.mdc`
|
||||
5. Test all cross-references still work
|
||||
6. Document the changes in sync-guide.md
|
||||
214
.ai/meta/sync-guide.md
Normal file
214
.ai/meta/sync-guide.md
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
# AI Instructions Synchronization Guide
|
||||
|
||||
This document explains how AI instructions are organized and synchronized across different AI tools used with Coolify.
|
||||
|
||||
## Overview
|
||||
|
||||
Coolify maintains AI instructions with a **single source of truth** approach:
|
||||
|
||||
1. **CLAUDE.md** - Main entry point for Claude Code (references `.ai/` directory)
|
||||
2. **.cursor/rules/coolify-ai-docs.mdc** - Master reference file for Cursor IDE (references `.ai/` directory)
|
||||
3. **.ai/** - Single source of truth containing all detailed documentation
|
||||
|
||||
All AI tools (Claude Code, Cursor IDE, etc.) reference the same `.ai/` directory to ensure consistency.
|
||||
|
||||
## Structure
|
||||
|
||||
### CLAUDE.md (Root Directory)
|
||||
- **Purpose**: Entry point for Claude Code with quick-reference guide
|
||||
- **Format**: Single markdown file
|
||||
- **Includes**:
|
||||
- Quick-reference development commands
|
||||
- High-level architecture overview
|
||||
- Essential patterns and guidelines
|
||||
- References to detailed `.ai/` documentation
|
||||
|
||||
### .cursor/rules/coolify-ai-docs.mdc
|
||||
- **Purpose**: Master reference file for Cursor IDE
|
||||
- **Format**: Single .mdc file with frontmatter
|
||||
- **Content**: Quick decision tree and references to `.ai/` directory
|
||||
- **Note**: Replaces all previous topic-specific .mdc files
|
||||
|
||||
### .ai/ Directory (Single Source of Truth)
|
||||
- **Purpose**: All detailed, topic-specific documentation
|
||||
- **Format**: Organized markdown files by category
|
||||
- **Structure**:
|
||||
```
|
||||
.ai/
|
||||
├── README.md # Navigation hub
|
||||
├── core/ # Project information
|
||||
│ ├── technology-stack.md # Version numbers (SINGLE SOURCE OF TRUTH)
|
||||
│ ├── project-overview.md
|
||||
│ ├── application-architecture.md
|
||||
│ └── deployment-architecture.md
|
||||
├── development/ # Development practices
|
||||
│ ├── development-workflow.md
|
||||
│ ├── testing-patterns.md
|
||||
│ └── laravel-boost.md
|
||||
├── patterns/ # Code patterns
|
||||
│ ├── database-patterns.md
|
||||
│ ├── frontend-patterns.md
|
||||
│ ├── security-patterns.md
|
||||
│ ├── form-components.md
|
||||
│ └── api-and-routing.md
|
||||
└── meta/ # Documentation guides
|
||||
├── maintaining-docs.md
|
||||
└── sync-guide.md (this file)
|
||||
```
|
||||
- **Used by**: All AI tools through CLAUDE.md or coolify-ai-docs.mdc
|
||||
|
||||
## Cross-References
|
||||
|
||||
All systems reference the `.ai/` directory as the source of truth:
|
||||
|
||||
- **CLAUDE.md** → references `.ai/` files for detailed documentation
|
||||
- **.cursor/rules/coolify-ai-docs.mdc** → references `.ai/` files for detailed documentation
|
||||
- **.ai/README.md** → provides navigation to all documentation
|
||||
|
||||
## Maintaining Consistency
|
||||
|
||||
### 1. Core Principles (MUST be consistent)
|
||||
|
||||
These are defined ONCE in `.ai/core/technology-stack.md`:
|
||||
- Laravel version (currently Laravel 12.4.1)
|
||||
- PHP version (8.4.7)
|
||||
- All package versions (Livewire 3.5.20, Tailwind 4.1.4, etc.)
|
||||
|
||||
**Exception**: CLAUDE.md is permitted to show essential version numbers as a quick reference for convenience. These must stay synchronized with `technology-stack.md`. When updating versions, update both locations.
|
||||
|
||||
Other critical patterns defined in `.ai/`:
|
||||
- Testing execution rules (Docker for Feature tests, mocking for Unit tests)
|
||||
- Security patterns and authorization requirements
|
||||
- Code style requirements (Pint, PSR-12)
|
||||
|
||||
### 2. Where to Make Changes
|
||||
|
||||
**For version numbers** (Laravel, PHP, packages):
|
||||
1. Update `.ai/core/technology-stack.md` (single source of truth)
|
||||
2. Update CLAUDE.md quick reference section (essential versions only)
|
||||
3. Verify both files stay synchronized
|
||||
4. Never duplicate version numbers in other locations
|
||||
|
||||
**For workflow changes** (how to run commands, development setup):
|
||||
1. Update `.ai/development/development-workflow.md`
|
||||
2. Update quick reference in CLAUDE.md if needed
|
||||
3. Verify `.cursor/rules/coolify-ai-docs.mdc` references are correct
|
||||
|
||||
**For architectural patterns** (how code should be structured):
|
||||
1. Update appropriate file in `.ai/core/`
|
||||
2. Add cross-references from related docs
|
||||
3. Update CLAUDE.md if it needs to highlight this pattern
|
||||
|
||||
**For code patterns** (how to write code):
|
||||
1. Update appropriate file in `.ai/patterns/`
|
||||
2. Add examples from real codebase
|
||||
3. Cross-reference from related docs
|
||||
|
||||
**For testing patterns**:
|
||||
1. Update `.ai/development/testing-patterns.md`
|
||||
2. Ensure CLAUDE.md testing section references it
|
||||
|
||||
### 3. Update Checklist
|
||||
|
||||
When making significant changes:
|
||||
|
||||
- [ ] Identify if change affects core principles (version numbers, critical patterns)
|
||||
- [ ] Update primary location in `.ai/` directory
|
||||
- [ ] Check if CLAUDE.md needs quick-reference update
|
||||
- [ ] Verify `.cursor/rules/coolify-ai-docs.mdc` references are still accurate
|
||||
- [ ] Update cross-references in related `.ai/` files
|
||||
- [ ] Verify all relative paths work correctly
|
||||
- [ ] Test links in markdown files
|
||||
- [ ] Run: `./vendor/bin/pint` on modified files (if applicable)
|
||||
|
||||
### 4. Common Inconsistencies to Watch
|
||||
|
||||
- **Version numbers**: Should ONLY exist in `.ai/core/technology-stack.md`
|
||||
- **Testing instructions**: Docker execution requirements must be consistent
|
||||
- **File paths**: Ensure relative paths work from their location
|
||||
- **Command syntax**: Docker commands, artisan commands must be accurate
|
||||
- **Cross-references**: Links must point to current file locations
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
/
|
||||
├── CLAUDE.md # Claude Code entry point
|
||||
├── .AI_INSTRUCTIONS_SYNC.md # Redirect to this file
|
||||
├── .cursor/
|
||||
│ └── rules/
|
||||
│ └── coolify-ai-docs.mdc # Cursor IDE master reference
|
||||
└── .ai/ # SINGLE SOURCE OF TRUTH
|
||||
├── README.md # Navigation hub
|
||||
├── core/ # Project information
|
||||
├── development/ # Development practices
|
||||
├── patterns/ # Code patterns
|
||||
└── meta/ # Documentation guides
|
||||
```
|
||||
|
||||
## Recent Updates
|
||||
|
||||
### 2025-11-18 - Documentation Consolidation
|
||||
- ✅ Consolidated all documentation into `.ai/` directory
|
||||
- ✅ Created single source of truth for version numbers
|
||||
- ✅ Reduced CLAUDE.md from 719 to 319 lines
|
||||
- ✅ Replaced 11 .cursor/rules/*.mdc files with single coolify-ai-docs.mdc
|
||||
- ✅ Organized by topic: core/, development/, patterns/, meta/
|
||||
- ✅ Standardized version numbers (Laravel 12.4.1, PHP 8.4.7, Tailwind 4.1.4)
|
||||
- ✅ Created comprehensive navigation with .ai/README.md
|
||||
|
||||
### 2025-10-07
|
||||
- ✅ Added cross-references between CLAUDE.md and .cursor/rules/
|
||||
- ✅ Synchronized Laravel version (12) across all files
|
||||
- ✅ Added comprehensive testing execution rules (Docker for Feature tests)
|
||||
- ✅ Added test design philosophy (prefer mocking over database)
|
||||
- ✅ Fixed inconsistencies in testing documentation
|
||||
|
||||
## Maintenance Commands
|
||||
|
||||
```bash
|
||||
# Check for version inconsistencies (should only be in technology-stack.md)
|
||||
# Note: CLAUDE.md is allowed to show quick reference versions
|
||||
grep -r "Laravel 12" .ai/ CLAUDE.md .cursor/rules/coolify-ai-docs.mdc
|
||||
grep -r "PHP 8.4" .ai/ CLAUDE.md .cursor/rules/coolify-ai-docs.mdc
|
||||
|
||||
# Check for broken cross-references to old .mdc files
|
||||
grep -r "\.cursor/rules/.*\.mdc" .ai/ CLAUDE.md
|
||||
|
||||
# Format all documentation
|
||||
./vendor/bin/pint CLAUDE.md .ai/**/*.md
|
||||
|
||||
# Search for specific patterns across all docs
|
||||
grep -r "pattern_to_check" CLAUDE.md .ai/ .cursor/rules/
|
||||
|
||||
# Verify all markdown links work (from repository root)
|
||||
find .ai -name "*.md" -exec grep -H "\[.*\](.*)" {} \;
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When contributing documentation:
|
||||
|
||||
1. **Check `.ai/` directory** for existing documentation
|
||||
2. **Update `.ai/` files** - this is the single source of truth
|
||||
3. **Use cross-references** - never duplicate content
|
||||
4. **Update CLAUDE.md** if adding critical quick-reference information
|
||||
5. **Verify `.cursor/rules/coolify-ai-docs.mdc`** still references correctly
|
||||
6. **Test all links** work from their respective locations
|
||||
7. **Update this sync-guide.md** if changing organizational structure
|
||||
8. **Verify consistency** before submitting PR
|
||||
|
||||
## Questions?
|
||||
|
||||
If unsure about where to document something:
|
||||
|
||||
- **Version numbers** → `.ai/core/technology-stack.md` (ONLY location)
|
||||
- **Quick reference / commands** → CLAUDE.md + `.ai/development/development-workflow.md`
|
||||
- **Detailed patterns / examples** → `.ai/patterns/[topic].md`
|
||||
- **Architecture / concepts** → `.ai/core/[topic].md`
|
||||
- **Development practices** → `.ai/development/[topic].md`
|
||||
- **Documentation guides** → `.ai/meta/[topic].md`
|
||||
|
||||
**Golden Rule**: Each piece of information exists in ONE location in `.ai/`, other files reference it.
|
||||
|
||||
When in doubt, prefer detailed documentation in `.ai/` and lightweight references in CLAUDE.md and coolify-ai-docs.mdc.
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify API & Routing Architecture
|
||||
|
||||
## Routing Structure
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Database Architecture & Patterns
|
||||
|
||||
## Database Strategy
|
||||
|
|
@ -142,6 +137,29 @@ ### Base Model Structure
|
|||
- **Soft deletes** for audit trails
|
||||
- **Activity logging** with Spatie package
|
||||
|
||||
### **CRITICAL: Mass Assignment Protection**
|
||||
**When adding new database columns, you MUST update the model's `$fillable` array.** Without this, Laravel will silently ignore mass assignment operations like `Model::create()` or `$model->update()`.
|
||||
|
||||
**Checklist for new columns:**
|
||||
1. ✅ Create migration file
|
||||
2. ✅ Run migration
|
||||
3. ✅ **Add column to model's `$fillable` array**
|
||||
4. ✅ Update any Livewire components that sync this property
|
||||
5. ✅ Test that the column can be read and written
|
||||
|
||||
**Example:**
|
||||
```php
|
||||
class Server extends BaseModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'ip',
|
||||
'port',
|
||||
'is_validating', // ← MUST add new columns here
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Relationship Patterns
|
||||
```php
|
||||
// Typical relationship structure in Application model
|
||||
|
|
@ -225,6 +243,59 @@ ### Database Indexes
|
|||
- **Composite indexes** for common queries
|
||||
- **Unique constraints** for business rules
|
||||
|
||||
### Request-Level Caching with ownedByCurrentTeamCached()
|
||||
|
||||
Many models have both `ownedByCurrentTeam()` (returns query builder) and `ownedByCurrentTeamCached()` (returns cached collection). **Always prefer the cached version** to avoid duplicate database queries within the same request.
|
||||
|
||||
**Models with cached methods available:**
|
||||
- `Server`, `PrivateKey`, `Project`
|
||||
- `Application`
|
||||
- `StandalonePostgresql`, `StandaloneMysql`, `StandaloneRedis`, `StandaloneMariadb`, `StandaloneMongodb`, `StandaloneKeydb`, `StandaloneDragonfly`, `StandaloneClickhouse`
|
||||
- `Service`, `ServiceApplication`, `ServiceDatabase`
|
||||
|
||||
**Usage patterns:**
|
||||
```php
|
||||
// ✅ CORRECT - Uses request-level cache (via Laravel's once() helper)
|
||||
$servers = Server::ownedByCurrentTeamCached();
|
||||
|
||||
// ❌ AVOID - Makes a new database query each time
|
||||
$servers = Server::ownedByCurrentTeam()->get();
|
||||
|
||||
// ✅ CORRECT - Filter cached collection in memory
|
||||
$activeServers = Server::ownedByCurrentTeamCached()->where('is_active', true);
|
||||
$server = Server::ownedByCurrentTeamCached()->firstWhere('id', $serverId);
|
||||
$serverIds = Server::ownedByCurrentTeamCached()->pluck('id');
|
||||
|
||||
// ❌ AVOID - Making filtered database queries when data is already cached
|
||||
$activeServers = Server::ownedByCurrentTeam()->where('is_active', true)->get();
|
||||
```
|
||||
|
||||
**When to use which:**
|
||||
- `ownedByCurrentTeamCached()` - **Default choice** for reading team data
|
||||
- `ownedByCurrentTeam()` - Only when you need to chain query builder methods that can't be done on collections (like `with()` for eager loading), or when you explicitly need a fresh database query
|
||||
|
||||
**Implementation pattern for new models:**
|
||||
```php
|
||||
/**
|
||||
* Get query builder for resources owned by current team.
|
||||
* If you need all resources without further query chaining, use ownedByCurrentTeamCached() instead.
|
||||
*/
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return self::whereTeamId(currentTeam()->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all resources owned by current team (cached for request duration).
|
||||
*/
|
||||
public static function ownedByCurrentTeamCached()
|
||||
{
|
||||
return once(function () {
|
||||
return self::ownedByCurrentTeam()->get();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Data Consistency Patterns
|
||||
|
||||
### Database Transactions
|
||||
447
.ai/patterns/form-components.md
Normal file
447
.ai/patterns/form-components.md
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
|
||||
# Enhanced Form Components with Authorization
|
||||
|
||||
## Overview
|
||||
|
||||
Coolify's form components now feature **built-in authorization** that automatically handles permission-based UI control, dramatically reducing code duplication and improving security consistency.
|
||||
|
||||
## Enhanced Components
|
||||
|
||||
All form components now support the `canGate` authorization system:
|
||||
|
||||
- **[Input.php](mdc:app/View/Components/Forms/Input.php)** - Text, password, and other input fields
|
||||
- **[Select.php](mdc:app/View/Components/Forms/Select.php)** - Dropdown selection components
|
||||
- **[Textarea.php](mdc:app/View/Components/Forms/Textarea.php)** - Multi-line text areas
|
||||
- **[Checkbox.php](mdc:app/View/Components/Forms/Checkbox.php)** - Boolean toggle components
|
||||
- **[Button.php](mdc:app/View/Components/Forms/Button.php)** - Action buttons
|
||||
|
||||
## Authorization Parameters
|
||||
|
||||
### Core Parameters
|
||||
```php
|
||||
public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete'
|
||||
public mixed $canResource = null; // Resource model instance to check against
|
||||
public bool $autoDisable = true; // Automatically disable if no permission
|
||||
```
|
||||
|
||||
### How It Works
|
||||
```php
|
||||
// Automatic authorization logic in each component
|
||||
if ($this->canGate && $this->canResource && $this->autoDisable) {
|
||||
$hasPermission = Gate::allows($this->canGate, $this->canResource);
|
||||
|
||||
if (! $hasPermission) {
|
||||
$this->disabled = true;
|
||||
// For Checkbox: also sets $this->instantSave = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### ✅ Recommended: Single Line Pattern
|
||||
|
||||
**Before (Verbose, 6+ lines per element):**
|
||||
```html
|
||||
@can('update', $application)
|
||||
<x-forms.input id="application.name" label="Name" />
|
||||
<x-forms.checkbox instantSave id="application.settings.is_static" label="Static Site" />
|
||||
<x-forms.button type="submit">Save</x-forms.button>
|
||||
@else
|
||||
<x-forms.input disabled id="application.name" label="Name" />
|
||||
<x-forms.checkbox disabled id="application.settings.is_static" label="Static Site" />
|
||||
@endcan
|
||||
```
|
||||
|
||||
**After (Clean, 1 line per element):**
|
||||
```html
|
||||
<x-forms.input canGate="update" :canResource="$application" id="application.name" label="Name" />
|
||||
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="application.settings.is_static" label="Static Site" />
|
||||
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||
```
|
||||
|
||||
**Result: 90% code reduction!**
|
||||
|
||||
### Component-Specific Examples
|
||||
|
||||
#### Input Fields
|
||||
```html
|
||||
<!-- Basic input with authorization -->
|
||||
<x-forms.input
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="application.name"
|
||||
label="Application Name" />
|
||||
|
||||
<!-- Password input with authorization -->
|
||||
<x-forms.input
|
||||
type="password"
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="application.database_password"
|
||||
label="Database Password" />
|
||||
|
||||
<!-- Required input with authorization -->
|
||||
<x-forms.input
|
||||
required
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="application.fqdn"
|
||||
label="Domain" />
|
||||
```
|
||||
|
||||
#### Select Dropdowns
|
||||
```html
|
||||
<!-- Build pack selection -->
|
||||
<x-forms.select
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="application.build_pack"
|
||||
label="Build Pack"
|
||||
required>
|
||||
<option value="nixpacks">Nixpacks</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
</x-forms.select>
|
||||
|
||||
<!-- Server selection -->
|
||||
<x-forms.select
|
||||
canGate="createAnyResource"
|
||||
:canResource="auth()->user()->currentTeam"
|
||||
id="server_id"
|
||||
label="Target Server">
|
||||
@foreach($servers as $server)
|
||||
<option value="{{ $server->id }}">{{ $server->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
```
|
||||
|
||||
#### Checkboxes with InstantSave
|
||||
```html
|
||||
<!-- Static site toggle -->
|
||||
<x-forms.checkbox
|
||||
instantSave
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="application.settings.is_static"
|
||||
label="Is it a static site?"
|
||||
helper="Enable if your application serves static files" />
|
||||
|
||||
<!-- Debug mode toggle -->
|
||||
<x-forms.checkbox
|
||||
instantSave
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="application.settings.is_debug_enabled"
|
||||
label="Debug Mode"
|
||||
helper="Enable debug logging for troubleshooting" />
|
||||
|
||||
<!-- Build server toggle -->
|
||||
<x-forms.checkbox
|
||||
instantSave
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="application.settings.is_build_server_enabled"
|
||||
label="Use Build Server"
|
||||
helper="Use a dedicated build server for compilation" />
|
||||
```
|
||||
|
||||
#### Textareas
|
||||
```html
|
||||
<!-- Configuration textarea -->
|
||||
<x-forms.textarea
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="application.docker_compose_raw"
|
||||
label="Docker Compose Configuration"
|
||||
rows="10"
|
||||
monacoEditorLanguage="yaml"
|
||||
useMonacoEditor />
|
||||
|
||||
<!-- Custom commands -->
|
||||
<x-forms.textarea
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="application.post_deployment_command"
|
||||
label="Post-Deployment Commands"
|
||||
placeholder="php artisan migrate"
|
||||
helper="Commands to run after deployment" />
|
||||
```
|
||||
|
||||
#### Buttons
|
||||
```html
|
||||
<!-- Save button -->
|
||||
<x-forms.button
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
type="submit">
|
||||
Save Configuration
|
||||
</x-forms.button>
|
||||
|
||||
<!-- Deploy button -->
|
||||
<x-forms.button
|
||||
canGate="deploy"
|
||||
:canResource="$application"
|
||||
wire:click="deploy">
|
||||
Deploy Application
|
||||
</x-forms.button>
|
||||
|
||||
<!-- Delete button -->
|
||||
<x-forms.button
|
||||
canGate="delete"
|
||||
:canResource="$application"
|
||||
wire:click="confirmDelete"
|
||||
class="button-danger">
|
||||
Delete Application
|
||||
</x-forms.button>
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Authorization Logic
|
||||
```html
|
||||
<!-- Disable auto-control for complex permissions -->
|
||||
<x-forms.input
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
autoDisable="false"
|
||||
:disabled="$application->is_deployed || !$application->canModifySettings()"
|
||||
id="deployment.setting"
|
||||
label="Advanced Setting" />
|
||||
```
|
||||
|
||||
### Multiple Permission Checks
|
||||
```html
|
||||
<!-- Combine multiple authorization requirements -->
|
||||
<x-forms.checkbox
|
||||
canGate="deploy"
|
||||
:canResource="$application"
|
||||
autoDisable="false"
|
||||
:disabled="!$application->hasDockerfile() || !Gate::allows('deploy', $application)"
|
||||
id="docker.setting"
|
||||
label="Docker-Specific Setting" />
|
||||
```
|
||||
|
||||
### Conditional Resources
|
||||
```html
|
||||
<!-- Different resources based on context -->
|
||||
<x-forms.button
|
||||
:canGate="$isEditing ? 'update' : 'view'"
|
||||
:canResource="$resource"
|
||||
type="submit">
|
||||
{{ $isEditing ? 'Save Changes' : 'View Details' }}
|
||||
</x-forms.button>
|
||||
```
|
||||
|
||||
## Supported Gates
|
||||
|
||||
### Resource-Level Gates
|
||||
- `view` - Read access to resource details
|
||||
- `update` - Modify resource configuration and settings
|
||||
- `deploy` - Deploy, restart, or manage resource state
|
||||
- `delete` - Remove or destroy resource
|
||||
- `clone` - Duplicate resource to another location
|
||||
|
||||
### Global Gates
|
||||
- `createAnyResource` - Create new resources of any type
|
||||
- `manageTeam` - Team administration permissions
|
||||
- `accessServer` - Server-level access permissions
|
||||
|
||||
## Supported Resources
|
||||
|
||||
### Primary Resources
|
||||
- `$application` - Application instances and configurations
|
||||
- `$service` - Docker Compose services and components
|
||||
- `$database` - Database instances (PostgreSQL, MySQL, etc.)
|
||||
- `$server` - Physical or virtual server instances
|
||||
|
||||
### Container Resources
|
||||
- `$project` - Project containers and environments
|
||||
- `$environment` - Environment-specific configurations
|
||||
- `$team` - Team and organization contexts
|
||||
|
||||
### Infrastructure Resources
|
||||
- `$privateKey` - SSH private keys and certificates
|
||||
- `$source` - Git sources and repositories
|
||||
- `$destination` - Deployment destinations and targets
|
||||
|
||||
## Component Behavior
|
||||
|
||||
### Input Components (Input, Select, Textarea)
|
||||
When authorization fails:
|
||||
- **disabled = true** - Field becomes non-editable
|
||||
- **Visual styling** - Opacity reduction and disabled cursor
|
||||
- **Form submission** - Values are ignored in forms
|
||||
- **User feedback** - Clear visual indication of restricted access
|
||||
|
||||
### Checkbox Components
|
||||
When authorization fails:
|
||||
- **disabled = true** - Checkbox becomes non-clickable
|
||||
- **instantSave = false** - Automatic saving is disabled
|
||||
- **State preservation** - Current value is maintained but read-only
|
||||
- **Visual styling** - Disabled appearance with reduced opacity
|
||||
|
||||
### Button Components
|
||||
When authorization fails:
|
||||
- **disabled = true** - Button becomes non-clickable
|
||||
- **Event blocking** - Click handlers are ignored
|
||||
- **Visual styling** - Disabled appearance and cursor
|
||||
- **Loading states** - Loading indicators are disabled
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Converting Existing Forms
|
||||
|
||||
**Old Pattern:**
|
||||
```html
|
||||
<form wire:submit='submit'>
|
||||
@can('update', $application)
|
||||
<x-forms.input id="name" label="Name" />
|
||||
<x-forms.select id="type" label="Type">...</x-forms.select>
|
||||
<x-forms.checkbox instantSave id="enabled" label="Enabled" />
|
||||
<x-forms.button type="submit">Save</x-forms.button>
|
||||
@else
|
||||
<x-forms.input disabled id="name" label="Name" />
|
||||
<x-forms.select disabled id="type" label="Type">...</x-forms.select>
|
||||
<x-forms.checkbox disabled id="enabled" label="Enabled" />
|
||||
@endcan
|
||||
</form>
|
||||
```
|
||||
|
||||
**New Pattern:**
|
||||
```html
|
||||
<form wire:submit='submit'>
|
||||
<x-forms.input canGate="update" :canResource="$application" id="name" label="Name" />
|
||||
<x-forms.select canGate="update" :canResource="$application" id="type" label="Type">...</x-forms.select>
|
||||
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="enabled" label="Enabled" />
|
||||
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Gradual Migration Strategy
|
||||
|
||||
1. **Start with new forms** - Use the new pattern for all new components
|
||||
2. **Convert high-traffic areas** - Migrate frequently used forms first
|
||||
3. **Batch convert similar forms** - Group similar authorization patterns
|
||||
4. **Test thoroughly** - Verify authorization behavior matches expectations
|
||||
5. **Remove old patterns** - Clean up legacy @can/@else blocks
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Component Authorization Tests
|
||||
```php
|
||||
// Test authorization integration in components
|
||||
test('input component respects authorization', function () {
|
||||
$user = User::factory()->member()->create();
|
||||
$application = Application::factory()->create();
|
||||
|
||||
// Member should see disabled input
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(TestComponent::class, [
|
||||
'canGate' => 'update',
|
||||
'canResource' => $application
|
||||
]);
|
||||
|
||||
expect($component->get('disabled'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('checkbox disables instantSave for unauthorized users', function () {
|
||||
$user = User::factory()->member()->create();
|
||||
$application = Application::factory()->create();
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(CheckboxComponent::class, [
|
||||
'instantSave' => true,
|
||||
'canGate' => 'update',
|
||||
'canResource' => $application
|
||||
]);
|
||||
|
||||
expect($component->get('disabled'))->toBeTrue();
|
||||
expect($component->get('instantSave'))->toBeFalse();
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
```php
|
||||
// Test full form authorization behavior
|
||||
test('application form respects member permissions', function () {
|
||||
$member = User::factory()->member()->create();
|
||||
$application = Application::factory()->create();
|
||||
|
||||
$this->actingAs($member)
|
||||
->get(route('application.edit', $application))
|
||||
->assertSee('disabled')
|
||||
->assertDontSee('Save Configuration');
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Consistent Gate Usage
|
||||
- Use `update` for configuration changes
|
||||
- Use `deploy` for operational actions
|
||||
- Use `view` for read-only access
|
||||
- Use `delete` for destructive actions
|
||||
|
||||
### Resource Context
|
||||
- Always pass the specific resource being acted upon
|
||||
- Use team context for creation permissions
|
||||
- Consider nested resource relationships
|
||||
|
||||
### Error Handling
|
||||
- Provide clear feedback for disabled components
|
||||
- Use helper text to explain permission requirements
|
||||
- Consider tooltips for disabled buttons
|
||||
|
||||
### Performance
|
||||
- Authorization checks are cached per request
|
||||
- Use eager loading for resource relationships
|
||||
- Consider query optimization for complex permissions
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Application Configuration Forms
|
||||
```html
|
||||
<!-- Application settings with consistent authorization -->
|
||||
<x-forms.input canGate="update" :canResource="$application" id="application.name" label="Name" />
|
||||
<x-forms.select canGate="update" :canResource="$application" id="application.build_pack" label="Build Pack">...</x-forms.select>
|
||||
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="application.settings.is_static" label="Static Site" />
|
||||
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||
```
|
||||
|
||||
### Service Configuration Forms
|
||||
```html
|
||||
<!-- Service stack configuration with authorization -->
|
||||
<x-forms.input canGate="update" :canResource="$service" id="service.name" label="Service Name" />
|
||||
<x-forms.input canGate="update" :canResource="$service" id="service.description" label="Description" />
|
||||
<x-forms.checkbox canGate="update" :canResource="$service" instantSave id="service.connect_to_docker_network" label="Connect To Predefined Network" />
|
||||
<x-forms.button canGate="update" :canResource="$service" type="submit">Save</x-forms.button>
|
||||
|
||||
<!-- Service-specific fields -->
|
||||
<x-forms.input canGate="update" :canResource="$service" type="{{ data_get($field, 'isPassword') ? 'password' : 'text' }}"
|
||||
required="{{ str(data_get($field, 'rules'))?->contains('required') }}"
|
||||
id="fields.{{ $serviceName }}.value"></x-forms.input>
|
||||
|
||||
<!-- Service restart modal - wrapped with @can -->
|
||||
@can('update', $service)
|
||||
<x-modal-confirmation title="Confirm Service Application Restart?"
|
||||
buttonTitle="Restart"
|
||||
submitAction="restartApplication({{ $application->id }})" />
|
||||
@endcan
|
||||
```
|
||||
|
||||
### Server Management Forms
|
||||
```html
|
||||
<!-- Server configuration with appropriate gates -->
|
||||
<x-forms.input canGate="update" :canResource="$server" id="server.name" label="Server Name" />
|
||||
<x-forms.select canGate="update" :canResource="$server" id="server.type" label="Server Type">...</x-forms.select>
|
||||
<x-forms.button canGate="delete" :canResource="$server" wire:click="deleteServer">Delete Server</x-forms.button>
|
||||
```
|
||||
|
||||
### Resource Creation Forms
|
||||
```html
|
||||
<!-- New resource creation -->
|
||||
<x-forms.input canGate="createAnyResource" :canResource="auth()->user()->currentTeam" id="name" label="Name" />
|
||||
<x-forms.select canGate="createAnyResource" :canResource="auth()->user()->currentTeam" id="server_id" label="Server">...</x-forms.select>
|
||||
<x-forms.button canGate="createAnyResource" :canResource="auth()->user()->currentTeam" type="submit">Create Application</x-forms.button>
|
||||
```
|
||||
696
.ai/patterns/frontend-patterns.md
Normal file
696
.ai/patterns/frontend-patterns.md
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
# Coolify Frontend Architecture & Patterns
|
||||
|
||||
## Frontend Philosophy
|
||||
|
||||
Coolify uses a **server-side first** approach with minimal JavaScript, leveraging Livewire for reactivity and Alpine.js for lightweight client-side interactions.
|
||||
|
||||
## Core Frontend Stack
|
||||
|
||||
### Livewire 3.5+ (Primary Framework)
|
||||
- **Server-side rendering** with reactive components
|
||||
- **Real-time updates** without page refreshes
|
||||
- **State management** handled on the server
|
||||
- **WebSocket integration** for live updates
|
||||
|
||||
### Alpine.js (Client-Side Interactivity)
|
||||
- **Lightweight JavaScript** for DOM manipulation
|
||||
- **Declarative directives** in HTML
|
||||
- **Component-like behavior** without build steps
|
||||
- **Perfect companion** to Livewire
|
||||
|
||||
### Tailwind CSS 4.1+ (Styling)
|
||||
- **Utility-first** CSS framework
|
||||
- **Custom design system** for deployment platform
|
||||
- **Responsive design** built-in
|
||||
- **Dark mode support**
|
||||
|
||||
## Livewire Component Structure
|
||||
|
||||
### Location: [app/Livewire/](mdc:app/Livewire)
|
||||
|
||||
#### Core Application Components
|
||||
- **[Dashboard.php](mdc:app/Livewire/Dashboard.php)** - Main dashboard interface
|
||||
- **[ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php)** - Real-time activity tracking
|
||||
- **[MonacoEditor.php](mdc:app/Livewire/MonacoEditor.php)** - Code editor component
|
||||
|
||||
#### Server Management
|
||||
- **Server/** directory - Server configuration and monitoring
|
||||
- Real-time server status updates
|
||||
- SSH connection management
|
||||
- Resource monitoring
|
||||
|
||||
#### Project & Application Management
|
||||
- **Project/** directory - Project organization
|
||||
- Application deployment interfaces
|
||||
- Environment variable management
|
||||
- Service configuration
|
||||
|
||||
#### Settings & Configuration
|
||||
- **Settings/** directory - System configuration
|
||||
- **[SettingsEmail.php](mdc:app/Livewire/SettingsEmail.php)** - Email notification setup
|
||||
- **[SettingsOauth.php](mdc:app/Livewire/SettingsOauth.php)** - OAuth provider configuration
|
||||
- **[SettingsBackup.php](mdc:app/Livewire/SettingsBackup.php)** - Backup configuration
|
||||
|
||||
#### User & Team Management
|
||||
- **Team/** directory - Team collaboration features
|
||||
- **Profile/** directory - User profile management
|
||||
- **Security/** directory - Security settings
|
||||
|
||||
## Blade Template Organization
|
||||
|
||||
### Location: [resources/views/](mdc:resources/views)
|
||||
|
||||
#### Layout Structure
|
||||
- **layouts/** - Base layout templates
|
||||
- **components/** - Reusable UI components
|
||||
- **livewire/** - Livewire component views
|
||||
|
||||
#### Feature-Specific Views
|
||||
- **server/** - Server management interfaces
|
||||
- **auth/** - Authentication pages
|
||||
- **emails/** - Email templates
|
||||
- **errors/** - Error pages
|
||||
|
||||
## Interactive Components
|
||||
|
||||
### Monaco Editor Integration
|
||||
- **Code editing** for configuration files
|
||||
- **Syntax highlighting** for multiple languages
|
||||
- **Live validation** and error detection
|
||||
- **Integration** with deployment process
|
||||
|
||||
### Terminal Emulation (XTerm.js)
|
||||
- **Real-time terminal** access to servers
|
||||
- **WebSocket-based** communication
|
||||
- **Multi-session** support
|
||||
- **Secure connection** through SSH
|
||||
|
||||
### Real-Time Updates
|
||||
- **WebSocket connections** via Laravel Echo
|
||||
- **Live deployment logs** streaming
|
||||
- **Server monitoring** with live metrics
|
||||
- **Activity notifications** in real-time
|
||||
|
||||
## Alpine.js Patterns
|
||||
|
||||
### Common Directives Used
|
||||
```html
|
||||
<!-- State management -->
|
||||
<div x-data="{ open: false }">
|
||||
|
||||
<!-- Event handling -->
|
||||
<button x-on:click="open = !open">
|
||||
|
||||
<!-- Conditional rendering -->
|
||||
<div x-show="open">
|
||||
|
||||
<!-- Data binding -->
|
||||
<input x-model="searchTerm">
|
||||
|
||||
<!-- Component initialization -->
|
||||
<div x-init="initializeComponent()">
|
||||
```
|
||||
|
||||
### Integration with Livewire
|
||||
```html
|
||||
<!-- Livewire actions with Alpine state -->
|
||||
<button
|
||||
x-data="{ loading: false }"
|
||||
x-on:click="loading = true"
|
||||
wire:click="deploy"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="deploy"
|
||||
>
|
||||
<span x-show="!loading">Deploy</span>
|
||||
<span x-show="loading">Deploying...</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
## Tailwind CSS Patterns
|
||||
|
||||
### Design System
|
||||
- **Consistent spacing** using Tailwind scale
|
||||
- **Color palette** optimized for deployment platform
|
||||
- **Typography** hierarchy for technical content
|
||||
- **Component classes** for reusable elements
|
||||
|
||||
### Responsive Design
|
||||
```html
|
||||
<!-- Mobile-first responsive design -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Content adapts to screen size -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Dark Mode Support
|
||||
```html
|
||||
<!-- Dark mode variants -->
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
<!-- Automatic dark mode switching -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## Build Process
|
||||
|
||||
### Vite Configuration ([vite.config.js](mdc:vite.config.js))
|
||||
- **Fast development** with hot module replacement
|
||||
- **Optimized production** builds
|
||||
- **Asset versioning** for cache busting
|
||||
- **CSS processing** with PostCSS
|
||||
|
||||
### Asset Compilation
|
||||
```bash
|
||||
# Development
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
```
|
||||
|
||||
## State Management Patterns
|
||||
|
||||
### Server-Side State (Livewire)
|
||||
- **Component properties** for persistent state
|
||||
- **Session storage** for user preferences
|
||||
- **Database models** for application state
|
||||
- **Cache layer** for performance
|
||||
|
||||
### Client-Side State (Alpine.js)
|
||||
- **Local component state** for UI interactions
|
||||
- **Form validation** and user feedback
|
||||
- **Modal and dropdown** state management
|
||||
- **Temporary UI states** (loading, hover, etc.)
|
||||
|
||||
## Real-Time Features
|
||||
|
||||
### WebSocket Integration
|
||||
```php
|
||||
// Livewire component with real-time updates
|
||||
class ActivityMonitor extends Component
|
||||
{
|
||||
public function getListeners()
|
||||
{
|
||||
return [
|
||||
'deployment.started' => 'refresh',
|
||||
'deployment.finished' => 'refresh',
|
||||
'server.status.changed' => 'updateServerStatus',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Broadcasting
|
||||
- **Laravel Echo** for client-side WebSocket handling
|
||||
- **Pusher protocol** for real-time communication
|
||||
- **Private channels** for user-specific events
|
||||
- **Presence channels** for collaborative features
|
||||
|
||||
## Performance Patterns
|
||||
|
||||
### Lazy Loading
|
||||
```php
|
||||
// Livewire lazy loading
|
||||
class ServerList extends Component
|
||||
{
|
||||
public function placeholder()
|
||||
{
|
||||
return view('components.loading-skeleton');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Caching Strategies
|
||||
- **Fragment caching** for expensive operations
|
||||
- **Image optimization** with lazy loading
|
||||
- **Asset bundling** and compression
|
||||
- **CDN integration** for static assets
|
||||
|
||||
## Enhanced Form Components
|
||||
|
||||
### Built-in Authorization System
|
||||
Coolify features **enhanced form components** with automatic authorization handling:
|
||||
|
||||
```html
|
||||
<!-- ✅ New Pattern: Single line with built-in authorization -->
|
||||
<x-forms.input canGate="update" :canResource="$application" id="application.name" label="Name" />
|
||||
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="application.settings.is_static" label="Static Site" />
|
||||
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||
|
||||
<!-- ❌ Old Pattern: Verbose @can/@else blocks (deprecated) -->
|
||||
@can('update', $application)
|
||||
<x-forms.input id="application.name" label="Name" />
|
||||
@else
|
||||
<x-forms.input disabled id="application.name" label="Name" />
|
||||
@endcan
|
||||
```
|
||||
|
||||
### Authorization Parameters
|
||||
```php
|
||||
// Available on all form components (Input, Select, Textarea, Checkbox, Button)
|
||||
public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete'
|
||||
public mixed $canResource = null; // Resource model instance to check against
|
||||
public bool $autoDisable = true; // Automatically disable if no permission (default: true)
|
||||
```
|
||||
|
||||
### Benefits
|
||||
- **90% code reduction** for authorization-protected forms
|
||||
- **Consistent security** across all form components
|
||||
- **Automatic disabling** for unauthorized users
|
||||
- **Smart behavior** (disables instantSave on checkboxes for unauthorized users)
|
||||
|
||||
For complete documentation, see **[form-components.md](.ai/patterns/form-components.md)**
|
||||
|
||||
## Form Handling Patterns
|
||||
|
||||
### Livewire Component Data Synchronization Pattern
|
||||
|
||||
**IMPORTANT**: All Livewire components must use the **manual `syncData()` pattern** for synchronizing component properties with Eloquent models.
|
||||
|
||||
#### Property Naming Convention
|
||||
- **Component properties**: Use camelCase (e.g., `$gitRepository`, `$isStatic`)
|
||||
- **Database columns**: Use snake_case (e.g., `git_repository`, `is_static`)
|
||||
- **View bindings**: Use camelCase matching component properties (e.g., `id="gitRepository"`)
|
||||
|
||||
#### The syncData() Method Pattern
|
||||
|
||||
```php
|
||||
use Livewire\Attributes\Validate;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
class MyComponent extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Application $application;
|
||||
|
||||
// Properties with validation attributes
|
||||
#[Validate(['required'])]
|
||||
public string $name;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $description = null;
|
||||
|
||||
#[Validate(['boolean', 'required'])]
|
||||
public bool $isStatic = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('view', $this->application);
|
||||
$this->syncData(); // Load from model
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
||||
// Sync TO model (camelCase → snake_case)
|
||||
$this->application->name = $this->name;
|
||||
$this->application->description = $this->description;
|
||||
$this->application->is_static = $this->isStatic;
|
||||
|
||||
$this->application->save();
|
||||
} else {
|
||||
// Sync FROM model (snake_case → camelCase)
|
||||
$this->name = $this->application->name;
|
||||
$this->description = $this->application->description;
|
||||
$this->isStatic = $this->application->is_static;
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
$this->syncData(toModel: true); // Save to model
|
||||
$this->dispatch('success', 'Saved successfully.');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Validation with #[Validate] Attributes
|
||||
|
||||
All component properties should have `#[Validate]` attributes:
|
||||
|
||||
```php
|
||||
// Boolean properties
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isEnabled = false;
|
||||
|
||||
// Required strings
|
||||
#[Validate(['string', 'required'])]
|
||||
public string $name;
|
||||
|
||||
// Nullable strings
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $description = null;
|
||||
|
||||
// With constraints
|
||||
#[Validate(['integer', 'min:1'])]
|
||||
public int $timeout;
|
||||
```
|
||||
|
||||
#### Benefits of syncData() Pattern
|
||||
|
||||
- **Explicit Control**: Clear visibility of what's being synchronized
|
||||
- **Type Safety**: #[Validate] attributes provide compile-time validation info
|
||||
- **Easy Debugging**: Single method to check for data flow issues
|
||||
- **Maintainability**: All sync logic in one place
|
||||
- **Flexibility**: Can add custom logic (encoding, transformations, etc.)
|
||||
|
||||
#### Creating New Form Components with syncData()
|
||||
|
||||
#### Step-by-Step Component Creation Guide
|
||||
|
||||
**Step 1: Define properties in camelCase with #[Validate] attributes**
|
||||
```php
|
||||
use Livewire\Attributes\Validate;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class MyFormComponent extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
// The model we're syncing with
|
||||
public Application $application;
|
||||
|
||||
// Component properties in camelCase with validation
|
||||
#[Validate(['string', 'required'])]
|
||||
public string $name;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $gitRepository = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $installCommand = null;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isStatic = false;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Implement syncData() method**
|
||||
```php
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
||||
// Sync TO model (component camelCase → database snake_case)
|
||||
$this->application->name = $this->name;
|
||||
$this->application->git_repository = $this->gitRepository;
|
||||
$this->application->install_command = $this->installCommand;
|
||||
$this->application->is_static = $this->isStatic;
|
||||
|
||||
$this->application->save();
|
||||
} else {
|
||||
// Sync FROM model (database snake_case → component camelCase)
|
||||
$this->name = $this->application->name;
|
||||
$this->gitRepository = $this->application->git_repository;
|
||||
$this->installCommand = $this->application->install_command;
|
||||
$this->isStatic = $this->application->is_static;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Implement mount() to load initial data**
|
||||
```php
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('view', $this->application);
|
||||
$this->syncData(); // Load data from model to component properties
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Implement action methods with authorization**
|
||||
```php
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$this->syncData(toModel: true); // Save component properties to model
|
||||
$this->dispatch('success', 'Settings saved.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$this->syncData(toModel: true); // Save component properties to model
|
||||
$this->dispatch('success', 'Changes saved successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Create Blade view with camelCase bindings**
|
||||
```blade
|
||||
<div>
|
||||
<form wire:submit="submit">
|
||||
<x-forms.input
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="name"
|
||||
label="Name"
|
||||
required />
|
||||
|
||||
<x-forms.input
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="gitRepository"
|
||||
label="Git Repository" />
|
||||
|
||||
<x-forms.input
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="installCommand"
|
||||
label="Install Command" />
|
||||
|
||||
<x-forms.checkbox
|
||||
instantSave
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="isStatic"
|
||||
label="Static Site" />
|
||||
|
||||
<x-forms.button
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
type="submit">
|
||||
Save Changes
|
||||
</x-forms.button>
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
- Use `wire:model="camelCase"` and `id="camelCase"` in Blade views
|
||||
- Component properties are camelCase, database columns are snake_case
|
||||
- Always include authorization checks (`authorize()`, `canGate`, `canResource`)
|
||||
- Use `instantSave` for checkboxes that save immediately without form submission
|
||||
|
||||
#### Special Patterns
|
||||
|
||||
**Pattern 1: Related Models (e.g., Application → Settings)**
|
||||
```php
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
||||
// Sync main model
|
||||
$this->application->name = $this->name;
|
||||
$this->application->save();
|
||||
|
||||
// Sync related model
|
||||
$this->application->settings->is_static = $this->isStatic;
|
||||
$this->application->settings->save();
|
||||
} else {
|
||||
// From main model
|
||||
$this->name = $this->application->name;
|
||||
|
||||
// From related model
|
||||
$this->isStatic = $this->application->settings->is_static;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern 2: Custom Encoding/Decoding**
|
||||
```php
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
||||
// Encode before saving
|
||||
$this->application->custom_labels = base64_encode($this->customLabels);
|
||||
$this->application->save();
|
||||
} else {
|
||||
// Decode when loading
|
||||
$this->customLabels = $this->application->parseContainerLabels();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern 3: Error Rollback**
|
||||
```php
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
$original = $this->model->getOriginal();
|
||||
|
||||
try {
|
||||
$this->syncData(toModel: true);
|
||||
$this->dispatch('success', 'Saved successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
// Rollback on error
|
||||
$this->model->setRawAttributes($original);
|
||||
$this->model->save();
|
||||
$this->syncData(); // Reload from model
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Property Type Patterns
|
||||
|
||||
**Required Strings**
|
||||
```php
|
||||
#[Validate(['string', 'required'])]
|
||||
public string $name; // No ?, no default, always has value
|
||||
```
|
||||
|
||||
**Nullable Strings**
|
||||
```php
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $description = null; // ?, = null, can be empty
|
||||
```
|
||||
|
||||
**Booleans**
|
||||
```php
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isEnabled = false; // Always has default value
|
||||
```
|
||||
|
||||
**Integers with Constraints**
|
||||
```php
|
||||
#[Validate(['integer', 'min:1'])]
|
||||
public int $timeout; // Required
|
||||
|
||||
#[Validate(['integer', 'min:1', 'nullable'])]
|
||||
public ?int $port = null; // Nullable
|
||||
```
|
||||
|
||||
#### Testing Checklist
|
||||
|
||||
After creating a new component with syncData(), verify:
|
||||
|
||||
- [ ] All checkboxes save correctly (especially `instantSave` ones)
|
||||
- [ ] All form inputs persist to database
|
||||
- [ ] Custom encoded fields (like labels) display correctly if applicable
|
||||
- [ ] Form validation works for all fields
|
||||
- [ ] No console errors in browser
|
||||
- [ ] Authorization checks work (`@can` directives and `authorize()` calls)
|
||||
- [ ] Error rollback works if exceptions occur
|
||||
- [ ] Related models save correctly if applicable (e.g., Application + ApplicationSetting)
|
||||
|
||||
#### Common Pitfalls to Avoid
|
||||
|
||||
1. **snake_case in component properties**: Always use camelCase for component properties (e.g., `$gitRepository` not `$git_repository`)
|
||||
2. **Missing #[Validate] attributes**: Every property should have validation attributes for type safety
|
||||
3. **Forgetting to call syncData()**: Must call `syncData()` in `mount()` to load initial data
|
||||
4. **Missing authorization**: Always use `authorize()` in methods and `canGate`/`canResource` in views
|
||||
5. **View binding mismatch**: Use camelCase in Blade (e.g., `id="gitRepository"` not `id="git_repository"`)
|
||||
6. **wire:model vs wire:model.live**: Use `.live` for `instantSave` checkboxes to avoid timing issues
|
||||
7. **Validation sync**: If using `rules()` method, keep it in sync with `#[Validate]` attributes
|
||||
8. **Related models**: Don't forget to save both main and related models in syncData() method
|
||||
|
||||
### 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
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Security Architecture & Patterns
|
||||
|
||||
## Security Philosophy
|
||||
|
|
@ -63,6 +58,323 @@ ### Authentication Models
|
|||
|
||||
## Authorization & Access Control
|
||||
|
||||
### Enhanced Form Component Authorization System
|
||||
|
||||
Coolify now features a **centralized authorization system** built into all form components (`Input`, `Select`, `Textarea`, `Checkbox`, `Button`) that automatically handles permission-based UI control.
|
||||
|
||||
#### Component Authorization Parameters
|
||||
```php
|
||||
// Available on all form components
|
||||
public ?string $canGate = null; // Gate name (e.g., 'update', 'view', 'delete')
|
||||
public mixed $canResource = null; // Resource to check against (model instance)
|
||||
public bool $autoDisable = true; // Auto-disable if no permission (default: true)
|
||||
```
|
||||
|
||||
#### Smart Authorization Logic
|
||||
```php
|
||||
// Automatic authorization handling in component constructor
|
||||
if ($this->canGate && $this->canResource && $this->autoDisable) {
|
||||
$hasPermission = Gate::allows($this->canGate, $this->canResource);
|
||||
|
||||
if (! $hasPermission) {
|
||||
$this->disabled = true;
|
||||
// For Checkbox: also disables instantSave
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Usage Examples
|
||||
|
||||
**✅ Recommended Pattern (Single Line):**
|
||||
```html
|
||||
<!-- Input with automatic authorization -->
|
||||
<x-forms.input
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="application.name"
|
||||
label="Application Name" />
|
||||
|
||||
<!-- Select with automatic authorization -->
|
||||
<x-forms.select
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="application.build_pack"
|
||||
label="Build Pack">
|
||||
<option value="nixpacks">Nixpacks</option>
|
||||
<option value="static">Static</option>
|
||||
</x-forms.select>
|
||||
|
||||
<!-- Checkbox with automatic instantSave control -->
|
||||
<x-forms.checkbox
|
||||
instantSave
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="application.settings.is_static"
|
||||
label="Is Static Site?" />
|
||||
|
||||
<!-- Button with automatic disable -->
|
||||
<x-forms.button
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
type="submit">
|
||||
Save Configuration
|
||||
</x-forms.button>
|
||||
```
|
||||
|
||||
**❌ Old Pattern (Verbose, Deprecated):**
|
||||
```html
|
||||
<!-- DON'T use this repetitive pattern anymore -->
|
||||
@can('update', $application)
|
||||
<x-forms.input id="application.name" label="Application Name" />
|
||||
<x-forms.button type="submit">Save</x-forms.button>
|
||||
@else
|
||||
<x-forms.input disabled id="application.name" label="Application Name" />
|
||||
@endcan
|
||||
```
|
||||
|
||||
#### Advanced Usage with Custom Control
|
||||
|
||||
**Custom Authorization Logic:**
|
||||
```html
|
||||
<!-- Disable auto-control, use custom logic -->
|
||||
<x-forms.input
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
autoDisable="false"
|
||||
:disabled="$application->is_deployed || !Gate::allows('update', $application)"
|
||||
id="advanced.setting"
|
||||
label="Advanced Setting" />
|
||||
```
|
||||
|
||||
**Multiple Permission Checks:**
|
||||
```html
|
||||
<!-- Complex permission requirements -->
|
||||
<x-forms.checkbox
|
||||
canGate="deploy"
|
||||
:canResource="$application"
|
||||
autoDisable="false"
|
||||
:disabled="!$application->canDeploy() || !auth()->user()->hasAdvancedPermissions()"
|
||||
id="deployment.setting"
|
||||
label="Advanced Deployment Setting" />
|
||||
```
|
||||
|
||||
#### Supported Gates and Resources
|
||||
|
||||
**Common Gates:**
|
||||
- `view` - Read access to resource
|
||||
- `update` - Modify resource configuration
|
||||
- `deploy` - Deploy/restart resource
|
||||
- `delete` - Remove resource
|
||||
- `createAnyResource` - Create new resources
|
||||
|
||||
**Resource Types:**
|
||||
- `Application` - Application instances
|
||||
- `Service` - Docker Compose services
|
||||
- `Server` - Server instances
|
||||
- `Project` - Project containers
|
||||
- `Environment` - Environment contexts
|
||||
- `Database` - Database instances
|
||||
|
||||
#### Benefits
|
||||
|
||||
**🔥 Massive Code Reduction:**
|
||||
- **90% less code** for authorization-protected forms
|
||||
- **Single line** instead of 6-12 lines per form element
|
||||
- **No more @can/@else blocks** cluttering templates
|
||||
|
||||
**🛡️ Consistent Security:**
|
||||
- **Unified authorization logic** across all form components
|
||||
- **Automatic disabling** for unauthorized users
|
||||
- **Smart behavior** (like disabling instantSave on checkboxes)
|
||||
|
||||
**🎨 Better UX:**
|
||||
- **Consistent disabled styling** across all components
|
||||
- **Proper visual feedback** for restricted access
|
||||
- **Clean, professional interface**
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
**Component Enhancement:**
|
||||
```php
|
||||
// Enhanced in all form components
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
public function __construct(
|
||||
// ... existing parameters
|
||||
public ?string $canGate = null,
|
||||
public mixed $canResource = null,
|
||||
public bool $autoDisable = true,
|
||||
) {
|
||||
// Handle authorization-based disabling
|
||||
if ($this->canGate && $this->canResource && $this->autoDisable) {
|
||||
$hasPermission = Gate::allows($this->canGate, $this->canResource);
|
||||
|
||||
if (! $hasPermission) {
|
||||
$this->disabled = true;
|
||||
// For Checkbox: $this->instantSave = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Backward Compatibility:**
|
||||
- All existing form components continue to work unchanged
|
||||
- New authorization parameters are optional
|
||||
- Legacy @can/@else patterns still function but are discouraged
|
||||
|
||||
### Custom Component Authorization Patterns
|
||||
|
||||
When dealing with **custom Alpine.js components** or complex UI elements that don't use the standard `x-forms.*` components, manual authorization protection is required since the automatic `canGate` system only applies to enhanced form components.
|
||||
|
||||
#### Common Custom Components Requiring Manual Protection
|
||||
|
||||
**⚠️ Custom Components That Need Manual Authorization:**
|
||||
- Custom dropdowns/selects with Alpine.js
|
||||
- Complex form widgets with JavaScript interactions
|
||||
- Multi-step wizards or dynamic forms
|
||||
- Third-party component integrations
|
||||
- Custom date/time pickers
|
||||
- File upload components with drag-and-drop
|
||||
|
||||
#### Manual Authorization Pattern
|
||||
|
||||
**✅ Proper Manual Authorization:**
|
||||
```html
|
||||
<!-- Custom timezone dropdown example -->
|
||||
<div class="w-full">
|
||||
<div class="flex items-center mb-1">
|
||||
<label for="customComponent">Component Label</label>
|
||||
<x-helper helper="Component description" />
|
||||
</div>
|
||||
@can('update', $resource)
|
||||
<!-- Full interactive component for authorized users -->
|
||||
<div x-data="{
|
||||
open: false,
|
||||
value: '{{ $currentValue }}',
|
||||
options: @js($options),
|
||||
init() { /* Alpine.js initialization */ }
|
||||
}">
|
||||
<input x-model="value" @focus="open = true"
|
||||
wire:model="propertyName" class="w-full input">
|
||||
<div x-show="open">
|
||||
<!-- Interactive dropdown content -->
|
||||
<template x-for="option in options" :key="option">
|
||||
<div @click="value = option; open = false; $wire.submit()"
|
||||
x-text="option"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<!-- Read-only version for unauthorized users -->
|
||||
<div class="relative">
|
||||
<input readonly disabled autocomplete="off"
|
||||
class="w-full input opacity-50 cursor-not-allowed"
|
||||
value="{{ $currentValue ?: 'No value set' }}">
|
||||
<svg class="absolute right-0 mr-2 w-4 h-4 opacity-50">
|
||||
<!-- Disabled icon -->
|
||||
</svg>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Implementation Checklist
|
||||
|
||||
When implementing authorization for custom components:
|
||||
|
||||
**🔍 1. Identify Custom Components:**
|
||||
- Look for Alpine.js `x-data` declarations
|
||||
- Find components not using `x-forms.*` prefix
|
||||
- Check for JavaScript-heavy interactions
|
||||
- Review complex form widgets
|
||||
|
||||
**🛡️ 2. Wrap with Authorization:**
|
||||
- Use `@can('gate', $resource)` / `@else` / `@endcan` structure
|
||||
- Provide full functionality in the `@can` block
|
||||
- Create disabled/readonly version in the `@else` block
|
||||
|
||||
**🎨 3. Design Disabled State:**
|
||||
- Apply `readonly disabled` attributes to inputs
|
||||
- Add `opacity-50 cursor-not-allowed` classes for visual feedback
|
||||
- Remove interactive JavaScript behaviors
|
||||
- Show current value or appropriate placeholder
|
||||
|
||||
**🔒 4. Backend Protection:**
|
||||
- Ensure corresponding Livewire methods check authorization
|
||||
- Add `$this->authorize('gate', $resource)` in relevant methods
|
||||
- Validate permissions before processing any changes
|
||||
|
||||
#### Real-World Examples
|
||||
|
||||
**Custom Date Range Picker:**
|
||||
```html
|
||||
@can('update', $application)
|
||||
<div x-data="dateRangePicker()" class="date-picker">
|
||||
<!-- Interactive date picker with calendar -->
|
||||
</div>
|
||||
@else
|
||||
<div class="flex gap-2">
|
||||
<input readonly disabled value="{{ $startDate }}" class="input opacity-50">
|
||||
<input readonly disabled value="{{ $endDate }}" class="input opacity-50">
|
||||
</div>
|
||||
@endcan
|
||||
```
|
||||
|
||||
**Multi-Select Component:**
|
||||
```html
|
||||
@can('update', $server)
|
||||
<div x-data="multiSelect({ options: @js($options) })">
|
||||
<!-- Interactive multi-select with checkboxes -->
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach($selectedValues as $value)
|
||||
<div class="px-3 py-1 bg-gray-100 rounded text-sm opacity-50">
|
||||
{{ $value }}
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endcan
|
||||
```
|
||||
|
||||
**File Upload Widget:**
|
||||
```html
|
||||
@can('update', $application)
|
||||
<div x-data="fileUploader()" @drop.prevent="handleDrop">
|
||||
<!-- Drag-and-drop file upload interface -->
|
||||
</div>
|
||||
@else
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center opacity-50">
|
||||
<p class="text-gray-500">File upload restricted</p>
|
||||
@if($currentFile)
|
||||
<p class="text-sm">Current: {{ $currentFile }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endcan
|
||||
```
|
||||
|
||||
#### Key Principles
|
||||
|
||||
**🎯 Consistency:**
|
||||
- Maintain similar visual styling between enabled/disabled states
|
||||
- Use consistent disabled patterns across the application
|
||||
- Apply the same opacity and cursor styling
|
||||
|
||||
**🔐 Security First:**
|
||||
- Always implement backend authorization checks
|
||||
- Never rely solely on frontend hiding/disabling
|
||||
- Validate permissions on every server action
|
||||
|
||||
**💡 User Experience:**
|
||||
- Show current values in disabled state when appropriate
|
||||
- Provide clear visual feedback about restricted access
|
||||
- Maintain layout stability between states
|
||||
|
||||
**🚀 Performance:**
|
||||
- Minimize Alpine.js initialization for disabled components
|
||||
- Avoid loading unnecessary JavaScript for unauthorized users
|
||||
- Use simple HTML structures for read-only states
|
||||
|
||||
### Team-Based Multi-Tenancy
|
||||
- **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure (8.9KB, 308 lines)
|
||||
- **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Secure team collaboration
|
||||
11
.cursor/mcp.json
Normal file
11
.cursor/mcp.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,368 +0,0 @@
|
|||
---
|
||||
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
|
||||
156
.cursor/rules/coolify-ai-docs.mdc
Normal file
156
.cursor/rules/coolify-ai-docs.mdc
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
---
|
||||
title: Coolify AI Documentation
|
||||
description: Master reference to all Coolify AI documentation in .ai/ directory
|
||||
globs: **/*
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Coolify AI Documentation
|
||||
|
||||
All Coolify AI documentation has been consolidated in the **`.ai/`** directory for better organization and single source of truth.
|
||||
|
||||
## Quick Start
|
||||
|
||||
- **For Claude Code**: Start with `CLAUDE.md` in the root directory
|
||||
- **For Cursor IDE**: Start with `.ai/README.md` for navigation
|
||||
- **For All AI Tools**: Browse `.ai/` directory by topic
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
All detailed documentation lives in `.ai/` with the following organization:
|
||||
|
||||
### 📚 Core Documentation
|
||||
- **[Technology Stack](.ai/core/technology-stack.md)** - All versions, packages, dependencies (SINGLE SOURCE OF TRUTH for versions)
|
||||
- **[Project Overview](.ai/core/project-overview.md)** - What Coolify is, high-level architecture
|
||||
- **[Application Architecture](.ai/core/application-architecture.md)** - System design, components, relationships
|
||||
- **[Deployment Architecture](.ai/core/deployment-architecture.md)** - Deployment flows, Docker, proxies
|
||||
|
||||
### 💻 Development
|
||||
- **[Development Workflow](.ai/development/development-workflow.md)** - Dev setup, commands, daily workflows
|
||||
- **[Testing Patterns](.ai/development/testing-patterns.md)** - How to write/run tests, Docker requirements
|
||||
- **[Laravel Boost](.ai/development/laravel-boost.md)** - Laravel-specific guidelines (SINGLE SOURCE for Laravel Boost)
|
||||
|
||||
### 🎨 Code Patterns
|
||||
- **[Database Patterns](.ai/patterns/database-patterns.md)** - Eloquent, migrations, relationships
|
||||
- **[Frontend Patterns](.ai/patterns/frontend-patterns.md)** - Livewire, Alpine.js, Tailwind CSS
|
||||
- **[Security Patterns](.ai/patterns/security-patterns.md)** - Auth, authorization, security
|
||||
- **[Form Components](.ai/patterns/form-components.md)** - Enhanced forms with authorization
|
||||
- **[API & Routing](.ai/patterns/api-and-routing.md)** - API design, routing conventions
|
||||
|
||||
### 📖 Meta
|
||||
- **[Maintaining Docs](.ai/meta/maintaining-docs.md)** - How to update/improve documentation
|
||||
- **[Sync Guide](.ai/meta/sync-guide.md)** - Keeping docs synchronized
|
||||
|
||||
## Quick Decision Tree
|
||||
|
||||
**What are you working on?**
|
||||
|
||||
### Running Commands
|
||||
→ `.ai/development/development-workflow.md`
|
||||
- `npm run dev` / `npm run build` - Frontend
|
||||
- `php artisan serve` / `php artisan migrate` - Backend
|
||||
- `docker exec coolify php artisan test` - Feature tests (requires Docker)
|
||||
- `./vendor/bin/pest tests/Unit` - Unit tests (no Docker needed)
|
||||
- `./vendor/bin/pint` - Code formatting
|
||||
|
||||
### Writing Tests
|
||||
→ `.ai/development/testing-patterns.md`
|
||||
- **Unit tests**: No database, use mocking, run outside Docker
|
||||
- **Feature tests**: Can use database, MUST run inside Docker
|
||||
- Critical: Docker execution requirements prevent database connection errors
|
||||
|
||||
### Building UI
|
||||
→ `.ai/patterns/frontend-patterns.md` + `.ai/patterns/form-components.md`
|
||||
- Livewire 3.5.20 with server-side state
|
||||
- Alpine.js for client interactions
|
||||
- Tailwind CSS 4.1.4 styling
|
||||
- Form components with `canGate` authorization
|
||||
|
||||
### Database Work
|
||||
→ `.ai/patterns/database-patterns.md`
|
||||
- Eloquent ORM patterns
|
||||
- Migration best practices
|
||||
- Relationship definitions
|
||||
- Query optimization
|
||||
|
||||
### Security & Authorization
|
||||
→ `.ai/patterns/security-patterns.md` + `.ai/patterns/form-components.md`
|
||||
- Team-based access control
|
||||
- Policy and gate patterns
|
||||
- Form authorization (`canGate`, `canResource`)
|
||||
- API security with Sanctum
|
||||
|
||||
### Laravel-Specific
|
||||
→ `.ai/development/laravel-boost.md`
|
||||
- Laravel 12.4.1 patterns
|
||||
- Livewire 3 best practices
|
||||
- Pest testing patterns
|
||||
- Laravel conventions
|
||||
|
||||
### Version Numbers
|
||||
→ `.ai/core/technology-stack.md`
|
||||
- **SINGLE SOURCE OF TRUTH** for all version numbers
|
||||
- Laravel 12.4.1, PHP 8.4.7, Tailwind 4.1.4, etc.
|
||||
- Never duplicate versions - always reference this file
|
||||
|
||||
## Critical Patterns (Always Follow)
|
||||
|
||||
### Testing Commands
|
||||
```bash
|
||||
# Unit tests (no database, outside Docker)
|
||||
./vendor/bin/pest tests/Unit
|
||||
|
||||
# Feature tests (requires database, inside Docker)
|
||||
docker exec coolify php artisan test
|
||||
```
|
||||
|
||||
**NEVER** run Feature tests outside Docker - they will fail with database connection errors.
|
||||
|
||||
### Form Authorization
|
||||
ALWAYS include authorization on form components:
|
||||
```blade
|
||||
<x-forms.input canGate="update" :canResource="$resource" id="name" label="Name" />
|
||||
```
|
||||
|
||||
### Livewire Components
|
||||
MUST have exactly ONE root element. No exceptions.
|
||||
|
||||
### Version Numbers
|
||||
Use exact versions from `technology-stack.md`:
|
||||
- ✅ Laravel 12.4.1
|
||||
- ❌ Laravel 12 or "v12"
|
||||
|
||||
### Code Style
|
||||
```bash
|
||||
# Always run before committing
|
||||
./vendor/bin/pint
|
||||
```
|
||||
|
||||
## For AI Assistants
|
||||
|
||||
### Important Notes
|
||||
1. **Single Source of Truth**: Each piece of information exists in ONE location only
|
||||
2. **Cross-Reference, Don't Duplicate**: Link to other files instead of copying content
|
||||
3. **Version Precision**: Always use exact versions from `technology-stack.md`
|
||||
4. **Docker for Feature Tests**: This is non-negotiable for database-dependent tests
|
||||
5. **Form Authorization**: Security requirement, not optional
|
||||
|
||||
### When to Use Which File
|
||||
- **Quick commands**: `CLAUDE.md` or `development-workflow.md`
|
||||
- **Detailed patterns**: Topic-specific files in `.ai/patterns/`
|
||||
- **Testing**: `.ai/development/testing-patterns.md`
|
||||
- **Laravel specifics**: `.ai/development/laravel-boost.md`
|
||||
- **Versions**: `.ai/core/technology-stack.md`
|
||||
|
||||
## Maintaining Documentation
|
||||
|
||||
When updating documentation:
|
||||
1. Read `.ai/meta/maintaining-docs.md` first
|
||||
2. Follow single source of truth principle
|
||||
3. Update cross-references when moving content
|
||||
4. Test all links work
|
||||
5. See `.ai/meta/sync-guide.md` for sync guidelines
|
||||
|
||||
## Migration Note
|
||||
|
||||
This file replaces all previous `.cursor/rules/*.mdc` files. All content has been migrated to `.ai/` directory for better organization and to serve as single source of truth for all AI tools (Claude Code, Cursor IDE, etc.).
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
---
|
||||
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
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
---
|
||||
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
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
---
|
||||
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.*
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
---
|
||||
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
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -4,6 +4,11 @@ on:
|
|||
schedule:
|
||||
- cron: '0 1 * * *'
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
discussions: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
lock-threads:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -13,5 +18,5 @@ jobs:
|
|||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-inactive-days: '30'
|
||||
pr-inactive-days: '30'
|
||||
discussion-inactive-days: '30'
|
||||
pr-inactive-days: '30'
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ on:
|
|||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
manage-stale:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
49
.github/workflows/chore-pr-comments.yml
vendored
Normal file
49
.github/workflows/chore-pr-comments.yml
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
name: Add comment based on label
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- label: "⚙️ Service"
|
||||
body: |
|
||||
Hi @${{ github.event.pull_request.user.login }}! 👋
|
||||
|
||||
It appears to us that you are either adding a new service or making changes to an existing one.
|
||||
We kindly ask you to also review and update the **Coolify Documentation** to include this new service or it's new configuration needs.
|
||||
This will help ensure that our documentation remains accurate and up-to-date for all users.
|
||||
|
||||
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
|
||||
How to Contribute a new Service to the Docs: https://coolify.io/docs/get-started/contribute/service#adding-a-new-service-template-to-the-coolify-documentation
|
||||
- label: "🛠️ Feature"
|
||||
body: |
|
||||
Hi @${{ github.event.pull_request.user.login }}! 👋
|
||||
|
||||
It appears to us that you are adding a new feature to Coolify.
|
||||
We kindly ask you to also update the **Coolify Documentation** to include information about this new feature.
|
||||
This will help ensure that our documentation remains accurate and up-to-date for all users.
|
||||
|
||||
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
|
||||
How to Contribute to the Docs: https://coolify.io/docs/get-started/contribute/documentation
|
||||
# - label: "✨ Enhancement"
|
||||
# body: |
|
||||
# It appears to us that you are making an enhancement to Coolify.
|
||||
# We kindly ask you to also review and update the Coolify Documentation to include information about this enhancement if applicable.
|
||||
# This will help ensure that our documentation remains accurate and up-to-date for all users.
|
||||
steps:
|
||||
- name: Add comment
|
||||
if: github.event.label.name == matrix.label
|
||||
run: gh pr comment "$NUMBER" --body "$BODY"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.pull_request.number }}
|
||||
BODY: ${{ matrix.body }}
|
||||
|
|
@ -8,6 +8,10 @@ on:
|
|||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
remove-labels-and-assignees:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
22
.github/workflows/cleanup-ghcr-untagged.yml
vendored
Normal file
22
.github/workflows/cleanup-ghcr-untagged.yml
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
name: Cleanup Untagged GHCR Images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
cleanup-all-packages:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host']
|
||||
steps:
|
||||
- name: Delete untagged ${{ matrix.package }} images
|
||||
uses: actions/delete-package-versions@v5
|
||||
with:
|
||||
package-name: ${{ matrix.package }}
|
||||
package-type: 'container'
|
||||
min-versions-to-keep: 0
|
||||
delete-only-untagged-versions: 'true'
|
||||
97
.github/workflows/coolify-helper-next.yml
vendored
97
.github/workflows/coolify-helper-next.yml
vendored
|
|
@ -7,19 +7,31 @@ on:
|
|||
- .github/workflows/coolify-helper-next.yml
|
||||
- docker/coolify-helper/Dockerfile
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
DOCKER_REGISTRY: docker.io
|
||||
IMAGE_NAME: "coollabsio/coolify-helper"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
|
|
@ -32,74 +44,35 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-helper/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
aarch64:
|
||||
runs-on: [ self-hosted, arm64 ]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-helper/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
|
|
@ -113,8 +86,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
|
|
@ -124,14 +97,16 @@ jobs:
|
|||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
||||
|
||||
|
|
|
|||
96
.github/workflows/coolify-helper.yml
vendored
96
.github/workflows/coolify-helper.yml
vendored
|
|
@ -7,19 +7,31 @@ on:
|
|||
- .github/workflows/coolify-helper.yml
|
||||
- docker/coolify-helper/Dockerfile
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
DOCKER_REGISTRY: docker.io
|
||||
IMAGE_NAME: "coollabsio/coolify-helper"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
|
|
@ -32,73 +44,33 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-helper/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
aarch64:
|
||||
runs-on: [ self-hosted, arm64 ]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-helper/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
|
@ -113,8 +85,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
|
|
@ -124,14 +96,16 @@ jobs:
|
|||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
|
|
|
|||
89
.github/workflows/coolify-production-build.yml
vendored
89
.github/workflows/coolify-production-build.yml
vendored
|
|
@ -14,16 +14,31 @@ on:
|
|||
- templates/**
|
||||
- CHANGELOG.md
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
DOCKER_REGISTRY: docker.io
|
||||
IMAGE_NAME: "coollabsio/coolify"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
|
|
@ -36,68 +51,32 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
|
||||
aarch64:
|
||||
runs-on: [self-hosted, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: [amd64, aarch64]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
|
@ -112,8 +91,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
|
|
@ -123,14 +102,16 @@ jobs:
|
|||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
|
|
|
|||
97
.github/workflows/coolify-realtime-next.yml
vendored
97
.github/workflows/coolify-realtime-next.yml
vendored
|
|
@ -11,19 +11,31 @@ on:
|
|||
- docker/coolify-realtime/package-lock.json
|
||||
- docker/coolify-realtime/soketi-entrypoint.sh
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
DOCKER_REGISTRY: docker.io
|
||||
IMAGE_NAME: "coollabsio/coolify-realtime"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
|
|
@ -36,75 +48,34 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-realtime/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
aarch64:
|
||||
runs-on: [ self-hosted, arm64 ]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-realtime/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
|
@ -119,8 +90,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
|
|
@ -130,14 +101,16 @@ jobs:
|
|||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
||||
|
||||
|
|
|
|||
97
.github/workflows/coolify-realtime.yml
vendored
97
.github/workflows/coolify-realtime.yml
vendored
|
|
@ -11,19 +11,31 @@ on:
|
|||
- docker/coolify-realtime/package-lock.json
|
||||
- docker/coolify-realtime/soketi-entrypoint.sh
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
DOCKER_REGISTRY: docker.io
|
||||
IMAGE_NAME: "coollabsio/coolify-realtime"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
|
|
@ -36,75 +48,34 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-realtime/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
aarch64:
|
||||
runs-on: [ self-hosted, arm64 ]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-realtime/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
|
@ -119,8 +90,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
|
|
@ -130,14 +101,16 @@ jobs:
|
|||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
|
|
|
|||
112
.github/workflows/coolify-staging-build.yml
vendored
112
.github/workflows/coolify-staging-build.yml
vendored
|
|
@ -17,16 +17,41 @@ on:
|
|||
- templates/**
|
||||
- CHANGELOG.md
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
DOCKER_REGISTRY: docker.io
|
||||
IMAGE_NAME: "coollabsio/coolify"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Sanitize branch name for Docker tag
|
||||
id: sanitize
|
||||
run: |
|
||||
# Replace slashes and other invalid characters with dashes
|
||||
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
|
||||
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
|
|
@ -39,61 +64,38 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
|
||||
aarch64:
|
||||
runs-on: [self-hosted, arm64]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
|
||||
cache-from: |
|
||||
type=gha,scope=build-${{ matrix.arch }}
|
||||
type=registry,ref=${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: [amd64, aarch64]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Sanitize branch name for Docker tag
|
||||
id: sanitize
|
||||
run: |
|
||||
# Replace slashes and other invalid characters with dashes
|
||||
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
|
||||
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
|
@ -108,20 +110,22 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
|
||||
|
||||
- uses: sarisia/actions-status-discord@v1
|
||||
if: always()
|
||||
|
|
|
|||
92
.github/workflows/coolify-testing-host.yml
vendored
92
.github/workflows/coolify-testing-host.yml
vendored
|
|
@ -7,19 +7,31 @@ on:
|
|||
- .github/workflows/coolify-testing-host.yml
|
||||
- docker/testing-host/Dockerfile
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
DOCKER_REGISTRY: docker.io
|
||||
IMAGE_NAME: "coollabsio/coolify-testing-host"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
|
|
@ -32,65 +44,29 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/testing-host/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
aarch64:
|
||||
runs-on: [ self-hosted, arm64 ]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/testing-host/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
|
@ -105,19 +81,21 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- uses: sarisia/actions-status-discord@v1
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -37,3 +37,4 @@ scripts/load-test/*
|
|||
docker/coolify-realtime/node_modules
|
||||
.DS_Store
|
||||
CHANGELOG.md
|
||||
/.workspaces
|
||||
|
|
|
|||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
4
.phpactor.json
Normal file
4
.phpactor.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "/phpactor.schema.json",
|
||||
"language_server_phpstan.enabled": true
|
||||
}
|
||||
7628
CHANGELOG.md
7628
CHANGELOG.md
File diff suppressed because it is too large
Load diff
322
CLAUDE.md
Normal file
322
CLAUDE.md
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to **Claude Code** (claude.ai/code) when working with code in this repository.
|
||||
|
||||
> **Note for AI Assistants**: This file is specifically for Claude Code. All detailed documentation is in the `.ai/` directory. Both Claude Code and Cursor IDE use the same source files in `.ai/` for consistency.
|
||||
>
|
||||
> **Maintaining Instructions**: When updating AI instructions, see [.ai/meta/sync-guide.md](.ai/meta/sync-guide.md) and [.ai/meta/maintaining-docs.md](.ai/meta/maintaining-docs.md) for guidelines.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Coolify is an open-source, self-hostable platform for deploying applications and managing servers - an alternative to Heroku/Netlify/Vercel. It's built with Laravel (PHP) and uses Docker for containerization.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Frontend Development
|
||||
- `npm run dev` - Start Vite development server for frontend assets
|
||||
- `npm run build` - Build frontend assets for production
|
||||
|
||||
### Backend Development
|
||||
Only run artisan commands inside "coolify" container when in development.
|
||||
- `php artisan serve` - Start Laravel development server
|
||||
- `php artisan migrate` - Run database migrations
|
||||
- `php artisan queue:work` - Start queue worker for background jobs
|
||||
- `php artisan horizon` - Start Laravel Horizon for queue monitoring
|
||||
- `php artisan tinker` - Start interactive PHP REPL
|
||||
|
||||
### Code Quality
|
||||
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
|
||||
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
|
||||
- `./vendor/bin/pest tests/Unit` - Run unit tests only (no database, can run outside Docker)
|
||||
- `./vendor/bin/pest` - Run ALL tests (includes Feature tests, may require database)
|
||||
|
||||
### Running Tests
|
||||
**IMPORTANT**: Tests that require database connections MUST be run inside the Docker container:
|
||||
- **Inside Docker**: `docker exec coolify php artisan test` (for feature tests requiring database)
|
||||
- **Outside Docker**: `./vendor/bin/pest tests/Unit` (for pure unit tests without database dependencies)
|
||||
- Unit tests should use mocking and avoid database connections
|
||||
- Feature tests that require database must be run in the `coolify` container
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Technology Stack
|
||||
- **Backend**: Laravel 12.4.1 (PHP 8.4.7)
|
||||
- **Frontend**: Livewire 3.5.20 with Alpine.js and Tailwind CSS 4.1.4
|
||||
- **Database**: PostgreSQL 15 (primary), Redis 7 (cache/queues)
|
||||
- **Real-time**: Soketi (WebSocket server)
|
||||
- **Containerization**: Docker & Docker Compose
|
||||
- **Queue Management**: Laravel Horizon 5.30.3
|
||||
|
||||
> **Note**: For complete version information and all dependencies, see [.ai/core/technology-stack.md](.ai/core/technology-stack.md)
|
||||
|
||||
### Key Components
|
||||
|
||||
#### Core Models
|
||||
- `Application` - Deployed applications with Git integration (74KB, highly complex)
|
||||
- `Server` - Remote servers managed by Coolify (46KB, complex)
|
||||
- `Service` - Docker Compose services (58KB, complex)
|
||||
- `Database` - Standalone database instances (PostgreSQL, MySQL, MongoDB, Redis, etc.)
|
||||
- `Team` - Multi-tenancy support
|
||||
- `Project` - Grouping of environments and resources
|
||||
- `Environment` - Environment isolation (staging, production, etc.)
|
||||
|
||||
#### Job System
|
||||
- Uses Laravel Horizon for queue management
|
||||
- Key jobs: `ApplicationDeploymentJob`, `ServerCheckJob`, `DatabaseBackupJob`
|
||||
- `ServerManagerJob` and `ServerConnectionCheckJob` handle job scheduling
|
||||
|
||||
#### Deployment Flow
|
||||
1. Git webhook triggers deployment
|
||||
2. `ApplicationDeploymentJob` handles build and deployment
|
||||
3. Docker containers are managed on target servers
|
||||
4. Proxy configuration (Nginx/Traefik) is updated
|
||||
|
||||
#### Server Management
|
||||
- SSH-based server communication via `ExecuteRemoteCommand` trait
|
||||
- Docker installation and management
|
||||
- Proxy configuration generation
|
||||
- Resource monitoring and cleanup
|
||||
|
||||
### Directory Structure
|
||||
- `app/Actions/` - Domain-specific actions (Application, Database, Server, etc.)
|
||||
- `app/Jobs/` - Background queue jobs
|
||||
- `app/Livewire/` - Frontend components (full-stack with Livewire)
|
||||
- `app/Models/` - Eloquent models
|
||||
- `app/Rules/` - Custom validation rules
|
||||
- `app/Http/Middleware/` - HTTP middleware
|
||||
- `bootstrap/helpers/` - Helper functions for various domains
|
||||
- `database/migrations/` - Database schema evolution
|
||||
- `routes/` - Application routing (web.php, api.php, webhooks.php, channels.php)
|
||||
- `resources/views/livewire/` - Livewire component views
|
||||
- `tests/` - Pest tests (Feature and Unit)
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Frontend Philosophy
|
||||
Coolify uses a **server-side first** approach with minimal JavaScript:
|
||||
- **Livewire** for server-side rendering with reactive components
|
||||
- **Alpine.js** for lightweight client-side interactions
|
||||
- **Tailwind CSS** for utility-first styling with dark mode support
|
||||
- **Enhanced Form Components** with built-in authorization system
|
||||
- Real-time updates via WebSocket without page refreshes
|
||||
|
||||
### Form Authorization Pattern
|
||||
**IMPORTANT**: When creating or editing forms, ALWAYS include authorization:
|
||||
|
||||
#### For Form Components (Input, Select, Textarea, Checkbox, Button):
|
||||
Use `canGate` and `canResource` attributes for automatic authorization:
|
||||
```html
|
||||
<x-forms.input canGate="update" :canResource="$resource" id="name" label="Name" />
|
||||
<x-forms.select canGate="update" :canResource="$resource" id="type" label="Type">...</x-forms.select>
|
||||
<x-forms.checkbox instantSave canGate="update" :canResource="$resource" id="enabled" label="Enabled" />
|
||||
<x-forms.button canGate="update" :canResource="$resource" type="submit">Save</x-forms.button>
|
||||
```
|
||||
|
||||
#### For Modal Components:
|
||||
Wrap with `@can` directives:
|
||||
```html
|
||||
@can('update', $resource)
|
||||
<x-modal-confirmation title="Confirm Action?" buttonTitle="Confirm">...</x-modal-confirmation>
|
||||
<x-modal-input buttonTitle="Edit" title="Edit Settings">...</x-modal-input>
|
||||
@endcan
|
||||
```
|
||||
|
||||
#### In Livewire Components:
|
||||
Always add the `AuthorizesRequests` trait and check permissions:
|
||||
```php
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
class MyComponent extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('view', $this->resource);
|
||||
}
|
||||
|
||||
public function update()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
// ... update logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Livewire Component Structure
|
||||
- Components located in `app/Livewire/`
|
||||
- Views in `resources/views/livewire/`
|
||||
- State management handled on the server
|
||||
- Use wire:model for two-way data binding
|
||||
- Dispatch events for component communication
|
||||
- **CRITICAL**: Livewire component views **MUST** have exactly ONE root element. ALL content must be contained within this single root element. Placing ANY elements (`<style>`, `<script>`, `<div>`, comments, or any other HTML) outside the root element will break Livewire's component tracking and cause `wire:click` and other directives to fail silently.
|
||||
|
||||
### Code Organization Patterns
|
||||
- **Actions Pattern**: Use Actions for complex business logic (`app/Actions/`)
|
||||
- **Livewire Components**: Handle UI and user interactions
|
||||
- **Jobs**: Handle asynchronous operations
|
||||
- **Traits**: Provide shared functionality (e.g., `ExecuteRemoteCommand`)
|
||||
- **Helper Functions**: Domain-specific helpers in `bootstrap/helpers/`
|
||||
|
||||
### Database Patterns
|
||||
- Use Eloquent ORM for database interactions
|
||||
- Implement relationships properly (HasMany, BelongsTo, etc.)
|
||||
- Use database transactions for critical operations
|
||||
- Leverage query scopes for reusable queries
|
||||
- Apply indexes for performance-critical queries
|
||||
- **CRITICAL**: When adding new database columns, ALWAYS update the model's `$fillable` array to allow mass assignment
|
||||
|
||||
### Security Best Practices
|
||||
- **Authentication**: Multi-provider auth via Laravel Fortify & Sanctum
|
||||
- **Authorization**: Team-based access control with policies and enhanced form components
|
||||
- **Form Component Security**: Built-in `canGate` authorization system for UI components
|
||||
- **API Security**: Token-based auth with IP allowlisting
|
||||
- **Secrets Management**: Never log or expose sensitive data
|
||||
- **Input Validation**: Always validate user input with Form Requests or Rules
|
||||
- **SQL Injection Prevention**: Use Eloquent ORM or parameterized queries
|
||||
|
||||
### API Development
|
||||
- RESTful endpoints in `routes/api.php`
|
||||
- Use API Resources for response formatting
|
||||
- Implement rate limiting for public endpoints
|
||||
- Version APIs when making breaking changes
|
||||
- Document endpoints with clear examples
|
||||
|
||||
### Testing Strategy
|
||||
- **Framework**: Pest for expressive testing
|
||||
- **Structure**: Feature tests for user flows, Unit tests for isolated logic
|
||||
- **Coverage**: Test critical paths and edge cases
|
||||
- **Mocking**: Use Laravel's built-in mocking for external services
|
||||
- **Database**: Use RefreshDatabase trait for test isolation
|
||||
|
||||
#### Test Execution Environment
|
||||
**CRITICAL**: Database-dependent tests MUST run inside Docker container:
|
||||
- **Unit Tests** (`tests/Unit/`): Should NOT use database. Use mocking. Run with `./vendor/bin/pest tests/Unit`
|
||||
- **Feature Tests** (`tests/Feature/`): May use database. MUST run inside Docker with `docker exec coolify php artisan test`
|
||||
- If a test needs database (factories, migrations, etc.), it belongs in `tests/Feature/`
|
||||
- Always mock external services and SSH connections in tests
|
||||
|
||||
#### Test Design Philosophy
|
||||
**PREFER MOCKING**: When designing features and writing tests:
|
||||
- **Design for testability**: Structure code so it can be tested without database (use dependency injection, interfaces)
|
||||
- **Mock by default**: Unit tests should mock models and external dependencies using Mockery
|
||||
- **Avoid database when possible**: If you can test the logic without database, write it as a Unit test
|
||||
- **Only use database when necessary**: Feature tests should test integration points, not isolated logic
|
||||
- **Example**: Instead of `Server::factory()->create()`, use `Mockery::mock('App\Models\Server')` in unit tests
|
||||
|
||||
### Routing Conventions
|
||||
- Group routes by middleware and prefix
|
||||
- Use route model binding for cleaner controllers
|
||||
- Name routes consistently (resource.action)
|
||||
- Implement proper HTTP verbs (GET, POST, PUT, DELETE)
|
||||
|
||||
### Error Handling
|
||||
- Use `handleError()` helper for consistent error handling
|
||||
- Log errors with appropriate context
|
||||
- Return user-friendly error messages
|
||||
- Implement proper HTTP status codes
|
||||
|
||||
### Performance Considerations
|
||||
- Use eager loading to prevent N+1 queries
|
||||
- Implement caching for frequently accessed data
|
||||
- Queue heavy operations
|
||||
- Optimize database queries with proper indexes
|
||||
- Use chunking for large data operations
|
||||
- **CRITICAL**: Use `ownedByCurrentTeamCached()` instead of `ownedByCurrentTeam()->get()`
|
||||
|
||||
### Code Style
|
||||
- Follow PSR-12 coding standards
|
||||
- Use Laravel Pint for automatic formatting
|
||||
- Write descriptive variable and method names
|
||||
- Keep methods small and focused
|
||||
- Document complex logic with clear comments
|
||||
|
||||
## Cloud Instance Considerations
|
||||
|
||||
We have a cloud instance of Coolify (hosted version) with:
|
||||
- 2 Horizon worker servers
|
||||
- Thousands of connected servers
|
||||
- Thousands of active users
|
||||
- High-availability requirements
|
||||
|
||||
When developing features:
|
||||
- Consider scalability implications
|
||||
- Test with large datasets
|
||||
- Implement efficient queries
|
||||
- Use queues for heavy operations
|
||||
- Consider rate limiting and resource constraints
|
||||
- Implement proper error recovery mechanisms
|
||||
|
||||
## Important Reminders
|
||||
|
||||
- Always run code formatting: `./vendor/bin/pint`
|
||||
- Test your changes: `./vendor/bin/pest`
|
||||
- Check for static analysis issues: `./vendor/bin/phpstan`
|
||||
- Use existing patterns and helpers
|
||||
- Follow the established directory structure
|
||||
- Maintain backward compatibility
|
||||
- Document breaking changes
|
||||
- Consider performance impact on large-scale deployments
|
||||
|
||||
## Additional Documentation
|
||||
|
||||
This file contains high-level guidelines for Claude Code. For **more detailed, topic-specific documentation**, refer to the `.ai/` directory:
|
||||
|
||||
> **Documentation Hub**: The `.ai/` directory contains comprehensive, detailed documentation organized by topic. Start with [.ai/README.md](.ai/README.md) for navigation, then explore specific topics below.
|
||||
|
||||
### Core Documentation
|
||||
- [Technology Stack](.ai/core/technology-stack.md) - All versions, packages, and dependencies (single source of truth)
|
||||
- [Project Overview](.ai/core/project-overview.md) - What Coolify is and how it works
|
||||
- [Application Architecture](.ai/core/application-architecture.md) - System design and component relationships
|
||||
- [Deployment Architecture](.ai/core/deployment-architecture.md) - How deployments work end-to-end
|
||||
|
||||
### Development Practices
|
||||
- [Development Workflow](.ai/development/development-workflow.md) - Development setup, commands, and workflows
|
||||
- [Testing Patterns](.ai/development/testing-patterns.md) - Testing strategies and examples (Docker requirements!)
|
||||
- [Laravel Boost](.ai/development/laravel-boost.md) - Laravel-specific guidelines and best practices
|
||||
|
||||
### Code Patterns
|
||||
- [Database Patterns](.ai/patterns/database-patterns.md) - Eloquent, migrations, relationships
|
||||
- [Frontend Patterns](.ai/patterns/frontend-patterns.md) - Livewire, Alpine.js, Tailwind CSS
|
||||
- [Security Patterns](.ai/patterns/security-patterns.md) - Authentication, authorization, security
|
||||
- [Form Components](.ai/patterns/form-components.md) - Enhanced form components with authorization
|
||||
- [API & Routing](.ai/patterns/api-and-routing.md) - API design and routing conventions
|
||||
|
||||
### Meta Documentation
|
||||
- [Maintaining Docs](.ai/meta/maintaining-docs.md) - How to update and improve AI documentation
|
||||
- [Sync Guide](.ai/meta/sync-guide.md) - Keeping documentation synchronized
|
||||
|
||||
## Laravel Boost Guidelines
|
||||
|
||||
> **Full Guidelines**: See [.ai/development/laravel-boost.md](.ai/development/laravel-boost.md) for complete Laravel Boost guidelines.
|
||||
|
||||
### Essential Laravel Patterns
|
||||
|
||||
- Use PHP 8.4 constructor property promotion and typed properties
|
||||
- Follow PSR-12 (run `./vendor/bin/pint` before committing)
|
||||
- Use Eloquent ORM, avoid raw queries
|
||||
- Use Form Request classes for validation
|
||||
- Queue heavy operations with Laravel Horizon
|
||||
- Never use `env()` outside config files
|
||||
- Use named routes with `route()` function
|
||||
- Laravel 12 with Laravel 10 structure (no bootstrap/app.php)
|
||||
|
||||
### Testing Requirements
|
||||
|
||||
- **Unit tests**: No database, use mocking, run with `./vendor/bin/pest tests/Unit`
|
||||
- **Feature tests**: Can use database, run with `docker exec coolify php artisan test`
|
||||
- Every change must have tests
|
||||
- Use Pest for all tests
|
||||
|
||||
### Livewire & Frontend
|
||||
|
||||
- Livewire components require single root element
|
||||
- Use `wire:model.live` for real-time updates
|
||||
- Alpine.js included with Livewire
|
||||
- Tailwind CSS 4.1.4 (use new utilities, not deprecated ones)
|
||||
- Use `gap` utilities for spacing, not margins
|
||||
|
||||
|
||||
Random other things you should remember:
|
||||
- App\Models\Application::team must return a relationship instance., always use team()
|
||||
- Always use `Model::ownedByCurrentTeamCached()` instead of `Model::ownedByCurrentTeam()->get()` for team-scoped queries to avoid duplicate database queries
|
||||
87
README.md
87
README.md
|
|
@ -1,9 +1,13 @@
|
|||

|
||||
<div align="center">
|
||||
|
||||
[](https://console.algora.io/org/coollabsio/bounties/new)
|
||||
# Coolify
|
||||
An open-source & self-hostable Heroku / Netlify / Vercel alternative.
|
||||
|
||||
# About the Project
|
||||
 [](https://console.algora.io/org/coollabsio/bounties/new)
|
||||
</div>
|
||||
|
||||
## About the Project
|
||||
|
||||
Coolify is an open-source & self-hostable alternative to Heroku / Netlify / Vercel / etc.
|
||||
|
||||
|
|
@ -15,7 +19,7 @@ # About the Project
|
|||
|
||||
For more information, take a look at our landing page at [coolify.io](https://coolify.io).
|
||||
|
||||
# Installation
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||
|
|
@ -25,11 +29,11 @@ # Installation
|
|||
> [!NOTE]
|
||||
> Please refer to the [docs](https://coolify.io/docs/installation) for more information about the installation.
|
||||
|
||||
# Support
|
||||
## Support
|
||||
|
||||
Contact us at [coolify.io/docs/contact](https://coolify.io/docs/contact).
|
||||
|
||||
# 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)
|
||||
|
||||
|
|
@ -44,51 +48,54 @@ ## Why should I use the Cloud version?
|
|||
- Better support
|
||||
- Less maintenance for you
|
||||
|
||||
# Donations
|
||||
## 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
|
||||
### Big Sponsors
|
||||
|
||||
* [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management
|
||||
* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions!
|
||||
* [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
|
||||
* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services
|
||||
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
|
||||
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
|
||||
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
|
||||
* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
|
||||
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
|
||||
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
|
||||
* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor
|
||||
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform
|
||||
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
|
||||
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
|
||||
* [Dade2](https://dade2.net/?ref=coolify.io) - IT Consulting, Cloud Solutions & System Integration
|
||||
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
|
||||
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
|
||||
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
|
||||
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
|
||||
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
|
||||
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
|
||||
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
|
||||
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
|
||||
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
|
||||
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
|
||||
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
|
||||
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
|
||||
* [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
|
||||
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform
|
||||
* [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
|
||||
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
|
||||
|
||||
## Small Sponsors
|
||||
|
||||
### Small Sponsors
|
||||
|
||||
<a href="https://open-elements.com/?utm_source=coolify.io"><img width="60px" alt="OpenElements" src="https://github.com/OpenElements.png"/></a>
|
||||
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="XamanApp" src="https://github.com/XamanApp.png"/></a>
|
||||
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
|
||||
<a href="https://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>
|
||||
|
|
@ -138,7 +145,7 @@ ## Small Sponsors
|
|||
|
||||
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
|
||||
|
||||
# Recognitions
|
||||
## Recognitions
|
||||
|
||||
<p>
|
||||
<a href="https://news.ycombinator.com/item?id=26624341">
|
||||
|
|
@ -154,17 +161,17 @@ # Recognitions
|
|||
|
||||
<a href="https://trendshift.io/repositories/634" target="_blank"><img src="https://trendshift.io/api/badge/repositories/634" alt="coollabsio%2Fcoolify | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
# Core Maintainers
|
||||
## Core Maintainers
|
||||
|
||||
| Andras Bacsai | 🏔️ Peak |
|
||||
|------------|------------|
|
||||
| <img src="https://github.com/andrasbacsai.png" width="200px" alt="Andras Bacsai" /> | <img src="https://github.com/peaklabs-dev.png" width="200px" alt="peaklabs-dev" /> |
|
||||
| <a href="https://github.com/andrasbacsai"><img src="https://api.iconify.design/devicon:github.svg" width="25px"></a> <a href="https://x.com/heyandras"><img src="https://api.iconify.design/devicon:twitter.svg" width="25px"></a> <a href="https://bsky.app/profile/heyandras.dev"><img src="https://api.iconify.design/simple-icons:bluesky.svg" width="25px"></a> | <a href="https://github.com/peaklabs-dev"><img src="https://api.iconify.design/devicon:github.svg" width="25px"></a> <a href="https://x.com/peaklabs_dev"><img src="https://api.iconify.design/devicon:twitter.svg" width="25px"></a> <a href="https://bsky.app/profile/peaklabs.dev"><img src="https://api.iconify.design/simple-icons:bluesky.svg" width="25px"></a> |
|
||||
|
||||
# Repo Activity
|
||||
## Repo Activity
|
||||
|
||||

|
||||
|
||||
# Star History
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#coollabsio/coolify&Date)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ ## Reporting a Vulnerability
|
|||
If you discover a security vulnerability, please follow these steps:
|
||||
|
||||
1. **DO NOT** disclose the vulnerability publicly.
|
||||
2. Send a detailed report to: `hi@coollabs.io`.
|
||||
2. Send a detailed report to: `security@coollabs.io`.
|
||||
3. Include in your report:
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce the issue
|
||||
|
|
|
|||
176
app/Actions/Application/CleanupPreviewDeployment.php
Normal file
176
app/Actions/Application/CleanupPreviewDeployment.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Application;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class CleanupPreviewDeployment
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
/**
|
||||
* Clean up a PR preview deployment completely.
|
||||
*
|
||||
* This handles:
|
||||
* 1. Cancelling active deployments for the PR (QUEUED/IN_PROGRESS → CANCELLED_BY_USER)
|
||||
* 2. Killing helper containers by deployment_uuid
|
||||
* 3. Stopping/removing all running PR containers
|
||||
* 4. Dispatching DeleteResourceJob for thorough cleanup (volumes, networks, database records)
|
||||
*
|
||||
* This unifies the cleanup logic from GitHub webhook handler to be used across all providers.
|
||||
*/
|
||||
public function handle(
|
||||
Application $application,
|
||||
int $pull_request_id,
|
||||
?ApplicationPreview $preview = null
|
||||
): array {
|
||||
$result = [
|
||||
'cancelled_deployments' => 0,
|
||||
'killed_containers' => 0,
|
||||
'status' => 'success',
|
||||
];
|
||||
|
||||
$server = $application->destination->server;
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return [
|
||||
...$result,
|
||||
'status' => 'failed',
|
||||
'message' => 'Server is not functional',
|
||||
];
|
||||
}
|
||||
|
||||
// Step 1: Cancel all active deployments for this PR and kill helper containers
|
||||
$result['cancelled_deployments'] = $this->cancelActiveDeployments(
|
||||
$application,
|
||||
$pull_request_id,
|
||||
$server
|
||||
);
|
||||
|
||||
// Step 2: Stop and remove all running PR containers
|
||||
$result['killed_containers'] = $this->stopRunningContainers(
|
||||
$application,
|
||||
$pull_request_id,
|
||||
$server
|
||||
);
|
||||
|
||||
// Step 3: Find or use provided preview, then dispatch cleanup job for thorough cleanup
|
||||
if (! $preview) {
|
||||
$preview = ApplicationPreview::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->first();
|
||||
}
|
||||
|
||||
if ($preview) {
|
||||
DeleteResourceJob::dispatch($preview);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all active (QUEUED/IN_PROGRESS) deployments for this PR.
|
||||
*/
|
||||
private function cancelActiveDeployments(
|
||||
Application $application,
|
||||
int $pull_request_id,
|
||||
$server
|
||||
): int {
|
||||
$activeDeployments = ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->whereIn('status', [
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->get();
|
||||
|
||||
$cancelled = 0;
|
||||
foreach ($activeDeployments as $deployment) {
|
||||
try {
|
||||
// Mark deployment as cancelled
|
||||
$deployment->update([
|
||||
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Add cancellation log entry
|
||||
$deployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||
|
||||
// Try to kill helper container if it exists
|
||||
$this->killHelperContainer($deployment->deployment_uuid, $server);
|
||||
$cancelled++;
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning("Failed to cancel deployment {$deployment->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
return $cancelled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the helper container used during deployment.
|
||||
*/
|
||||
private function killHelperContainer(string $deployment_uuid, $server): void
|
||||
{
|
||||
try {
|
||||
$escapedUuid = escapeshellarg($deployment_uuid);
|
||||
$checkCommand = "docker ps -a --filter name={$escapedUuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process(["docker rm -f {$escapedUuid}"], $server);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Silently handle - container may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and remove all running containers for this PR.
|
||||
*/
|
||||
private function stopRunningContainers(
|
||||
Application $application,
|
||||
int $pull_request_id,
|
||||
$server
|
||||
): int {
|
||||
$killed = 0;
|
||||
|
||||
try {
|
||||
if ($server->isSwarm()) {
|
||||
$escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
|
||||
instant_remote_process(["docker stack rm {$escapedStackName}"], $server);
|
||||
$killed++;
|
||||
} else {
|
||||
$containers = getCurrentApplicationContainerStatus(
|
||||
$server,
|
||||
$application->id,
|
||||
$pull_request_id
|
||||
);
|
||||
|
||||
if ($containers->isNotEmpty()) {
|
||||
foreach ($containers as $container) {
|
||||
$containerName = data_get($container, 'Names');
|
||||
if ($containerName) {
|
||||
$escapedContainerName = escapeshellarg($containerName);
|
||||
instant_remote_process(
|
||||
["docker rm -f {$escapedContainerName}"],
|
||||
$server
|
||||
);
|
||||
$killed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning("Error stopping containers for PR #{$pull_request_id}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
return $killed;
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ public function handle(Application $application, bool $previewDeployments = fals
|
|||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=30 $containerName",
|
||||
"docker stop -t 30 $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ public function handle(Application $application, bool $previewDeployments = fals
|
|||
}
|
||||
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, true);
|
||||
CleanupDocker::dispatch($server, false, false);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return $e->getMessage();
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ public function handle(Application $application, Server $server)
|
|||
if ($containerName) {
|
||||
instant_remote_process(
|
||||
[
|
||||
"docker stop --time=30 $containerName",
|
||||
"docker stop -t 30 $containerName",
|
||||
"docker rm -f $containerName",
|
||||
],
|
||||
$server
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
|
|||
if (! $server->isFunctional()) {
|
||||
return 'Server is not functional';
|
||||
}
|
||||
StopDatabase::run($database);
|
||||
StopDatabase::run($database, dockerCleanup: false);
|
||||
|
||||
return StartDatabase::run($database);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ public function handle(StandaloneClickhouse $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -55,11 +55,11 @@ public function handle(StandaloneDragonfly $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();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -192,6 +192,8 @@ public function handle(StandaloneDragonfly $database)
|
|||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||
}
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -56,11 +56,11 @@ public function handle(StandaloneKeydb $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();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -208,6 +208,8 @@ public function handle(StandaloneKeydb $database)
|
|||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||
}
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -57,11 +57,11 @@ public function handle(StandaloneMariadb $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();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -209,6 +209,8 @@ public function handle(StandaloneMariadb $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
if ($this->database->enable_ssl) {
|
||||
|
|
|
|||
|
|
@ -61,11 +61,11 @@ public function handle(StandaloneMongodb $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();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -260,6 +260,8 @@ public function handle(StandaloneMongodb $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = executeInDocker($this->database->uuid, 'chown mongodb:mongodb /etc/mongo/certs/server.pem');
|
||||
|
|
|
|||
|
|
@ -57,11 +57,11 @@ public function handle(StandaloneMysql $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();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -210,6 +210,8 @@ public function handle(StandaloneMysql $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
|
|
|
|||
|
|
@ -62,11 +62,11 @@ public function handle(StandalonePostgresql $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();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -185,6 +185,8 @@ public function handle(StandalonePostgresql $database)
|
|||
}
|
||||
}
|
||||
|
||||
$command = ['postgres'];
|
||||
|
||||
if (filled($this->database->postgres_conf)) {
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'],
|
||||
|
|
@ -195,29 +197,25 @@ public function handle(StandalonePostgresql $database)
|
|||
'read_only' => true,
|
||||
]]
|
||||
);
|
||||
$docker_compose['services'][$container_name]['command'] = [
|
||||
'postgres',
|
||||
'-c',
|
||||
'config_file=/etc/postgresql/postgresql.conf',
|
||||
];
|
||||
$command = array_merge($command, ['-c', 'config_file=/etc/postgresql/postgresql.conf']);
|
||||
}
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
$docker_compose['services'][$container_name]['command'] = [
|
||||
'postgres',
|
||||
'-c',
|
||||
'ssl=on',
|
||||
'-c',
|
||||
'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
|
||||
'-c',
|
||||
'ssl_key_file=/var/lib/postgresql/certs/server.key',
|
||||
];
|
||||
$command = array_merge($command, [
|
||||
'-c', 'ssl=on',
|
||||
'-c', 'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
|
||||
'-c', 'ssl_key_file=/var/lib/postgresql/certs/server.key',
|
||||
]);
|
||||
}
|
||||
|
||||
// Add custom docker run options
|
||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||
|
||||
if (count($command) > 1) {
|
||||
$docker_compose['services'][$container_name]['command'] = $command;
|
||||
}
|
||||
|
||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||
$docker_compose_base64 = base64_encode($docker_compose);
|
||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||
|
|
@ -225,6 +223,8 @@ public function handle(StandalonePostgresql $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
if ($this->database->enable_ssl) {
|
||||
$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");
|
||||
|
|
|
|||
|
|
@ -56,11 +56,11 @@ public function handle(StandaloneRedis $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();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
|
||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
|
|
@ -205,6 +205,8 @@ public function handle(StandaloneRedis $database)
|
|||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||
}
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class StopDatabase
|
|||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $isDeleteOperation = false, bool $dockerCleanup = true)
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true)
|
||||
{
|
||||
try {
|
||||
$server = $database->destination->server;
|
||||
|
|
@ -29,7 +29,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
|
|||
$this->stopContainer($database, $database->uuid, 30);
|
||||
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, true);
|
||||
CleanupDocker::dispatch($server, false, false);
|
||||
}
|
||||
|
||||
if ($database->is_public) {
|
||||
|
|
@ -49,7 +49,7 @@ private function stopContainer($database, string $containerName, int $timeout =
|
|||
{
|
||||
$server = $database->destination->server;
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=$timeout $containerName",
|
||||
"docker stop -t $timeout $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@
|
|||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Traits\CalculatesExcludedStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class GetContainersStatus
|
||||
{
|
||||
use AsAction;
|
||||
use CalculatesExcludedStatus;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
|
|
@ -26,6 +30,12 @@ class GetContainersStatus
|
|||
|
||||
public $server;
|
||||
|
||||
protected ?Collection $applicationContainerStatuses;
|
||||
|
||||
protected ?Collection $applicationContainerRestartCounts;
|
||||
|
||||
protected ?Collection $serviceContainerStatuses;
|
||||
|
||||
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
||||
{
|
||||
$this->containers = $containers;
|
||||
|
|
@ -93,8 +103,16 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
$labels = data_get($container, 'Config.Labels');
|
||||
}
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
$containerStatus = "$containerStatus ($containerHealth)";
|
||||
$containerHealth = data_get($container, 'State.Health.Status');
|
||||
if ($containerStatus === 'restarting') {
|
||||
$healthSuffix = $containerHealth ?? 'unknown';
|
||||
$containerStatus = "restarting:$healthSuffix";
|
||||
} elseif ($containerStatus === 'exited') {
|
||||
// Keep as-is, no health suffix for exited containers
|
||||
} else {
|
||||
$healthSuffix = $containerHealth ?? 'unknown';
|
||||
$containerStatus = "$containerStatus:$healthSuffix";
|
||||
}
|
||||
$labels = Arr::undot(format_docker_labels_to_json($labels));
|
||||
$applicationId = data_get($labels, 'coolify.applicationId');
|
||||
if ($applicationId) {
|
||||
|
|
@ -119,11 +137,28 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
if ($application) {
|
||||
$foundApplications[] = $application->id;
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$application->update(['status' => $containerStatus]);
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
// Store container status for aggregation
|
||||
if (! isset($this->applicationContainerStatuses)) {
|
||||
$this->applicationContainerStatuses = collect();
|
||||
}
|
||||
if (! $this->applicationContainerStatuses->has($applicationId)) {
|
||||
$this->applicationContainerStatuses->put($applicationId, collect());
|
||||
}
|
||||
$containerName = data_get($labels, 'com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||
}
|
||||
|
||||
// Track restart counts for applications
|
||||
$restartCount = data_get($container, 'RestartCount', 0);
|
||||
if (! isset($this->applicationContainerRestartCounts)) {
|
||||
$this->applicationContainerRestartCounts = collect();
|
||||
}
|
||||
if (! $this->applicationContainerRestartCounts->has($applicationId)) {
|
||||
$this->applicationContainerRestartCounts->put($applicationId, collect());
|
||||
}
|
||||
if ($containerName) {
|
||||
$this->applicationContainerRestartCounts->get($applicationId)->put($containerName, $restartCount);
|
||||
}
|
||||
} else {
|
||||
// Notify user that this container should not be there.
|
||||
|
|
@ -196,23 +231,34 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
if ($serviceLabelId) {
|
||||
$subType = data_get($labels, 'coolify.service.subType');
|
||||
$subId = data_get($labels, 'coolify.service.subId');
|
||||
$service = $services->where('id', $serviceLabelId)->first();
|
||||
if (! $service) {
|
||||
$parentService = $services->where('id', $serviceLabelId)->first();
|
||||
if (! $parentService) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store container status for aggregation
|
||||
if (! isset($this->serviceContainerStatuses)) {
|
||||
$this->serviceContainerStatuses = collect();
|
||||
}
|
||||
|
||||
$key = $serviceLabelId.':'.$subType.':'.$subId;
|
||||
if (! $this->serviceContainerStatuses->has($key)) {
|
||||
$this->serviceContainerStatuses->put($key, collect());
|
||||
}
|
||||
|
||||
$containerName = data_get($labels, 'com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
|
||||
}
|
||||
|
||||
// Mark service as found
|
||||
if ($subType === 'application') {
|
||||
$service = $service->applications()->where('id', $subId)->first();
|
||||
$service = $parentService->applications()->where('id', $subId)->first();
|
||||
} else {
|
||||
$service = $service->databases()->where('id', $subId)->first();
|
||||
$service = $parentService->databases()->where('id', $subId)->first();
|
||||
}
|
||||
if ($service) {
|
||||
$foundServices[] = "$service->id-$service->name";
|
||||
$statusFromDb = $service->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$service->update(['status' => $containerStatus]);
|
||||
} else {
|
||||
$service->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -280,7 +326,24 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
continue;
|
||||
}
|
||||
|
||||
$application->update(['status' => 'exited']);
|
||||
// If container was recently restarting (crash loop), keep it as degraded for a grace period
|
||||
// This prevents false "exited" status during the brief moment between container removal and recreation
|
||||
$recentlyRestarted = $application->restart_count > 0 &&
|
||||
$application->last_restart_at &&
|
||||
$application->last_restart_at->greaterThan(now()->subSeconds(30));
|
||||
|
||||
if ($recentlyRestarted) {
|
||||
// Keep it as degraded if it was recently in a crash loop
|
||||
$application->update(['status' => 'degraded:unhealthy']);
|
||||
} else {
|
||||
// Reset restart count when application exits completely
|
||||
$application->update([
|
||||
'status' => 'exited',
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
$notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews);
|
||||
foreach ($notRunningApplicationPreviews as $previewId) {
|
||||
|
|
@ -320,6 +383,155 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
}
|
||||
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
|
||||
}
|
||||
|
||||
// Aggregate multi-container application statuses
|
||||
if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) {
|
||||
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
if (! $application) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track restart counts first
|
||||
$maxRestartCount = 0;
|
||||
if (isset($this->applicationContainerRestartCounts) && $this->applicationContainerRestartCounts->has($applicationId)) {
|
||||
$containerRestartCounts = $this->applicationContainerRestartCounts->get($applicationId);
|
||||
$maxRestartCount = $containerRestartCounts->max() ?? 0;
|
||||
}
|
||||
|
||||
// Wrap all database updates in a transaction to ensure consistency
|
||||
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
|
||||
$previousRestartCount = $application->restart_count ?? 0;
|
||||
|
||||
if ($maxRestartCount > $previousRestartCount) {
|
||||
// Restart count increased - this is a crash restart
|
||||
$application->update([
|
||||
'restart_count' => $maxRestartCount,
|
||||
'last_restart_at' => now(),
|
||||
'last_restart_type' => 'crash',
|
||||
]);
|
||||
|
||||
// Send notification
|
||||
$containerName = $application->name;
|
||||
$projectUuid = data_get($application, 'environment.project.uuid');
|
||||
$environmentName = data_get($application, 'environment.name');
|
||||
$applicationUuid = data_get($application, 'uuid');
|
||||
|
||||
if ($projectUuid && $applicationUuid && $environmentName) {
|
||||
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
|
||||
} else {
|
||||
$url = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate status after tracking restart counts
|
||||
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount);
|
||||
if ($aggregatedStatus) {
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $aggregatedStatus) {
|
||||
$application->update(['status' => $aggregatedStatus]);
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate multi-container service statuses
|
||||
$this->aggregateServiceContainerStatuses($services);
|
||||
|
||||
ServiceChecked::dispatch($this->server->team->id);
|
||||
}
|
||||
|
||||
private function aggregateApplicationStatus($application, Collection $containerStatuses, int $maxRestartCount = 0): ?string
|
||||
{
|
||||
// Parse docker compose to check for excluded containers
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||
|
||||
// Filter out excluded containers
|
||||
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||
return ! $excludedContainers->contains($containerName);
|
||||
});
|
||||
|
||||
// If all containers are excluded, calculate status from excluded containers
|
||||
if ($relevantStatuses->isEmpty()) {
|
||||
return $this->calculateExcludedStatusFromStrings($containerStatuses);
|
||||
}
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
// Use preserveRestarting: true so applications show "Restarting" instead of "Degraded"
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
|
||||
return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true);
|
||||
}
|
||||
|
||||
private function aggregateServiceContainerStatuses($services)
|
||||
{
|
||||
if (! isset($this->serviceContainerStatuses) || $this->serviceContainerStatuses->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->serviceContainerStatuses as $key => $containerStatuses) {
|
||||
// Parse key: serviceId:subType:subId
|
||||
[$serviceId, $subType, $subId] = explode(':', $key);
|
||||
|
||||
$service = $services->where('id', $serviceId)->first();
|
||||
if (! $service) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
|
||||
$subResource = null;
|
||||
if ($subType === 'application') {
|
||||
$subResource = $service->applications()->where('id', $subId)->first();
|
||||
} elseif ($subType === 'database') {
|
||||
$subResource = $service->databases()->where('id', $subId)->first();
|
||||
}
|
||||
|
||||
if (! $subResource) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse docker compose from service to check for excluded containers
|
||||
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
|
||||
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||
|
||||
// Filter out excluded containers
|
||||
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||
return ! $excludedContainers->contains($containerName);
|
||||
});
|
||||
|
||||
// If all containers are excluded, calculate status from excluded containers
|
||||
if ($relevantStatuses->isEmpty()) {
|
||||
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
|
||||
if ($aggregatedStatus) {
|
||||
$statusFromDb = $subResource->status;
|
||||
if ($statusFromDb !== $aggregatedStatus) {
|
||||
$subResource->update(['status' => $aggregatedStatus]);
|
||||
} else {
|
||||
$subResource->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
// Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded"
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, preserveRestarting: true);
|
||||
|
||||
// Update service sub-resource status with aggregated result
|
||||
if ($aggregatedStatus) {
|
||||
$statusFromDb = $subResource->status;
|
||||
if ($statusFromDb !== $aggregatedStatus) {
|
||||
$subResource->update(['status' => $aggregatedStatus]);
|
||||
} else {
|
||||
$subResource->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ public function create(array $input): User
|
|||
$user = User::create([
|
||||
'id' => 0,
|
||||
'name' => $input['name'],
|
||||
'email' => strtolower($input['email']),
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
$team = $user->teams()->first();
|
||||
|
|
@ -52,7 +52,7 @@ public function create(array $input): User
|
|||
} else {
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => strtolower($input['email']),
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
$team = $user->teams()->first();
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Proxy;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\ProxyDashboardCacheService;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class CheckConfiguration
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, bool $reset = false)
|
||||
{
|
||||
$proxyType = $server->proxyType();
|
||||
if ($proxyType === 'NONE') {
|
||||
return 'OK';
|
||||
}
|
||||
$proxy_path = $server->proxyPath();
|
||||
$payload = [
|
||||
"mkdir -p $proxy_path",
|
||||
"cat $proxy_path/docker-compose.yml",
|
||||
];
|
||||
$proxy_configuration = instant_remote_process($payload, $server, false);
|
||||
if ($reset || ! $proxy_configuration || is_null($proxy_configuration)) {
|
||||
$proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value();
|
||||
}
|
||||
if (! $proxy_configuration || is_null($proxy_configuration)) {
|
||||
throw new \Exception('Could not generate proxy configuration');
|
||||
}
|
||||
|
||||
ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration);
|
||||
|
||||
return $proxy_configuration;
|
||||
}
|
||||
}
|
||||
|
|
@ -66,11 +66,11 @@ public function handle(Server $server, $fromUI = false): bool
|
|||
if ($server->id === 0) {
|
||||
$ip = 'host.docker.internal';
|
||||
}
|
||||
$portsToCheck = ['80', '443'];
|
||||
$portsToCheck = [];
|
||||
|
||||
try {
|
||||
if ($server->proxyType() !== ProxyTypes::NONE->value) {
|
||||
$proxyCompose = CheckConfiguration::run($server);
|
||||
$proxyCompose = GetProxyConfiguration::run($server);
|
||||
if (isset($proxyCompose)) {
|
||||
$yaml = Yaml::parse($proxyCompose);
|
||||
$configPorts = [];
|
||||
|
|
|
|||
53
app/Actions/Proxy/GetProxyConfiguration.php
Normal file
53
app/Actions/Proxy/GetProxyConfiguration.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Proxy;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\ProxyDashboardCacheService;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class GetProxyConfiguration
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, bool $forceRegenerate = false): string
|
||||
{
|
||||
$proxyType = $server->proxyType();
|
||||
if ($proxyType === 'NONE') {
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
$proxy_path = $server->proxyPath();
|
||||
$proxy_configuration = null;
|
||||
|
||||
// If not forcing regeneration, try to read existing configuration
|
||||
if (! $forceRegenerate) {
|
||||
$payload = [
|
||||
"mkdir -p $proxy_path",
|
||||
"cat $proxy_path/docker-compose.yml 2>/dev/null",
|
||||
];
|
||||
$proxy_configuration = instant_remote_process($payload, $server, false);
|
||||
}
|
||||
|
||||
// Generate default configuration if:
|
||||
// 1. Force regenerate is requested
|
||||
// 2. Configuration file doesn't exist or is empty
|
||||
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
|
||||
// Extract custom commands from existing config before regenerating
|
||||
$custom_commands = [];
|
||||
if (! empty(trim($proxy_configuration ?? ''))) {
|
||||
$custom_commands = extractCustomProxyCommands($server, $proxy_configuration);
|
||||
}
|
||||
|
||||
$proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value();
|
||||
}
|
||||
|
||||
if (empty($proxy_configuration)) {
|
||||
throw new \Exception('Could not get or generate proxy configuration');
|
||||
}
|
||||
|
||||
ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration);
|
||||
|
||||
return $proxy_configuration;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,22 +5,21 @@
|
|||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class SaveConfiguration
|
||||
class SaveProxyConfiguration
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, ?string $proxy_settings = null)
|
||||
public function handle(Server $server, string $configuration): void
|
||||
{
|
||||
if (is_null($proxy_settings)) {
|
||||
$proxy_settings = CheckConfiguration::run($server, true);
|
||||
}
|
||||
$proxy_path = $server->proxyPath();
|
||||
$docker_compose_yml_base64 = base64_encode($proxy_settings);
|
||||
$docker_compose_yml_base64 = base64_encode($configuration);
|
||||
|
||||
// Update the saved settings hash
|
||||
$server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value;
|
||||
$server->save();
|
||||
|
||||
return instant_remote_process([
|
||||
// Transfer the configuration file to the server
|
||||
instant_remote_process([
|
||||
"mkdir -p $proxy_path",
|
||||
"echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
|
||||
], $server);
|
||||
|
|
@ -13,19 +13,27 @@ class StartProxy
|
|||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, bool $async = true, bool $force = false): string|Activity
|
||||
public function handle(Server $server, bool $async = true, bool $force = false, bool $restarting = false): string|Activity
|
||||
{
|
||||
$proxyType = $server->proxyType();
|
||||
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) {
|
||||
return 'OK';
|
||||
}
|
||||
$server->proxy->set('status', 'starting');
|
||||
$server->save();
|
||||
$server->refresh();
|
||||
|
||||
if (! $restarting) {
|
||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||
}
|
||||
|
||||
$commands = collect([]);
|
||||
$proxy_path = $server->proxyPath();
|
||||
$configuration = CheckConfiguration::run($server);
|
||||
$configuration = GetProxyConfiguration::run($server);
|
||||
if (! $configuration) {
|
||||
throw new \Exception('Configuration is not synced');
|
||||
}
|
||||
SaveConfiguration::run($server, $configuration);
|
||||
SaveProxyConfiguration::run($server, $configuration);
|
||||
$docker_compose_yml_base64 = base64_encode($configuration);
|
||||
$server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
|
||||
$server->save();
|
||||
|
|
@ -55,23 +63,34 @@ public function handle(Server $server, bool $async = true, bool $force = false):
|
|||
'docker compose pull',
|
||||
'if docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
|
||||
" echo 'Stopping and removing existing coolify-proxy.'",
|
||||
' docker rm -f coolify-proxy || true',
|
||||
' docker stop coolify-proxy 2>/dev/null || true',
|
||||
' docker rm -f coolify-proxy 2>/dev/null || true',
|
||||
' # Wait for container to be fully removed',
|
||||
' for i in {1..10}; do',
|
||||
' if ! docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
|
||||
' break',
|
||||
' fi',
|
||||
' echo "Waiting for coolify-proxy to be removed... ($i/10)"',
|
||||
' sleep 1',
|
||||
' done',
|
||||
" echo 'Successfully stopped and removed existing coolify-proxy.'",
|
||||
'fi',
|
||||
]);
|
||||
// Ensure required networks exist BEFORE docker compose up (networks are declared as external)
|
||||
$commands = $commands->merge(ensureProxyNetworksExist($server));
|
||||
$commands = $commands->merge([
|
||||
"echo 'Starting coolify-proxy.'",
|
||||
'docker compose up -d --wait --remove-orphans',
|
||||
"echo 'Successfully started coolify-proxy.'",
|
||||
]);
|
||||
$commands = $commands->merge(connectProxyToNetworks($server));
|
||||
}
|
||||
$server->proxy->set('status', 'starting');
|
||||
$server->save();
|
||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||
|
||||
if ($async) {
|
||||
return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id);
|
||||
} else {
|
||||
instant_remote_process($commands, $server);
|
||||
|
||||
$server->proxy->set('type', $proxyType);
|
||||
$server->save();
|
||||
ProxyStatusChanged::dispatch($server->id);
|
||||
|
|
|
|||
|
|
@ -12,17 +12,27 @@ class StopProxy
|
|||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, bool $forceStop = true, int $timeout = 30)
|
||||
public function handle(Server $server, bool $forceStop = true, int $timeout = 30, bool $restarting = false)
|
||||
{
|
||||
try {
|
||||
$containerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
|
||||
$server->proxy->status = 'stopping';
|
||||
$server->save();
|
||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||
|
||||
if (! $restarting) {
|
||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||
}
|
||||
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=$timeout $containerName",
|
||||
"docker rm -f $containerName",
|
||||
"docker stop -t=$timeout $containerName 2>/dev/null || true",
|
||||
"docker rm -f $containerName 2>/dev/null || true",
|
||||
'# Wait for container to be fully removed',
|
||||
'for i in {1..10}; do',
|
||||
" if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then",
|
||||
' break',
|
||||
' fi',
|
||||
' sleep 1',
|
||||
'done',
|
||||
], server: $server, throwError: false);
|
||||
|
||||
$server->proxy->force_stop = $forceStop;
|
||||
|
|
@ -32,7 +42,10 @@ public function handle(Server $server, bool $forceStop = true, int $timeout = 30
|
|||
return handleError($e);
|
||||
} finally {
|
||||
ProxyDashboardCacheService::clearCache($server);
|
||||
ProxyStatusChanged::dispatch($server->id);
|
||||
|
||||
if (! $restarting) {
|
||||
ProxyStatusChanged::dispatch($server->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ class CheckUpdates
|
|||
|
||||
public function handle(Server $server)
|
||||
{
|
||||
$osId = 'unknown';
|
||||
$packageManager = null;
|
||||
|
||||
try {
|
||||
if ($server->serverStatus() === false) {
|
||||
return [
|
||||
|
|
@ -93,6 +96,16 @@ public function handle(Server $server)
|
|||
$out['osId'] = $osId;
|
||||
$out['package_manager'] = $packageManager;
|
||||
|
||||
return $out;
|
||||
case 'pacman':
|
||||
// Sync database first, then check for updates
|
||||
// Using -Sy to refresh package database before querying available updates
|
||||
instant_remote_process(['pacman -Sy'], $server);
|
||||
$output = instant_remote_process(['pacman -Qu 2>/dev/null'], $server);
|
||||
$out = $this->parsePacmanOutput($output);
|
||||
$out['osId'] = $osId;
|
||||
$out['package_manager'] = $packageManager;
|
||||
|
||||
return $out;
|
||||
default:
|
||||
return [
|
||||
|
|
@ -102,7 +115,6 @@ public function handle(Server $server)
|
|||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
ray('Error:', $e->getMessage());
|
||||
|
||||
return [
|
||||
'osId' => $osId,
|
||||
|
|
@ -220,4 +232,45 @@ private function parseAptOutput(string $output): array
|
|||
'updates' => $updates,
|
||||
];
|
||||
}
|
||||
|
||||
private function parsePacmanOutput(string $output): array
|
||||
{
|
||||
$updates = [];
|
||||
$unparsedLines = [];
|
||||
$lines = explode("\n", $output);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
// Format: package current_version -> new_version
|
||||
if (preg_match('/^(\S+)\s+(\S+)\s+->\s+(\S+)$/', $line, $matches)) {
|
||||
$updates[] = [
|
||||
'package' => $matches[1],
|
||||
'current_version' => $matches[2],
|
||||
'new_version' => $matches[3],
|
||||
'architecture' => 'unknown',
|
||||
'repository' => 'unknown',
|
||||
];
|
||||
} else {
|
||||
// Log unmatched lines for debugging purposes
|
||||
$unparsedLines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [
|
||||
'total_updates' => count($updates),
|
||||
'updates' => $updates,
|
||||
];
|
||||
|
||||
// Include unparsed lines in the result for debugging if any exist
|
||||
if (! empty($unparsedLines)) {
|
||||
$result['unparsed_lines'] = $unparsedLines;
|
||||
\Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [
|
||||
'unparsed_lines' => $unparsedLines,
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,24 +11,45 @@ class CleanupDocker
|
|||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(Server $server)
|
||||
public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false)
|
||||
{
|
||||
$settings = instanceSettings();
|
||||
$realtimeImage = config('constants.coolify.realtime_image');
|
||||
$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 = getHelperVersion();
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$helperImageWithVersion = "$helperImage:$helperImageVersion";
|
||||
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
|
||||
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
|
||||
|
||||
$cleanupLog = [];
|
||||
|
||||
// Get all application image repositories to exclude from prune
|
||||
$applications = $server->applications();
|
||||
$applicationImageRepos = collect($applications)->map(function ($app) {
|
||||
return $app->docker_registry_image_name ?? $app->uuid;
|
||||
})->unique()->values();
|
||||
|
||||
// Clean up old application images while preserving N most recent for rollback
|
||||
$applicationCleanupLog = $this->cleanupApplicationImages($server, $applications);
|
||||
$cleanupLog = array_merge($cleanupLog, $applicationCleanupLog);
|
||||
|
||||
// Build image prune command that excludes application images and current Coolify infrastructure images
|
||||
// This ensures we clean up non-Coolify images while preserving rollback images and current helper/realtime images
|
||||
// Note: Only the current version is protected; old versions will be cleaned up by explicit commands below
|
||||
// We pass the version strings so all registry variants are protected (ghcr.io, docker.io, no prefix)
|
||||
$imagePruneCmd = $this->buildImagePruneCommand(
|
||||
$applicationImageRepos,
|
||||
$helperImageVersion,
|
||||
$realtimeImageVersion
|
||||
);
|
||||
|
||||
$commands = [
|
||||
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
|
||||
'docker image prune -af --filter "label!=coolify.managed=true"',
|
||||
$imagePruneCmd,
|
||||
'docker builder prune -af',
|
||||
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||
|
|
@ -36,15 +57,14 @@ public function handle(Server $server)
|
|||
"docker images --filter before=$realtimeImageWithoutPrefixVersion --filter reference=$realtimeImageWithoutPrefix | grep $realtimeImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
|
||||
];
|
||||
|
||||
if ($server->settings->delete_unused_volumes) {
|
||||
if ($deleteUnusedVolumes) {
|
||||
$commands[] = 'docker volume prune -af';
|
||||
}
|
||||
|
||||
if ($server->settings->delete_unused_networks) {
|
||||
if ($deleteUnusedNetworks) {
|
||||
$commands[] = 'docker network prune -f';
|
||||
}
|
||||
|
||||
$cleanupLog = [];
|
||||
foreach ($commands as $command) {
|
||||
$commandOutput = instant_remote_process([$command], $server, false);
|
||||
if ($commandOutput !== null) {
|
||||
|
|
@ -57,4 +77,140 @@ public function handle(Server $server)
|
|||
|
||||
return $cleanupLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a docker image prune command that excludes application image repositories.
|
||||
*
|
||||
* Since docker image prune doesn't support excluding by repository name directly,
|
||||
* we use a shell script approach to delete unused images while preserving application images.
|
||||
*/
|
||||
private function buildImagePruneCommand(
|
||||
$applicationImageRepos,
|
||||
string $helperImageVersion,
|
||||
string $realtimeImageVersion
|
||||
): string {
|
||||
// Step 1: Always prune dangling images (untagged)
|
||||
$commands = ['docker image prune -f'];
|
||||
|
||||
// Build grep pattern to exclude application image repositories (matches repo:tag and repo_service:tag)
|
||||
$appExcludePatterns = $applicationImageRepos->map(function ($repo) {
|
||||
// Escape special characters for grep extended regex (ERE)
|
||||
// ERE special chars: . \ + * ? [ ^ ] $ ( ) { } |
|
||||
return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo);
|
||||
})->implode('|');
|
||||
|
||||
// Build grep pattern to exclude Coolify infrastructure images (current version only)
|
||||
// This pattern matches the image name regardless of registry prefix:
|
||||
// - ghcr.io/coollabsio/coolify-helper:1.0.12
|
||||
// - docker.io/coollabsio/coolify-helper:1.0.12
|
||||
// - coollabsio/coolify-helper:1.0.12
|
||||
// Pattern: (^|/)coollabsio/coolify-(helper|realtime):VERSION$
|
||||
$escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperImageVersion);
|
||||
$escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeImageVersion);
|
||||
$infraExcludePattern = "(^|/)coollabsio/coolify-helper:{$escapedHelperVersion}$|(^|/)coollabsio/coolify-realtime:{$escapedRealtimeVersion}$";
|
||||
|
||||
// Delete unused images that:
|
||||
// - Are not application images (don't match app repos)
|
||||
// - Are not current Coolify infrastructure images (any registry)
|
||||
// - Don't have coolify.managed=true label
|
||||
// Images in use by containers will fail silently with docker rmi
|
||||
// Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build)
|
||||
$grepCommands = "grep -v '<none>'";
|
||||
|
||||
// Add application repo exclusion if there are applications
|
||||
if ($applicationImageRepos->isNotEmpty()) {
|
||||
$grepCommands .= " | grep -v -E '^({$appExcludePatterns})[_:].+'";
|
||||
}
|
||||
|
||||
// Add infrastructure image exclusion (matches any registry prefix)
|
||||
$grepCommands .= " | grep -v -E '{$infraExcludePattern}'";
|
||||
|
||||
$commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ".
|
||||
$grepCommands.' | '.
|
||||
"xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true";
|
||||
|
||||
return implode(' && ', $commands);
|
||||
}
|
||||
|
||||
private function cleanupApplicationImages(Server $server, $applications = null): array
|
||||
{
|
||||
$cleanupLog = [];
|
||||
|
||||
if ($applications === null) {
|
||||
$applications = $server->applications();
|
||||
}
|
||||
|
||||
$disableRetention = $server->settings->disable_application_image_retention ?? false;
|
||||
|
||||
foreach ($applications as $application) {
|
||||
$imagesToKeep = $disableRetention ? 0 : ($application->settings->docker_images_to_keep ?? 2);
|
||||
$imageRepository = $application->docker_registry_image_name ?? $application->uuid;
|
||||
|
||||
// Get the currently running image tag
|
||||
$currentTagCommand = "docker inspect --format='{{.Config.Image}}' {$application->uuid} 2>/dev/null | grep -oP '(?<=:)[^:]+$' || true";
|
||||
$currentTag = instant_remote_process([$currentTagCommand], $server, false);
|
||||
$currentTag = trim($currentTag ?? '');
|
||||
|
||||
// List all images for this application with their creation timestamps
|
||||
// Use wildcard to match both uuid:tag and uuid_servicename:tag (Docker Compose with build)
|
||||
$listCommand = "docker images --format '{{.Repository}}:{{.Tag}}#{{.CreatedAt}}' --filter reference='{$imageRepository}*' 2>/dev/null || true";
|
||||
$output = instant_remote_process([$listCommand], $server, false);
|
||||
|
||||
if (empty($output)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$images = collect(explode("\n", trim($output)))
|
||||
->filter()
|
||||
->map(function ($line) {
|
||||
$parts = explode('#', $line);
|
||||
$imageRef = $parts[0] ?? '';
|
||||
$tagParts = explode(':', $imageRef);
|
||||
|
||||
return [
|
||||
'repository' => $tagParts[0] ?? '',
|
||||
'tag' => $tagParts[1] ?? '',
|
||||
'created_at' => $parts[1] ?? '',
|
||||
'image_ref' => $imageRef,
|
||||
];
|
||||
})
|
||||
->filter(fn ($image) => ! empty($image['tag']));
|
||||
|
||||
// Separate images into categories
|
||||
// PR images (pr-*) and build images (*-build) are excluded from retention
|
||||
// Build images will be cleaned up by docker image prune -af
|
||||
$prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-'));
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
|
||||
// Always delete all PR images
|
||||
foreach ($prImages as $image) {
|
||||
$deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true";
|
||||
$deleteOutput = instant_remote_process([$deleteCommand], $server, false);
|
||||
$cleanupLog[] = [
|
||||
'command' => $deleteCommand,
|
||||
'output' => $deleteOutput ?? 'PR image removed or was in use',
|
||||
];
|
||||
}
|
||||
|
||||
// Filter out current running image from regular images and sort by creation date
|
||||
$sortedRegularImages = $regularImages
|
||||
->filter(fn ($image) => $image['tag'] !== $currentTag)
|
||||
->sortByDesc('created_at')
|
||||
->values();
|
||||
|
||||
// Keep only N images (imagesToKeep), delete the rest
|
||||
$imagesToDelete = $sortedRegularImages->skip($imagesToKeep);
|
||||
|
||||
foreach ($imagesToDelete as $image) {
|
||||
$deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true";
|
||||
$deleteOutput = instant_remote_process([$deleteCommand], $server, false);
|
||||
$cleanupLog[] = [
|
||||
'command' => $deleteCommand,
|
||||
'output' => $deleteOutput ?? 'Image removed or was in use',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $cleanupLog;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,102 @@
|
|||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\Server\HetznerDeletionFailed;
|
||||
use App\Services\HetznerService;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DeleteServer
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server)
|
||||
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null)
|
||||
{
|
||||
StopSentinel::run($server);
|
||||
$server->forceDelete();
|
||||
$server = Server::withTrashed()->find($serverId);
|
||||
|
||||
// Delete from Hetzner even if server is already gone from Coolify
|
||||
if ($deleteFromHetzner && ($hetznerServerId || ($server && $server->hetzner_server_id))) {
|
||||
$this->deleteFromHetznerById(
|
||||
$hetznerServerId ?? $server->hetzner_server_id,
|
||||
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
|
||||
$teamId ?? $server->team_id
|
||||
);
|
||||
}
|
||||
|
||||
ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
|
||||
|
||||
// If server is already deleted from Coolify, skip this part
|
||||
if (! $server) {
|
||||
return; // Server already force deleted from Coolify
|
||||
}
|
||||
|
||||
ray('force deleting server from Coolify', ['server_id' => $server->id]);
|
||||
|
||||
try {
|
||||
$server->forceDelete();
|
||||
} catch (\Throwable $e) {
|
||||
ray('Failed to force delete server from Coolify', [
|
||||
'error' => $e->getMessage(),
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
logger()->error('Failed to force delete server from Coolify', [
|
||||
'error' => $e->getMessage(),
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProviderTokenId, int $teamId): void
|
||||
{
|
||||
try {
|
||||
// Use the provided token, or fallback to first available team token
|
||||
$token = null;
|
||||
|
||||
if ($cloudProviderTokenId) {
|
||||
$token = CloudProviderToken::find($cloudProviderTokenId);
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
$token = CloudProviderToken::where('team_id', $teamId)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
ray('No Hetzner token found for team, skipping Hetzner deletion', [
|
||||
'team_id' => $teamId,
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$hetznerService->deleteServer($hetznerServerId);
|
||||
|
||||
ray('Deleted server from Hetzner', [
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
ray('Failed to delete server from Hetzner', [
|
||||
'error' => $e->getMessage(),
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
// Log the error but don't prevent the server from being deleted from Coolify
|
||||
logger()->error('Failed to delete server from Hetzner', [
|
||||
'error' => $e->getMessage(),
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
// Notify the team about the failure
|
||||
$team = Team::find($teamId);
|
||||
$team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneDocker;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
|
|
@ -12,15 +11,17 @@ class InstallDocker
|
|||
{
|
||||
use AsAction;
|
||||
|
||||
private string $dockerVersion;
|
||||
|
||||
public function handle(Server $server)
|
||||
{
|
||||
$dockerVersion = config('constants.docker.minimum_required_version');
|
||||
$this->dockerVersion = config('constants.docker.minimum_required_version');
|
||||
$supported_os_type = $server->validateOS();
|
||||
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>.');
|
||||
}
|
||||
|
||||
if (! SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->exists()) {
|
||||
if (! $server->sslCertificates()->where('is_ca_certificate', true)->exists()) {
|
||||
$serverCert = SslHelper::generateSslCertificate(
|
||||
commonName: 'Coolify CA Certificate',
|
||||
serverId: $server->id,
|
||||
|
|
@ -58,8 +59,6 @@ public function handle(Server $server)
|
|||
$command = collect([]);
|
||||
if (isDev() && $server->id === 0) {
|
||||
$command = $command->merge([
|
||||
"echo 'Installing Prerequisites...'",
|
||||
'sleep 1',
|
||||
"echo 'Installing Docker Engine...'",
|
||||
"echo 'Configuring Docker Engine (merging existing configuration with the required)...'",
|
||||
'sleep 4',
|
||||
|
|
@ -69,38 +68,23 @@ public function handle(Server $server)
|
|||
|
||||
return remote_process($command, $server);
|
||||
} else {
|
||||
if ($supported_os_type->contains('debian')) {
|
||||
$command = $command->merge([
|
||||
"echo 'Installing Prerequisites...'",
|
||||
'apt-get update -y',
|
||||
'command -v curl >/dev/null || apt install -y curl',
|
||||
'command -v wget >/dev/null || apt install -y wget',
|
||||
'command -v git >/dev/null || apt install -y git',
|
||||
'command -v jq >/dev/null || apt install -y jq',
|
||||
]);
|
||||
} elseif ($supported_os_type->contains('rhel')) {
|
||||
$command = $command->merge([
|
||||
"echo 'Installing Prerequisites...'",
|
||||
'command -v curl >/dev/null || dnf install -y curl',
|
||||
'command -v wget >/dev/null || dnf install -y wget',
|
||||
'command -v git >/dev/null || dnf install -y git',
|
||||
'command -v jq >/dev/null || dnf install -y jq',
|
||||
]);
|
||||
} elseif ($supported_os_type->contains('sles')) {
|
||||
$command = $command->merge([
|
||||
"echo 'Installing Prerequisites...'",
|
||||
'zypper update -y',
|
||||
'command -v curl >/dev/null || zypper install -y curl',
|
||||
'command -v wget >/dev/null || zypper install -y wget',
|
||||
'command -v git >/dev/null || zypper install -y git',
|
||||
'command -v jq >/dev/null || zypper install -y jq',
|
||||
]);
|
||||
} else {
|
||||
throw new \Exception('Unsupported OS');
|
||||
}
|
||||
$command = $command->merge([
|
||||
"echo 'Installing Docker Engine...'",
|
||||
"curl https://releases.rancher.com/install-docker/{$dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$dockerVersion}",
|
||||
]);
|
||||
|
||||
if ($supported_os_type->contains('debian')) {
|
||||
$command = $command->merge([$this->getDebianDockerInstallCommand()]);
|
||||
} elseif ($supported_os_type->contains('rhel')) {
|
||||
$command = $command->merge([$this->getRhelDockerInstallCommand()]);
|
||||
} elseif ($supported_os_type->contains('sles')) {
|
||||
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
|
||||
} elseif ($supported_os_type->contains('arch')) {
|
||||
$command = $command->merge([$this->getArchDockerInstallCommand()]);
|
||||
} else {
|
||||
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
|
||||
}
|
||||
|
||||
$command = $command->merge([
|
||||
"echo 'Configuring Docker Engine (merging existing configuration with the required)...'",
|
||||
'test -s /etc/docker/daemon.json && cp /etc/docker/daemon.json "/etc/docker/daemon.json.original-$(date +"%Y%m%d-%H%M%S")"',
|
||||
"test ! -s /etc/docker/daemon.json && echo '{$config}' | base64 -d | tee /etc/docker/daemon.json > /dev/null",
|
||||
|
|
@ -129,4 +113,54 @@ public function handle(Server $server)
|
|||
return remote_process($command, $server);
|
||||
}
|
||||
}
|
||||
|
||||
private function getDebianDockerInstallCommand(): string
|
||||
{
|
||||
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
|
||||
'install -m 0755 -d /etc/apt/keyrings && '.
|
||||
'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && '.
|
||||
'chmod a+r /etc/apt/keyrings/docker.asc && '.
|
||||
'. /etc/os-release && '.
|
||||
'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list && '.
|
||||
'apt-get update && '.
|
||||
'apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin'.
|
||||
')';
|
||||
}
|
||||
|
||||
private function getRhelDockerInstallCommand(): string
|
||||
{
|
||||
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
|
||||
'dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && '.
|
||||
'dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
|
||||
'systemctl start docker && '.
|
||||
'systemctl enable docker'.
|
||||
')';
|
||||
}
|
||||
|
||||
private function getSuseDockerInstallCommand(): string
|
||||
{
|
||||
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
|
||||
'zypper addrepo https://download.docker.com/linux/sles/docker-ce.repo && '.
|
||||
'zypper refresh && '.
|
||||
'zypper install -y --no-confirm docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
|
||||
'systemctl start docker && '.
|
||||
'systemctl enable docker'.
|
||||
')';
|
||||
}
|
||||
|
||||
private function getArchDockerInstallCommand(): string
|
||||
{
|
||||
// Use -Syu to perform full system upgrade before installing Docker
|
||||
// Partial upgrades (-Sy without -u) are discouraged on Arch Linux
|
||||
// as they can lead to broken dependencies and system instability
|
||||
// Use --needed to skip reinstalling packages that are already up-to-date (idempotent)
|
||||
return 'pacman -Syu --noconfirm --needed docker docker-compose && '.
|
||||
'systemctl enable docker.service && '.
|
||||
'systemctl start docker.service';
|
||||
}
|
||||
|
||||
private function getGenericDockerInstallCommand(): string
|
||||
{
|
||||
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
app/Actions/Server/InstallPrerequisites.php
Normal file
64
app/Actions/Server/InstallPrerequisites.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class InstallPrerequisites
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(Server $server)
|
||||
{
|
||||
$supported_os_type = $server->validateOS();
|
||||
if (! $supported_os_type) {
|
||||
throw new \Exception('Server OS type is not supported for automated installation. Please install prerequisites manually.');
|
||||
}
|
||||
|
||||
$command = collect([]);
|
||||
|
||||
if ($supported_os_type->contains('debian')) {
|
||||
$command = $command->merge([
|
||||
"echo 'Installing Prerequisites...'",
|
||||
'apt-get update -y',
|
||||
'command -v curl >/dev/null || apt install -y curl',
|
||||
'command -v wget >/dev/null || apt install -y wget',
|
||||
'command -v git >/dev/null || apt install -y git',
|
||||
'command -v jq >/dev/null || apt install -y jq',
|
||||
]);
|
||||
} elseif ($supported_os_type->contains('rhel')) {
|
||||
$command = $command->merge([
|
||||
"echo 'Installing Prerequisites...'",
|
||||
'command -v curl >/dev/null || dnf install -y curl',
|
||||
'command -v wget >/dev/null || dnf install -y wget',
|
||||
'command -v git >/dev/null || dnf install -y git',
|
||||
'command -v jq >/dev/null || dnf install -y jq',
|
||||
]);
|
||||
} elseif ($supported_os_type->contains('sles')) {
|
||||
$command = $command->merge([
|
||||
"echo 'Installing Prerequisites...'",
|
||||
'zypper update -y',
|
||||
'command -v curl >/dev/null || zypper install -y curl',
|
||||
'command -v wget >/dev/null || zypper install -y wget',
|
||||
'command -v git >/dev/null || zypper install -y git',
|
||||
'command -v jq >/dev/null || zypper install -y jq',
|
||||
]);
|
||||
} elseif ($supported_os_type->contains('arch')) {
|
||||
// Use -Syu for full system upgrade to avoid partial upgrade issues on Arch Linux
|
||||
// --needed flag skips packages that are already installed and up-to-date
|
||||
$command = $command->merge([
|
||||
"echo 'Installing Prerequisites for Arch Linux...'",
|
||||
'pacman -Syu --noconfirm --needed curl wget git jq',
|
||||
]);
|
||||
} else {
|
||||
throw new \Exception('Unsupported OS type for prerequisites installation');
|
||||
}
|
||||
|
||||
$command->push("echo 'Prerequisites installed successfully.'");
|
||||
|
||||
return remote_process($command, $server);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Jobs\ServerStorageCheckJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Notifications\Container\ContainerRestarted;
|
||||
use Illuminate\Support\Arr;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class ServerCheck
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public bool $isSentinel = false;
|
||||
|
||||
public $containers;
|
||||
|
||||
public $databases;
|
||||
|
||||
public function handle(Server $server, $data = null)
|
||||
{
|
||||
$this->server = $server;
|
||||
try {
|
||||
if ($this->server->isFunctional() === false) {
|
||||
return 'Server is not functional.';
|
||||
}
|
||||
|
||||
if (! $this->server->isSwarmWorker() && ! $this->server->isBuildServer()) {
|
||||
|
||||
if (isset($data)) {
|
||||
$data = collect($data);
|
||||
|
||||
$this->server->sentinelHeartbeat();
|
||||
|
||||
$this->containers = collect(data_get($data, 'containers'));
|
||||
|
||||
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
|
||||
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
|
||||
|
||||
$containerReplicates = null;
|
||||
$this->isSentinel = true;
|
||||
} else {
|
||||
['containers' => $this->containers, 'containerReplicates' => $containerReplicates] = $this->server->getContainers();
|
||||
// ServerStorageCheckJob::dispatch($this->server);
|
||||
}
|
||||
|
||||
if (is_null($this->containers)) {
|
||||
return 'No containers found.';
|
||||
}
|
||||
|
||||
if (isset($containerReplicates)) {
|
||||
foreach ($containerReplicates as $containerReplica) {
|
||||
$name = data_get($containerReplica, 'Name');
|
||||
$this->containers = $this->containers->map(function ($container) use ($name, $containerReplica) {
|
||||
if (data_get($container, 'Spec.Name') === $name) {
|
||||
$replicas = data_get($containerReplica, 'Replicas');
|
||||
$running = str($replicas)->explode('/')[0];
|
||||
$total = str($replicas)->explode('/')[1];
|
||||
if ($running === $total) {
|
||||
data_set($container, 'State.Status', 'running');
|
||||
data_set($container, 'State.Health.Status', 'healthy');
|
||||
} else {
|
||||
data_set($container, 'State.Status', 'starting');
|
||||
data_set($container, 'State.Health.Status', 'unhealthy');
|
||||
}
|
||||
}
|
||||
|
||||
return $container;
|
||||
});
|
||||
}
|
||||
}
|
||||
$this->checkContainers();
|
||||
|
||||
if ($this->server->isSentinelEnabled() && $this->isSentinel === false) {
|
||||
CheckAndStartSentinelJob::dispatch($this->server);
|
||||
}
|
||||
|
||||
if ($this->server->isLogDrainEnabled()) {
|
||||
$this->checkLogDrainContainer();
|
||||
}
|
||||
|
||||
if ($this->server->proxySet() && ! $this->server->proxy->force_stop) {
|
||||
$foundProxyContainer = $this->containers->filter(function ($value, $key) {
|
||||
if ($this->server->isSwarm()) {
|
||||
return data_get($value, 'Spec.Name') === 'coolify-proxy_traefik';
|
||||
} else {
|
||||
return data_get($value, 'Name') === '/coolify-proxy';
|
||||
}
|
||||
})->first();
|
||||
$proxyStatus = data_get($foundProxyContainer, 'State.Status', 'exited');
|
||||
if (! $foundProxyContainer || $proxyStatus !== 'running') {
|
||||
try {
|
||||
$shouldStart = CheckProxy::run($this->server);
|
||||
if ($shouldStart) {
|
||||
StartProxy::run($this->server, async: false);
|
||||
$this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
} else {
|
||||
$this->server->proxy->status = data_get($foundProxyContainer, 'State.Status');
|
||||
$this->server->save();
|
||||
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
|
||||
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkLogDrainContainer()
|
||||
{
|
||||
$foundLogDrainContainer = $this->containers->filter(function ($value, $key) {
|
||||
return data_get($value, 'Name') === '/coolify-log-drain';
|
||||
})->first();
|
||||
if ($foundLogDrainContainer) {
|
||||
$status = data_get($foundLogDrainContainer, 'State.Status');
|
||||
if ($status !== 'running') {
|
||||
StartLogDrain::dispatch($this->server);
|
||||
}
|
||||
} else {
|
||||
StartLogDrain::dispatch($this->server);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkContainers()
|
||||
{
|
||||
foreach ($this->containers as $container) {
|
||||
if ($this->isSentinel) {
|
||||
$labels = Arr::undot(data_get($container, 'labels'));
|
||||
} else {
|
||||
if ($this->server->isSwarm()) {
|
||||
$labels = Arr::undot(data_get($container, 'Spec.Labels'));
|
||||
} else {
|
||||
$labels = Arr::undot(data_get($container, 'Config.Labels'));
|
||||
}
|
||||
}
|
||||
$managed = data_get($labels, 'coolify.managed');
|
||||
if (! $managed) {
|
||||
continue;
|
||||
}
|
||||
$uuid = data_get($labels, 'coolify.name');
|
||||
if (! $uuid) {
|
||||
$uuid = data_get($labels, 'com.docker.compose.service');
|
||||
}
|
||||
|
||||
if ($this->isSentinel) {
|
||||
$containerStatus = data_get($container, 'state');
|
||||
$containerHealth = data_get($container, 'health_status');
|
||||
} else {
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
}
|
||||
$containerStatus = "$containerStatus ($containerHealth)";
|
||||
|
||||
$applicationId = data_get($labels, 'coolify.applicationId');
|
||||
$serviceId = data_get($labels, 'coolify.serviceId');
|
||||
$databaseId = data_get($labels, 'coolify.databaseId');
|
||||
$pullRequestId = data_get($labels, 'coolify.pullRequestId');
|
||||
|
||||
if ($applicationId) {
|
||||
// Application
|
||||
if ($pullRequestId != 0) {
|
||||
if (str($applicationId)->contains('-')) {
|
||||
$applicationId = str($applicationId)->before('-');
|
||||
}
|
||||
$preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first();
|
||||
if ($preview) {
|
||||
$preview->update(['status' => $containerStatus]);
|
||||
}
|
||||
} else {
|
||||
$application = Application::where('id', $applicationId)->first();
|
||||
if ($application) {
|
||||
$application->update([
|
||||
'status' => $containerStatus,
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} elseif (isset($serviceId)) {
|
||||
// Service
|
||||
$subType = data_get($labels, 'coolify.service.subType');
|
||||
$subId = data_get($labels, 'coolify.service.subId');
|
||||
$service = Service::where('id', $serviceId)->first();
|
||||
if (! $service) {
|
||||
continue;
|
||||
}
|
||||
if ($subType === 'application') {
|
||||
$service = ServiceApplication::where('id', $subId)->first();
|
||||
} else {
|
||||
$service = ServiceDatabase::where('id', $subId)->first();
|
||||
}
|
||||
if ($service) {
|
||||
$service->update([
|
||||
'status' => $containerStatus,
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
if ($subType === 'database') {
|
||||
$isPublic = data_get($service, 'is_public');
|
||||
if ($isPublic) {
|
||||
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
|
||||
if ($this->isSentinel) {
|
||||
return data_get($value, 'name') === $uuid.'-proxy';
|
||||
} else {
|
||||
|
||||
if ($this->server->isSwarm()) {
|
||||
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
|
||||
} else {
|
||||
return data_get($value, 'Name') === "/$uuid-proxy";
|
||||
}
|
||||
}
|
||||
})->first();
|
||||
if (! $foundTcpProxy) {
|
||||
StartDatabaseProxy::run($service);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Database
|
||||
if (is_null($this->databases)) {
|
||||
$this->databases = $this->server->databases();
|
||||
}
|
||||
$database = $this->databases->where('uuid', $uuid)->first();
|
||||
if ($database) {
|
||||
$database->update([
|
||||
'status' => $containerStatus,
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
|
||||
$isPublic = data_get($database, 'is_public');
|
||||
if ($isPublic) {
|
||||
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
|
||||
if ($this->isSentinel) {
|
||||
return data_get($value, 'name') === $uuid.'-proxy';
|
||||
} else {
|
||||
if ($this->server->isSwarm()) {
|
||||
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
|
||||
} else {
|
||||
|
||||
return data_get($value, 'Name') === "/$uuid-proxy";
|
||||
}
|
||||
}
|
||||
})->first();
|
||||
if (! $foundTcpProxy) {
|
||||
StartDatabaseProxy::run($database);
|
||||
// $this->server->team?->notify(new ContainerRestarted("TCP Proxy for database", $this->server));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Events\SentinelRestarted;
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
|
|
@ -9,7 +10,7 @@ class StartSentinel
|
|||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null)
|
||||
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null)
|
||||
{
|
||||
if ($server->isSwarm() || $server->isBuildServer()) {
|
||||
return;
|
||||
|
|
@ -43,7 +44,9 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
|
|||
];
|
||||
if (isDev()) {
|
||||
// data_set($environments, 'DEBUG', 'true');
|
||||
// $image = 'sentinel';
|
||||
if ($customImage && ! empty($customImage)) {
|
||||
$image = $customImage;
|
||||
}
|
||||
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
|
||||
}
|
||||
$dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"';
|
||||
|
|
@ -61,5 +64,8 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
|
|||
$server->settings->is_sentinel_enabled = true;
|
||||
$server->settings->save();
|
||||
$server->sentinelHeartbeat();
|
||||
|
||||
// Dispatch event to notify UI components
|
||||
SentinelRestarted::dispatch($server, $version);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Jobs\PullHelperImageJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Sleep;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
|
|
@ -29,8 +30,59 @@ public function handle($manual_update = false)
|
|||
if (! $this->server) {
|
||||
return;
|
||||
}
|
||||
CleanupDocker::dispatch($this->server);
|
||||
$this->latestVersion = get_latest_version_of_coolify();
|
||||
|
||||
// Fetch fresh version from CDN instead of using cache
|
||||
try {
|
||||
$response = Http::retry(3, 1000)->timeout(10)
|
||||
->get(config('constants.coolify.versions_url'));
|
||||
|
||||
if ($response->successful()) {
|
||||
$versions = $response->json();
|
||||
$this->latestVersion = data_get($versions, 'coolify.v4.version');
|
||||
} else {
|
||||
// Fallback to cache if CDN unavailable
|
||||
$cacheVersion = get_latest_version_of_coolify();
|
||||
|
||||
// Validate cache version against current running version
|
||||
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
|
||||
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
|
||||
'cached_version' => $cacheVersion,
|
||||
'current_version' => config('constants.coolify.version'),
|
||||
]);
|
||||
throw new \Exception(
|
||||
'Cannot determine latest version: CDN unavailable and cache version '.
|
||||
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
|
||||
);
|
||||
}
|
||||
|
||||
$this->latestVersion = $cacheVersion;
|
||||
Log::warning('Failed to fetch fresh version from CDN (unsuccessful response), using validated cache', [
|
||||
'version' => $cacheVersion,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$cacheVersion = get_latest_version_of_coolify();
|
||||
|
||||
// Validate cache version against current running version
|
||||
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
|
||||
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
|
||||
'error' => $e->getMessage(),
|
||||
'cached_version' => $cacheVersion,
|
||||
'current_version' => config('constants.coolify.version'),
|
||||
]);
|
||||
throw new \Exception(
|
||||
'Cannot determine latest version: CDN unavailable and cache version '.
|
||||
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
|
||||
);
|
||||
}
|
||||
|
||||
$this->latestVersion = $cacheVersion;
|
||||
Log::warning('Failed to fetch fresh version from CDN, using validated cache', [
|
||||
'error' => $e->getMessage(),
|
||||
'version' => $cacheVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->currentVersion = config('constants.coolify.version');
|
||||
if (! $manual_update) {
|
||||
if (! $settings->is_auto_update_enabled) {
|
||||
|
|
@ -43,6 +95,20 @@ public function handle($manual_update = false)
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ALWAYS check for downgrades (even for manual updates)
|
||||
if (version_compare($this->latestVersion, $this->currentVersion, '<')) {
|
||||
Log::error('Downgrade prevented', [
|
||||
'target_version' => $this->latestVersion,
|
||||
'current_version' => $this->currentVersion,
|
||||
'manual_update' => $manual_update,
|
||||
]);
|
||||
throw new \Exception(
|
||||
"Cannot downgrade from {$this->currentVersion} to {$this->latestVersion}. ".
|
||||
'If you need to downgrade, please do so manually via Docker commands.'
|
||||
);
|
||||
}
|
||||
|
||||
$this->update();
|
||||
$settings->new_version_available = false;
|
||||
$settings->save();
|
||||
|
|
@ -50,14 +116,12 @@ public function handle($manual_update = false)
|
|||
|
||||
private function update()
|
||||
{
|
||||
PullHelperImageJob::dispatch($this->server);
|
||||
|
||||
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
|
||||
instant_remote_process(["docker pull -q $image"], $this->server, false);
|
||||
$latestHelperImageVersion = getHelperVersion();
|
||||
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
|
||||
|
||||
remote_process([
|
||||
'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh',
|
||||
"bash /data/coolify/source/upgrade.sh $this->latestVersion",
|
||||
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
|
||||
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
|
||||
], $this->server);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,18 +20,43 @@ public function handle(Server $server, string $osId, ?string $package = null, ?s
|
|||
'error' => 'Server is not reachable or not ready.',
|
||||
];
|
||||
}
|
||||
|
||||
// Validate that package name is provided when not updating all packages
|
||||
if (! $all && ($package === null || $package === '')) {
|
||||
return [
|
||||
'error' => "Package name required when 'all' is false.",
|
||||
];
|
||||
}
|
||||
|
||||
// Sanitize package name to prevent command injection
|
||||
// Only allow alphanumeric characters, hyphens, underscores, periods, plus signs, and colons
|
||||
// These are valid characters in package names across most package managers
|
||||
$sanitizedPackage = '';
|
||||
if ($package !== null && ! $all) {
|
||||
if (! preg_match('/^[a-zA-Z0-9._+:-]+$/', $package)) {
|
||||
return [
|
||||
'error' => 'Invalid package name. Package names can only contain alphanumeric characters, hyphens, underscores, periods, plus signs, and colons.',
|
||||
];
|
||||
}
|
||||
$sanitizedPackage = escapeshellarg($package);
|
||||
}
|
||||
|
||||
switch ($packageManager) {
|
||||
case 'zypper':
|
||||
$commandAll = 'zypper update -y';
|
||||
$commandInstall = 'zypper install -y '.$package;
|
||||
$commandInstall = 'zypper install -y '.$sanitizedPackage;
|
||||
break;
|
||||
case 'dnf':
|
||||
$commandAll = 'dnf update -y';
|
||||
$commandInstall = 'dnf update -y '.$package;
|
||||
$commandInstall = 'dnf update -y '.$sanitizedPackage;
|
||||
break;
|
||||
case 'apt':
|
||||
$commandAll = 'apt update && apt upgrade -y';
|
||||
$commandInstall = 'apt install -y '.$package;
|
||||
$commandInstall = 'apt install -y '.$sanitizedPackage;
|
||||
break;
|
||||
case 'pacman':
|
||||
$commandAll = 'pacman -Syu --noconfirm';
|
||||
$commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage;
|
||||
break;
|
||||
default:
|
||||
return [
|
||||
|
|
|
|||
40
app/Actions/Server/ValidatePrerequisites.php
Normal file
40
app/Actions/Server/ValidatePrerequisites.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class ValidatePrerequisites
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
/**
|
||||
* Validate that required commands are available on the server.
|
||||
*
|
||||
* @return array{success: bool, missing: array<string>, found: array<string>}
|
||||
*/
|
||||
public function handle(Server $server): array
|
||||
{
|
||||
$requiredCommands = ['git', 'curl', 'jq'];
|
||||
$missing = [];
|
||||
$found = [];
|
||||
|
||||
foreach ($requiredCommands as $cmd) {
|
||||
$result = instant_remote_process(["command -v {$cmd}"], $server, false);
|
||||
if (! $result) {
|
||||
$missing[] = $cmd;
|
||||
} else {
|
||||
$found[] = $cmd;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => empty($missing),
|
||||
'missing' => $missing,
|
||||
'found' => $found,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,16 @@ public function handle(Server $server)
|
|||
throw new \Exception($this->error);
|
||||
}
|
||||
|
||||
$validationResult = $server->validatePrerequisites();
|
||||
if (! $validationResult['success']) {
|
||||
$missingCommands = implode(', ', $validationResult['missing']);
|
||||
$this->error = "Prerequisites ({$missingCommands}) are not installed. Please install them before continuing or use the validation with installation endpoint.";
|
||||
$server->update([
|
||||
'validation_logs' => $this->error,
|
||||
]);
|
||||
throw new \Exception($this->error);
|
||||
}
|
||||
|
||||
$this->docker_installed = $server->validateDockerEngine();
|
||||
$this->docker_compose_installed = $server->validateDockerCompose();
|
||||
if (! $this->docker_installed || ! $this->docker_compose_installed) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class DeleteService
|
|||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Service $service, bool $deleteConfigurations, bool $deleteVolumes, bool $dockerCleanup, bool $deleteConnectedNetworks)
|
||||
public function handle(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations, bool $dockerCleanup)
|
||||
{
|
||||
try {
|
||||
$server = data_get($service, 'server');
|
||||
|
|
@ -71,7 +71,7 @@ public function handle(Service $service, bool $deleteConfigurations, bool $delet
|
|||
$service->forceDelete();
|
||||
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, true);
|
||||
CleanupDocker::dispatch($server, false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,18 +20,23 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
|
|||
}
|
||||
$service->saveComposeConfigs();
|
||||
$service->isConfigurationChanged(save: true);
|
||||
$commands[] = 'cd '.$service->workdir();
|
||||
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
|
||||
$workdir = $service->workdir();
|
||||
// $commands[] = "cd {$workdir}";
|
||||
$commands[] = "echo 'Saved configuration files to {$workdir}.'";
|
||||
// Ensure .env exists in the correct directory before docker compose tries to load it
|
||||
// This is defensive programming - saveComposeConfigs() already creates it,
|
||||
// but we guarantee it here in case of any edge cases or manual deployments
|
||||
$commands[] = "touch {$workdir}/.env";
|
||||
if ($pullLatestImages) {
|
||||
$commands[] = "echo 'Pulling images.'";
|
||||
$commands[] = 'docker compose pull';
|
||||
$commands[] = "docker compose --project-directory {$workdir} pull";
|
||||
}
|
||||
if ($service->networks()->count() > 0) {
|
||||
$commands[] = "echo 'Creating Docker network.'";
|
||||
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
|
||||
}
|
||||
$commands[] = 'echo Starting service.';
|
||||
$commands[] = 'docker compose up -d --remove-orphans --force-recreate --build';
|
||||
$commands[] = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --remove-orphans --force-recreate --build";
|
||||
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
|
||||
if (data_get($service, 'connect_to_docker_network')) {
|
||||
$compose = data_get($service, 'docker_compose', []);
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
namespace App\Actions\Service;
|
||||
|
||||
use App\Actions\Server\CleanupDocker;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class StopService
|
||||
{
|
||||
|
|
@ -14,9 +16,20 @@ class StopService
|
|||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(Service $service, bool $isDeleteOperation = false, bool $dockerCleanup = true)
|
||||
public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true)
|
||||
{
|
||||
try {
|
||||
// Cancel any in-progress deployment activities so status doesn't stay stuck at "starting"
|
||||
Activity::where('properties->type_uuid', $service->uuid)
|
||||
->where(function ($q) {
|
||||
$q->where('properties->status', ProcessStatus::IN_PROGRESS->value)
|
||||
->orWhere('properties->status', ProcessStatus::QUEUED->value);
|
||||
})
|
||||
->each(function ($activity) {
|
||||
$activity->properties = $activity->properties->put('status', ProcessStatus::CANCELLED->value);
|
||||
$activity->save();
|
||||
});
|
||||
|
||||
$server = $service->destination->server;
|
||||
if (! $server->isFunctional()) {
|
||||
return 'Server is not functional';
|
||||
|
|
@ -36,11 +49,11 @@ public function handle(Service $service, bool $isDeleteOperation = false, bool $
|
|||
$this->stopContainersInParallel($containersToStop, $server);
|
||||
}
|
||||
|
||||
if ($isDeleteOperation) {
|
||||
if ($deleteConnectedNetworks) {
|
||||
$service->deleteConnectedNetworks();
|
||||
}
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, true);
|
||||
CleanupDocker::dispatch($server, false, false);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return $e->getMessage();
|
||||
|
|
@ -54,7 +67,7 @@ private function stopContainersInParallel(array $containersToStop, Server $serve
|
|||
$timeout = count($containersToStop) > 5 ? 10 : 30;
|
||||
$commands = [];
|
||||
$containerList = implode(' ', $containersToStop);
|
||||
$commands[] = "docker stop --time=$timeout $containerList";
|
||||
$commands[] = "docker stop -t $timeout $containerList";
|
||||
$commands[] = "docker rm -f $containerList";
|
||||
instant_remote_process(
|
||||
command: $commands,
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
namespace App\Actions\Shared;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Traits\CalculatesExcludedStatus;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class ComplexStatusCheck
|
||||
{
|
||||
use AsAction;
|
||||
use CalculatesExcludedStatus;
|
||||
|
||||
public function handle(Application $application)
|
||||
{
|
||||
|
|
@ -17,44 +20,69 @@ public function handle(Application $application)
|
|||
$is_main_server = $application->destination->server->id === $server->id;
|
||||
if (! $server->isFunctional()) {
|
||||
if ($is_main_server) {
|
||||
$application->update(['status' => 'exited:unhealthy']);
|
||||
$application->update(['status' => 'exited']);
|
||||
|
||||
continue;
|
||||
} else {
|
||||
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited:unhealthy']);
|
||||
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited']);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$container = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
|
||||
$container = format_docker_command_output_to_json($container);
|
||||
if ($container->count() === 1) {
|
||||
$container = $container->first();
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
$containers = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
|
||||
$containers = format_docker_command_output_to_json($containers);
|
||||
|
||||
if ($containers->count() > 0) {
|
||||
$statusToSet = $this->aggregateContainerStatuses($application, $containers);
|
||||
|
||||
if ($is_main_server) {
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$application->update(['status' => "$containerStatus:$containerHealth"]);
|
||||
if ($statusFromDb !== $statusToSet) {
|
||||
$application->update(['status' => $statusToSet]);
|
||||
}
|
||||
} else {
|
||||
$additional_server = $application->additional_servers()->wherePivot('server_id', $server->id);
|
||||
$statusFromDb = $additional_server->first()->pivot->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]);
|
||||
if ($statusFromDb !== $statusToSet) {
|
||||
$additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($is_main_server) {
|
||||
$application->update(['status' => 'exited:unhealthy']);
|
||||
$application->update(['status' => 'exited']);
|
||||
|
||||
continue;
|
||||
} else {
|
||||
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited:unhealthy']);
|
||||
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited']);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function aggregateContainerStatuses($application, $containers)
|
||||
{
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||
|
||||
// Filter non-excluded containers
|
||||
$relevantContainers = collect($containers)->filter(function ($container) use ($excludedContainers) {
|
||||
$labels = data_get($container, 'Config.Labels', []);
|
||||
$serviceName = data_get($labels, 'com.docker.compose.service');
|
||||
|
||||
return ! ($serviceName && $excludedContainers->contains($serviceName));
|
||||
});
|
||||
|
||||
// If all containers are excluded, calculate status from excluded containers
|
||||
// but mark it with :excluded to indicate monitoring is disabled
|
||||
if ($relevantContainers->isEmpty()) {
|
||||
return $this->calculateExcludedStatus($containers, $excludedContainers);
|
||||
}
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
|
||||
return $aggregator->aggregateFromContainers($relevantContainers);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
209
app/Actions/Stripe/CancelSubscription.php
Normal file
209
app/Actions/Stripe/CancelSubscription.php
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class CancelSubscription
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private bool $isDryRun;
|
||||
|
||||
private ?StripeClient $stripe = null;
|
||||
|
||||
public function __construct(User $user, bool $isDryRun = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->isDryRun = $isDryRun;
|
||||
|
||||
if (! $isDryRun && isCloud()) {
|
||||
$this->stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
}
|
||||
}
|
||||
|
||||
public function getSubscriptionsPreview(): Collection
|
||||
{
|
||||
$subscriptions = collect();
|
||||
|
||||
// Get all teams the user belongs to
|
||||
$teams = $this->user->teams()->get();
|
||||
|
||||
foreach ($teams as $team) {
|
||||
// Only include subscriptions from teams where user is owner
|
||||
$userRole = $team->pivot->role;
|
||||
if ($userRole === 'owner' && $team->subscription) {
|
||||
$subscription = $team->subscription;
|
||||
|
||||
// Only include active subscriptions
|
||||
if ($subscription->stripe_subscription_id &&
|
||||
$subscription->stripe_invoice_paid) {
|
||||
$subscriptions->push($subscription);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $subscriptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify subscriptions exist and are active in Stripe API
|
||||
*
|
||||
* @return array ['verified' => Collection, 'not_found' => Collection, 'errors' => array]
|
||||
*/
|
||||
public function verifySubscriptionsInStripe(): array
|
||||
{
|
||||
if (! isCloud()) {
|
||||
return [
|
||||
'verified' => collect(),
|
||||
'not_found' => collect(),
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
$subscriptions = $this->getSubscriptionsPreview();
|
||||
|
||||
$verified = collect();
|
||||
$notFound = collect();
|
||||
$errors = [];
|
||||
|
||||
foreach ($subscriptions as $subscription) {
|
||||
try {
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
||||
|
||||
// Check if subscription is actually active in Stripe
|
||||
if (in_array($stripeSubscription->status, ['active', 'trialing', 'past_due'])) {
|
||||
$verified->push([
|
||||
'subscription' => $subscription,
|
||||
'stripe_status' => $stripeSubscription->status,
|
||||
'current_period_end' => $stripeSubscription->current_period_end,
|
||||
]);
|
||||
} else {
|
||||
$notFound->push([
|
||||
'subscription' => $subscription,
|
||||
'reason' => "Status in Stripe: {$stripeSubscription->status}",
|
||||
]);
|
||||
}
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
// Subscription doesn't exist in Stripe
|
||||
$notFound->push([
|
||||
'subscription' => $subscription,
|
||||
'reason' => 'Not found in Stripe',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "Error verifying subscription {$subscription->stripe_subscription_id}: ".$e->getMessage();
|
||||
\Log::error("Error verifying subscription {$subscription->stripe_subscription_id}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'verified' => $verified,
|
||||
'not_found' => $notFound,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(): array
|
||||
{
|
||||
if ($this->isDryRun) {
|
||||
return [
|
||||
'cancelled' => 0,
|
||||
'failed' => 0,
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$cancelledCount = 0;
|
||||
$failedCount = 0;
|
||||
$errors = [];
|
||||
|
||||
$subscriptions = $this->getSubscriptionsPreview();
|
||||
|
||||
foreach ($subscriptions as $subscription) {
|
||||
try {
|
||||
$this->cancelSingleSubscription($subscription);
|
||||
$cancelledCount++;
|
||||
} catch (\Exception $e) {
|
||||
$failedCount++;
|
||||
$errorMessage = "Failed to cancel subscription {$subscription->stripe_subscription_id}: ".$e->getMessage();
|
||||
$errors[] = $errorMessage;
|
||||
\Log::error($errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'cancelled' => $cancelledCount,
|
||||
'failed' => $failedCount,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
private function cancelSingleSubscription(Subscription $subscription): void
|
||||
{
|
||||
if (! $this->stripe) {
|
||||
throw new \Exception('Stripe client not initialized');
|
||||
}
|
||||
|
||||
$subscriptionId = $subscription->stripe_subscription_id;
|
||||
|
||||
// Cancel the subscription immediately (not at period end)
|
||||
$this->stripe->subscriptions->cancel($subscriptionId, []);
|
||||
|
||||
// Update local database
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_past_due' => false,
|
||||
'stripe_feedback' => 'User account deleted',
|
||||
'stripe_comment' => 'Subscription cancelled due to user account deletion at '.now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
// Call the team's subscription ended method to handle cleanup
|
||||
if ($subscription->team) {
|
||||
$subscription->team->subscriptionEnded();
|
||||
}
|
||||
|
||||
\Log::info("Cancelled Stripe subscription: {$subscriptionId} for team: {$subscription->team->name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a single subscription by ID (helper method for external use)
|
||||
*/
|
||||
public static function cancelById(string $subscriptionId): bool
|
||||
{
|
||||
try {
|
||||
if (! isCloud()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripe->subscriptions->cancel($subscriptionId, []);
|
||||
|
||||
// Update local record if exists
|
||||
$subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->first();
|
||||
if ($subscription) {
|
||||
$subscription->update([
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
if ($subscription->team) {
|
||||
$subscription->team->subscriptionEnded();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to cancel subscription {$subscriptionId}: ".$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
131
app/Actions/User/DeleteUserResources.php
Normal file
131
app/Actions/User/DeleteUserResources.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DeleteUserResources
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private bool $isDryRun;
|
||||
|
||||
public function __construct(User $user, bool $isDryRun = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->isDryRun = $isDryRun;
|
||||
}
|
||||
|
||||
public function getResourcesPreview(): array
|
||||
{
|
||||
$applications = collect();
|
||||
$databases = collect();
|
||||
$services = collect();
|
||||
|
||||
// Get all teams the user belongs to
|
||||
$teams = $this->user->teams()->get();
|
||||
|
||||
foreach ($teams as $team) {
|
||||
// Only delete resources from teams that will be FULLY DELETED
|
||||
// This means: user is the ONLY member of the team
|
||||
//
|
||||
// DO NOT delete resources if:
|
||||
// - User is just a member (not owner)
|
||||
// - Team has other members (ownership will be transferred or user just removed)
|
||||
|
||||
$userRole = $team->pivot->role;
|
||||
$memberCount = $team->members->count();
|
||||
|
||||
// Skip if user is not owner
|
||||
if ($userRole !== 'owner') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if team has other members (will be transferred/user removed, not deleted)
|
||||
if ($memberCount > 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only delete resources from teams where user is the ONLY member
|
||||
// These teams will be fully deleted
|
||||
|
||||
// Get all servers for this team
|
||||
$servers = $team->servers()->get();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
// Get applications (custom method returns Collection)
|
||||
$serverApplications = $server->applications();
|
||||
$applications = $applications->merge($serverApplications);
|
||||
|
||||
// Get databases (custom method returns Collection)
|
||||
$serverDatabases = $server->databases();
|
||||
$databases = $databases->merge($serverDatabases);
|
||||
|
||||
// Get services (relationship needs ->get())
|
||||
$serverServices = $server->services()->get();
|
||||
$services = $services->merge($serverServices);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'applications' => $applications->unique('id'),
|
||||
'databases' => $databases->unique('id'),
|
||||
'services' => $services->unique('id'),
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(): array
|
||||
{
|
||||
if ($this->isDryRun) {
|
||||
return [
|
||||
'applications' => 0,
|
||||
'databases' => 0,
|
||||
'services' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$deletedCounts = [
|
||||
'applications' => 0,
|
||||
'databases' => 0,
|
||||
'services' => 0,
|
||||
];
|
||||
|
||||
$resources = $this->getResourcesPreview();
|
||||
|
||||
// Delete applications
|
||||
foreach ($resources['applications'] as $application) {
|
||||
try {
|
||||
$application->forceDelete();
|
||||
$deletedCounts['applications']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete application {$application->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Delete databases
|
||||
foreach ($resources['databases'] as $database) {
|
||||
try {
|
||||
$database->forceDelete();
|
||||
$deletedCounts['databases']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete database {$database->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Delete services
|
||||
foreach ($resources['services'] as $service) {
|
||||
try {
|
||||
$service->forceDelete();
|
||||
$deletedCounts['services']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete service {$service->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
return $deletedCounts;
|
||||
}
|
||||
}
|
||||
77
app/Actions/User/DeleteUserServers.php
Normal file
77
app/Actions/User/DeleteUserServers.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DeleteUserServers
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private bool $isDryRun;
|
||||
|
||||
public function __construct(User $user, bool $isDryRun = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->isDryRun = $isDryRun;
|
||||
}
|
||||
|
||||
public function getServersPreview(): Collection
|
||||
{
|
||||
$servers = collect();
|
||||
|
||||
// Get all teams the user belongs to
|
||||
$teams = $this->user->teams()->get();
|
||||
|
||||
foreach ($teams as $team) {
|
||||
// Only include servers from teams where user is owner or admin
|
||||
$userRole = $team->pivot->role;
|
||||
if ($userRole === 'owner' || $userRole === 'admin') {
|
||||
$teamServers = $team->servers()->get();
|
||||
$servers = $servers->merge($teamServers);
|
||||
}
|
||||
}
|
||||
|
||||
// Return unique servers (in case same server is in multiple teams)
|
||||
return $servers->unique('id');
|
||||
}
|
||||
|
||||
public function execute(): array
|
||||
{
|
||||
if ($this->isDryRun) {
|
||||
return [
|
||||
'servers' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
|
||||
$servers = $this->getServersPreview();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
try {
|
||||
// Skip the default server (ID 0) which is the Coolify host
|
||||
if ($server->id === 0) {
|
||||
\Log::info('Skipping deletion of Coolify host server (ID: 0)');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// The Server model's forceDeleting event will handle cleanup of:
|
||||
// - destinations
|
||||
// - settings
|
||||
$server->forceDelete();
|
||||
$deletedCount++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete server {$server->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'servers' => $deletedCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
202
app/Actions/User/DeleteUserTeams.php
Normal file
202
app/Actions/User/DeleteUserTeams.php
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
class DeleteUserTeams
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private bool $isDryRun;
|
||||
|
||||
public function __construct(User $user, bool $isDryRun = false)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->isDryRun = $isDryRun;
|
||||
}
|
||||
|
||||
public function getTeamsPreview(): array
|
||||
{
|
||||
$teamsToDelete = collect();
|
||||
$teamsToTransfer = collect();
|
||||
$teamsToLeave = collect();
|
||||
$edgeCases = collect();
|
||||
|
||||
$teams = $this->user->teams;
|
||||
|
||||
foreach ($teams as $team) {
|
||||
// Skip root team (ID 0)
|
||||
if ($team->id === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$userRole = $team->pivot->role;
|
||||
$memberCount = $team->members->count();
|
||||
|
||||
if ($memberCount === 1) {
|
||||
// User is alone in the team - delete it
|
||||
$teamsToDelete->push($team);
|
||||
} elseif ($userRole === 'owner') {
|
||||
// Check if there are other owners
|
||||
$otherOwners = $team->members
|
||||
->where('id', '!=', $this->user->id)
|
||||
->filter(function ($member) {
|
||||
return $member->pivot->role === 'owner';
|
||||
});
|
||||
|
||||
if ($otherOwners->isNotEmpty()) {
|
||||
// There are other owners, but check if this user is paying for the subscription
|
||||
if ($this->isUserPayingForTeamSubscription($team)) {
|
||||
// User is paying for the subscription - this is an edge case
|
||||
$edgeCases->push([
|
||||
'team' => $team,
|
||||
'reason' => 'User is paying for the team\'s Stripe subscription but there are other owners. The subscription needs to be cancelled or transferred to another owner\'s payment method.',
|
||||
]);
|
||||
} else {
|
||||
// There are other owners and user is not paying, just remove this user
|
||||
$teamsToLeave->push($team);
|
||||
}
|
||||
} else {
|
||||
// User is the only owner, check for replacement
|
||||
$newOwner = $this->findNewOwner($team);
|
||||
if ($newOwner) {
|
||||
$teamsToTransfer->push([
|
||||
'team' => $team,
|
||||
'new_owner' => $newOwner,
|
||||
]);
|
||||
} else {
|
||||
// No suitable replacement found - this is an edge case
|
||||
$edgeCases->push([
|
||||
'team' => $team,
|
||||
'reason' => 'No suitable owner replacement found. Team has only regular members without admin privileges.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User is just a member - remove them from the team
|
||||
$teamsToLeave->push($team);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'to_delete' => $teamsToDelete,
|
||||
'to_transfer' => $teamsToTransfer,
|
||||
'to_leave' => $teamsToLeave,
|
||||
'edge_cases' => $edgeCases,
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(): array
|
||||
{
|
||||
if ($this->isDryRun) {
|
||||
return [
|
||||
'deleted' => 0,
|
||||
'transferred' => 0,
|
||||
'left' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$counts = [
|
||||
'deleted' => 0,
|
||||
'transferred' => 0,
|
||||
'left' => 0,
|
||||
];
|
||||
|
||||
$preview = $this->getTeamsPreview();
|
||||
|
||||
// Check for edge cases - should not happen here as we check earlier, but be safe
|
||||
if ($preview['edge_cases']->isNotEmpty()) {
|
||||
throw new \Exception('Edge cases detected during execution. This should not happen.');
|
||||
}
|
||||
|
||||
// Delete teams where user is alone
|
||||
foreach ($preview['to_delete'] as $team) {
|
||||
try {
|
||||
// The Team model's deleting event will handle cleanup of:
|
||||
// - private keys
|
||||
// - sources
|
||||
// - tags
|
||||
// - environment variables
|
||||
// - s3 storages
|
||||
// - notification settings
|
||||
$team->delete();
|
||||
$counts['deleted']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to delete team {$team->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer ownership for teams where user is owner but not alone
|
||||
foreach ($preview['to_transfer'] as $item) {
|
||||
try {
|
||||
$team = $item['team'];
|
||||
$newOwner = $item['new_owner'];
|
||||
|
||||
// Update the new owner's role to owner
|
||||
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
|
||||
|
||||
// Remove the current user from the team
|
||||
$team->members()->detach($this->user->id);
|
||||
|
||||
$counts['transferred']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to transfer ownership of team {$item['team']->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
// Remove user from teams where they're just a member
|
||||
foreach ($preview['to_leave'] as $team) {
|
||||
try {
|
||||
$team->members()->detach($this->user->id);
|
||||
$counts['left']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw to trigger rollback
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function findNewOwner(Team $team): ?User
|
||||
{
|
||||
// Only look for admins as potential new owners
|
||||
// We don't promote regular members automatically
|
||||
$otherAdmin = $team->members
|
||||
->where('id', '!=', $this->user->id)
|
||||
->filter(function ($member) {
|
||||
return $member->pivot->role === 'admin';
|
||||
})
|
||||
->first();
|
||||
|
||||
return $otherAdmin;
|
||||
}
|
||||
|
||||
private function isUserPayingForTeamSubscription(Team $team): bool
|
||||
{
|
||||
if (! $team->subscription || ! $team->subscription->stripe_customer_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// In Stripe, we need to check if the customer email matches the user's email
|
||||
// This would require a Stripe API call to get customer details
|
||||
// For now, we'll check if the subscription was created by this user
|
||||
|
||||
// Alternative approach: Check if user is the one who initiated the subscription
|
||||
// We could store this information when the subscription is created
|
||||
// For safety, we'll assume if there's an active subscription and multiple owners,
|
||||
// we should treat it as an edge case that needs manual review
|
||||
|
||||
if ($team->subscription->stripe_subscription_id &&
|
||||
$team->subscription->stripe_invoice_paid) {
|
||||
// Active subscription exists - we should be cautious
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
1173
app/Console/Commands/AdminDeleteUser.php
Normal file
1173
app/Console/Commands/AdminDeleteUser.php
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,56 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class AdminRemoveUser extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'admin:remove-user {email}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Remove User from database';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
$email = $this->argument('email');
|
||||
$confirm = $this->confirm('Are you sure you want to remove user with email: '.$email.'?');
|
||||
if (! $confirm) {
|
||||
$this->info('User removal cancelled.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->info("Removing user with email: $email");
|
||||
$user = User::whereEmail($email)->firstOrFail();
|
||||
$teams = $user->teams;
|
||||
foreach ($teams as $team) {
|
||||
if ($team->members->count() > 1) {
|
||||
$this->error('User is a member of a team with more than one member. Please remove user from team first.');
|
||||
|
||||
return;
|
||||
}
|
||||
$team->delete();
|
||||
}
|
||||
$user->delete();
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to remove user.');
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/Console/Commands/CheckTraefikVersionCommand.php
Normal file
30
app/Console/Commands/CheckTraefikVersionCommand.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CheckTraefikVersionJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckTraefikVersionCommand extends Command
|
||||
{
|
||||
protected $signature = 'traefik:check-version';
|
||||
|
||||
protected $description = 'Check Traefik proxy versions on all servers and send notifications for outdated versions';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Checking Traefik versions on all servers...');
|
||||
|
||||
try {
|
||||
CheckTraefikVersionJob::dispatch();
|
||||
$this->info('Traefik version check job dispatched successfully.');
|
||||
$this->info('Notifications will be sent to teams with outdated Traefik versions.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Failed to dispatch Traefik version check job: '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,13 +64,5 @@ public function handle()
|
|||
if ($this->option('yes')) {
|
||||
$scheduled_task_executions->delete();
|
||||
}
|
||||
|
||||
// Cleanup webhooks table
|
||||
$webhooks = DB::table('webhooks')->where('created_at', '<', now()->subDays($keep_days));
|
||||
$count = $webhooks->count();
|
||||
echo "Delete $count entries from webhooks.\n";
|
||||
if ($this->option('yes')) {
|
||||
$webhooks->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
214
app/Console/Commands/CleanupNames.php
Normal file
214
app/Console/Commands/CleanupNames.php
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Team;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CleanupNames extends Command
|
||||
{
|
||||
protected $signature = 'cleanup:names
|
||||
{--dry-run : Preview changes without applying them}
|
||||
{--model= : Clean specific model (e.g., Project, Server)}
|
||||
{--backup : Create database backup before changes}
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
protected $description = 'Sanitize name fields by removing invalid characters (keeping only letters, numbers, spaces, dashes, underscores, dots, slashes, colons, parentheses)';
|
||||
|
||||
protected array $modelsToClean = [
|
||||
'Project' => Project::class,
|
||||
'Environment' => Environment::class,
|
||||
'Application' => Application::class,
|
||||
'Service' => Service::class,
|
||||
'Server' => Server::class,
|
||||
'Team' => Team::class,
|
||||
'StandalonePostgresql' => StandalonePostgresql::class,
|
||||
'StandaloneMysql' => StandaloneMysql::class,
|
||||
'StandaloneRedis' => StandaloneRedis::class,
|
||||
'StandaloneMongodb' => StandaloneMongodb::class,
|
||||
'StandaloneMariadb' => StandaloneMariadb::class,
|
||||
'StandaloneKeydb' => StandaloneKeydb::class,
|
||||
'StandaloneDragonfly' => StandaloneDragonfly::class,
|
||||
'StandaloneClickhouse' => StandaloneClickhouse::class,
|
||||
'S3Storage' => S3Storage::class,
|
||||
'Tag' => Tag::class,
|
||||
'PrivateKey' => PrivateKey::class,
|
||||
'ScheduledTask' => ScheduledTask::class,
|
||||
];
|
||||
|
||||
protected array $changes = [];
|
||||
|
||||
protected int $totalProcessed = 0;
|
||||
|
||||
protected int $totalCleaned = 0;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('backup') && ! $this->option('dry-run')) {
|
||||
$this->createBackup();
|
||||
}
|
||||
|
||||
$modelFilter = $this->option('model');
|
||||
$modelsToProcess = $modelFilter
|
||||
? [$modelFilter => $this->modelsToClean[$modelFilter] ?? null]
|
||||
: $this->modelsToClean;
|
||||
|
||||
if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
|
||||
$this->error("Unknown model: {$modelFilter}");
|
||||
$this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
foreach ($modelsToProcess as $modelName => $modelClass) {
|
||||
if (! $modelClass) {
|
||||
continue;
|
||||
}
|
||||
$this->processModel($modelName, $modelClass);
|
||||
}
|
||||
|
||||
if (! $this->option('dry-run') && $this->totalCleaned > 0) {
|
||||
$this->logChanges();
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info("Name cleanup: would sanitize {$this->totalCleaned} records");
|
||||
} else {
|
||||
$this->info("Name cleanup: sanitized {$this->totalCleaned} records");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function processModel(string $modelName, string $modelClass): void
|
||||
{
|
||||
try {
|
||||
$records = $modelClass::all(['id', 'name']);
|
||||
$cleaned = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
$this->totalProcessed++;
|
||||
|
||||
$originalName = $record->name;
|
||||
$sanitizedName = $this->sanitizeName($originalName);
|
||||
|
||||
if ($sanitizedName !== $originalName) {
|
||||
$this->changes[] = [
|
||||
'model' => $modelName,
|
||||
'id' => $record->id,
|
||||
'original' => $originalName,
|
||||
'sanitized' => $sanitizedName,
|
||||
'timestamp' => now(),
|
||||
];
|
||||
|
||||
if (! $this->option('dry-run')) {
|
||||
// Update without triggering events/mutators to avoid conflicts
|
||||
$modelClass::where('id', $record->id)->update(['name' => $sanitizedName]);
|
||||
}
|
||||
|
||||
$cleaned++;
|
||||
$this->totalCleaned++;
|
||||
|
||||
// Only log in dry-run mode to preview changes
|
||||
if ($this->option('dry-run')) {
|
||||
$this->warn(" 🧹 {$modelName} #{$record->id}:");
|
||||
$this->line(' From: '.$this->truncate($originalName, 80));
|
||||
$this->line(' To: '.$this->truncate($sanitizedName, 80));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Error processing {$modelName}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected function sanitizeName(string $name): string
|
||||
{
|
||||
// Remove all characters that don't match the allowed pattern
|
||||
// Use the shared ValidationPatterns to ensure consistency
|
||||
$allowedPattern = str_replace(['/', '^', '$'], '', ValidationPatterns::NAME_PATTERN);
|
||||
$sanitized = preg_replace('/[^'.$allowedPattern.']+/', '', $name);
|
||||
|
||||
// Clean up excessive whitespace but preserve other allowed characters
|
||||
$sanitized = preg_replace('/\s+/', ' ', $sanitized);
|
||||
$sanitized = trim($sanitized);
|
||||
|
||||
// If result is empty, provide a default name
|
||||
if (empty($sanitized)) {
|
||||
$sanitized = 'sanitized-item';
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
protected function logChanges(): void
|
||||
{
|
||||
$logFile = storage_path('logs/name-cleanup.log');
|
||||
$logData = [
|
||||
'timestamp' => now()->toISOString(),
|
||||
'total_processed' => $this->totalProcessed,
|
||||
'total_cleaned' => $this->totalCleaned,
|
||||
'changes' => $this->changes,
|
||||
];
|
||||
|
||||
file_put_contents($logFile, json_encode($logData, JSON_PRETTY_PRINT)."\n", FILE_APPEND);
|
||||
|
||||
Log::info('Name Sanitization completed', [
|
||||
'total_processed' => $this->totalProcessed,
|
||||
'total_sanitized' => $this->totalCleaned,
|
||||
'changes_count' => count($this->changes),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createBackup(): void
|
||||
{
|
||||
try {
|
||||
$backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
|
||||
|
||||
// Ensure backup directory exists
|
||||
if (! file_exists(dirname($backupFile))) {
|
||||
mkdir(dirname($backupFile), 0755, true);
|
||||
}
|
||||
|
||||
$dbConfig = config('database.connections.'.config('database.default'));
|
||||
$command = sprintf(
|
||||
'pg_dump -h %s -p %s -U %s -d %s > %s',
|
||||
$dbConfig['host'],
|
||||
$dbConfig['port'],
|
||||
$dbConfig['username'],
|
||||
$dbConfig['database'],
|
||||
$backupFile
|
||||
);
|
||||
|
||||
exec($command, $output, $returnCode);
|
||||
} catch (\Exception $e) {
|
||||
// Log failure but continue - backup is optional safeguard
|
||||
Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function truncate(string $text, int $length): string
|
||||
{
|
||||
return strlen($text) > $length ? substr($text, 0, $length).'...' : $text;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,9 @@
|
|||
|
||||
class CleanupRedis extends Command
|
||||
{
|
||||
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup}';
|
||||
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks} {--restart : Aggressive cleanup mode for system restart (marks all processing jobs as failed)}';
|
||||
|
||||
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, and related data)';
|
||||
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
|
|
@ -18,10 +18,6 @@ public function handle()
|
|||
$dryRun = $this->option('dry-run');
|
||||
$skipOverlapping = $this->option('skip-overlapping');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('DRY RUN MODE - No data will be deleted');
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
$totalKeys = 0;
|
||||
|
||||
|
|
@ -29,8 +25,6 @@ public function handle()
|
|||
$keys = $redis->keys('*');
|
||||
$totalKeys = count($keys);
|
||||
|
||||
$this->info("Scanning {$totalKeys} keys for cleanup...");
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
||||
$type = $redis->command('type', [$keyWithoutPrefix]);
|
||||
|
|
@ -51,15 +45,27 @@ public function handle()
|
|||
|
||||
// Clean up overlapping queues if not skipped
|
||||
if (! $skipOverlapping) {
|
||||
$this->info('Cleaning up overlapping queues...');
|
||||
$overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
|
||||
$deletedCount += $overlappingCleaned;
|
||||
}
|
||||
|
||||
// Clean up stale cache locks (WithoutOverlapping middleware)
|
||||
if ($this->option('clear-locks')) {
|
||||
$locksCleaned = $this->cleanupCacheLocks($dryRun);
|
||||
$deletedCount += $locksCleaned;
|
||||
}
|
||||
|
||||
// Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
|
||||
$isRestart = $this->option('restart');
|
||||
if ($isRestart || $this->option('clear-locks')) {
|
||||
$jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart);
|
||||
$deletedCount += $jobsCleaned;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
|
||||
$this->info("Redis cleanup: would delete {$deletedCount} items");
|
||||
} else {
|
||||
$this->info("Deleted {$deletedCount} out of {$totalKeys} keys");
|
||||
$this->info("Redis cleanup: deleted {$deletedCount} items");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,11 +76,8 @@ private function shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)
|
|||
|
||||
// Delete completed and failed jobs
|
||||
if (in_array($status, ['completed', 'failed'])) {
|
||||
if ($dryRun) {
|
||||
$this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})");
|
||||
} else {
|
||||
if (! $dryRun) {
|
||||
$redis->command('del', [$keyWithoutPrefix]);
|
||||
$this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -100,11 +103,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR
|
|||
|
||||
foreach ($patterns as $pattern => $description) {
|
||||
if (str_contains($keyWithoutPrefix, $pattern)) {
|
||||
if ($dryRun) {
|
||||
$this->line("Would delete {$description}: {$keyWithoutPrefix}");
|
||||
} else {
|
||||
if (! $dryRun) {
|
||||
$redis->command('del', [$keyWithoutPrefix]);
|
||||
$this->line("Deleted {$description}: {$keyWithoutPrefix}");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -117,11 +117,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR
|
|||
$weekAgo = now()->subDays(7)->timestamp;
|
||||
|
||||
if ($timestamp < $weekAgo) {
|
||||
if ($dryRun) {
|
||||
$this->line("Would delete old timestamped data: {$keyWithoutPrefix}");
|
||||
} else {
|
||||
if (! $dryRun) {
|
||||
$redis->command('del', [$keyWithoutPrefix]);
|
||||
$this->line("Deleted old timestamped data: {$keyWithoutPrefix}");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -145,8 +142,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
|
|||
}
|
||||
}
|
||||
|
||||
$this->info('Found '.count($queueKeys).' queue-related keys');
|
||||
|
||||
// Group queues by name pattern to find duplicates
|
||||
$queueGroups = [];
|
||||
foreach ($queueKeys as $queueKey) {
|
||||
|
|
@ -178,7 +173,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
|
|||
private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
|
||||
{
|
||||
$cleanedCount = 0;
|
||||
$this->line("Processing queue group: {$baseName} (".count($keys).' keys)');
|
||||
|
||||
// Sort keys to keep the most recent one
|
||||
usort($keys, function ($a, $b) {
|
||||
|
|
@ -229,11 +223,8 @@ private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
|
|||
}
|
||||
|
||||
if ($shouldDelete) {
|
||||
if ($dryRun) {
|
||||
$this->line(" Would delete empty queue: {$redundantKey}");
|
||||
} else {
|
||||
if (! $dryRun) {
|
||||
$redis->command('del', [$redundantKey]);
|
||||
$this->line(" Deleted empty queue: {$redundantKey}");
|
||||
}
|
||||
$cleanedCount++;
|
||||
}
|
||||
|
|
@ -256,15 +247,12 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun)
|
|||
if (count($uniqueItems) < count($items)) {
|
||||
$duplicates = count($items) - count($uniqueItems);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}");
|
||||
} else {
|
||||
if (! $dryRun) {
|
||||
// Rebuild the list with unique items
|
||||
$redis->command('del', [$queueKey]);
|
||||
foreach (array_reverse($uniqueItems) as $item) {
|
||||
$redis->command('lpush', [$queueKey, $item]);
|
||||
}
|
||||
$this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}");
|
||||
}
|
||||
$cleanedCount += $duplicates;
|
||||
}
|
||||
|
|
@ -273,4 +261,165 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun)
|
|||
|
||||
return $cleanedCount;
|
||||
}
|
||||
|
||||
private function cleanupCacheLocks(bool $dryRun): int
|
||||
{
|
||||
$cleanedCount = 0;
|
||||
|
||||
// Use the default Redis connection (database 0) where cache locks are stored
|
||||
$redis = Redis::connection('default');
|
||||
|
||||
// Get all keys matching WithoutOverlapping lock pattern
|
||||
$allKeys = $redis->keys('*');
|
||||
$lockKeys = [];
|
||||
|
||||
foreach ($allKeys as $key) {
|
||||
// Match cache lock keys: they contain 'laravel-queue-overlap'
|
||||
if (preg_match('/overlap/i', $key)) {
|
||||
$lockKeys[] = $key;
|
||||
}
|
||||
}
|
||||
if (empty($lockKeys)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($lockKeys as $lockKey) {
|
||||
// Check TTL to identify stale locks
|
||||
$ttl = $redis->ttl($lockKey);
|
||||
|
||||
// TTL = -1 means no expiration (stale lock!)
|
||||
// TTL = -2 means key doesn't exist
|
||||
// TTL > 0 means lock is valid and will expire
|
||||
if ($ttl === -1) {
|
||||
if ($dryRun) {
|
||||
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
|
||||
} else {
|
||||
$redis->del($lockKey);
|
||||
}
|
||||
$cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $cleanedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stuck jobs based on mode (restart vs runtime).
|
||||
*
|
||||
* @param mixed $redis Redis connection
|
||||
* @param string $prefix Horizon prefix
|
||||
* @param bool $dryRun Dry run mode
|
||||
* @param bool $isRestart Restart mode (aggressive) vs runtime mode (conservative)
|
||||
* @return int Number of jobs cleaned
|
||||
*/
|
||||
private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $isRestart): int
|
||||
{
|
||||
$cleanedCount = 0;
|
||||
$now = time();
|
||||
|
||||
// Get all keys with the horizon prefix
|
||||
$cursor = 0;
|
||||
$keys = [];
|
||||
do {
|
||||
$result = $redis->scan($cursor, ['match' => '*', 'count' => 100]);
|
||||
|
||||
// Guard against scan() returning false
|
||||
if ($result === false) {
|
||||
$this->error('Redis scan failed, stopping key retrieval');
|
||||
break;
|
||||
}
|
||||
|
||||
$cursor = $result[0];
|
||||
$keys = array_merge($keys, $result[1]);
|
||||
} while ($cursor !== 0);
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
||||
$type = $redis->command('type', [$keyWithoutPrefix]);
|
||||
|
||||
// Only process hash-type keys (individual jobs)
|
||||
if ($type !== 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $redis->command('hgetall', [$keyWithoutPrefix]);
|
||||
$status = data_get($data, 'status');
|
||||
$payload = data_get($data, 'payload');
|
||||
|
||||
// Only process jobs in "processing" or "reserved" state
|
||||
if (! in_array($status, ['processing', 'reserved'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse job payload to get job class and started time
|
||||
$payloadData = json_decode($payload, true);
|
||||
|
||||
// Check for JSON decode errors
|
||||
if ($payloadData === null || json_last_error() !== JSON_ERROR_NONE) {
|
||||
$errorMsg = json_last_error_msg();
|
||||
$truncatedPayload = is_string($payload) ? substr($payload, 0, 200) : 'non-string payload';
|
||||
$this->error("Failed to decode job payload for {$keyWithoutPrefix}: {$errorMsg}. Payload: {$truncatedPayload}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$jobClass = data_get($payloadData, 'displayName', 'Unknown');
|
||||
|
||||
// Prefer reserved_at (when job started processing), fallback to created_at
|
||||
$reservedAt = (int) data_get($data, 'reserved_at', 0);
|
||||
$createdAt = (int) data_get($data, 'created_at', 0);
|
||||
$startTime = $reservedAt ?: $createdAt;
|
||||
|
||||
// If we can't determine when the job started, skip it
|
||||
if (! $startTime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate how long the job has been processing
|
||||
$processingTime = $now - $startTime;
|
||||
|
||||
$shouldFail = false;
|
||||
$reason = '';
|
||||
|
||||
if ($isRestart) {
|
||||
// RESTART MODE: Mark ALL processing/reserved jobs as failed
|
||||
// Safe because all workers are dead on restart
|
||||
$shouldFail = true;
|
||||
$reason = 'System restart - all workers terminated';
|
||||
} else {
|
||||
// RUNTIME MODE: Only mark truly stuck jobs as failed
|
||||
// Be conservative to avoid killing legitimate long-running jobs
|
||||
|
||||
// Skip ApplicationDeploymentJob entirely (has dynamic_timeout, can run 2+ hours)
|
||||
if (str_contains($jobClass, 'ApplicationDeploymentJob')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip DatabaseBackupJob (large backups can take hours)
|
||||
if (str_contains($jobClass, 'DatabaseBackupJob')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other jobs, only fail if processing > 12 hours
|
||||
if ($processingTime > 43200) { // 12 hours
|
||||
$shouldFail = true;
|
||||
$reason = 'Processing for more than 12 hours';
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldFail) {
|
||||
if ($dryRun) {
|
||||
$this->warn(" Would mark as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1)." min) - {$reason}");
|
||||
} else {
|
||||
// Mark job as failed
|
||||
$redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
|
||||
$redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
|
||||
$redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]);
|
||||
}
|
||||
$cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $cleanedCount;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CleanupHelperContainersJob;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
|
|
@ -57,6 +59,15 @@ private function cleanup_stucked_resources()
|
|||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stucked resources: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$servers = Server::onlyTrashed()->get();
|
||||
foreach ($servers as $server) {
|
||||
echo "Force deleting stuck server: {$server->name}\n";
|
||||
$server->forceDelete();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck servers: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$applicationsDeploymentQueue = ApplicationDeploymentQueue::get();
|
||||
foreach ($applicationsDeploymentQueue as $applicationDeploymentQueue) {
|
||||
|
|
@ -72,7 +83,7 @@ private function cleanup_stucked_resources()
|
|||
$applications = Application::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($applications as $application) {
|
||||
echo "Deleting stuck application: {$application->name}\n";
|
||||
$application->forceDelete();
|
||||
DeleteResourceJob::dispatch($application);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck application: {$e->getMessage()}\n";
|
||||
|
|
@ -82,26 +93,35 @@ private function cleanup_stucked_resources()
|
|||
foreach ($applicationsPreviews as $applicationPreview) {
|
||||
if (! data_get($applicationPreview, 'application')) {
|
||||
echo "Deleting stuck application preview: {$applicationPreview->uuid}\n";
|
||||
$applicationPreview->delete();
|
||||
DeleteResourceJob::dispatch($applicationPreview);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck application: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($applicationsPreviews as $applicationPreview) {
|
||||
echo "Deleting stuck application preview: {$applicationPreview->fqdn}\n";
|
||||
DeleteResourceJob::dispatch($applicationPreview);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck application: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($postgresqls as $postgresql) {
|
||||
echo "Deleting stuck postgresql: {$postgresql->name}\n";
|
||||
$postgresql->forceDelete();
|
||||
DeleteResourceJob::dispatch($postgresql);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$redis = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($redis as $redis) {
|
||||
$rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($rediss as $redis) {
|
||||
echo "Deleting stuck redis: {$redis->name}\n";
|
||||
$redis->forceDelete();
|
||||
DeleteResourceJob::dispatch($redis);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck redis: {$e->getMessage()}\n";
|
||||
|
|
@ -110,7 +130,7 @@ private function cleanup_stucked_resources()
|
|||
$keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($keydbs as $keydb) {
|
||||
echo "Deleting stuck keydb: {$keydb->name}\n";
|
||||
$keydb->forceDelete();
|
||||
DeleteResourceJob::dispatch($keydb);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck keydb: {$e->getMessage()}\n";
|
||||
|
|
@ -119,7 +139,7 @@ private function cleanup_stucked_resources()
|
|||
$dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($dragonflies as $dragonfly) {
|
||||
echo "Deleting stuck dragonfly: {$dragonfly->name}\n";
|
||||
$dragonfly->forceDelete();
|
||||
DeleteResourceJob::dispatch($dragonfly);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck dragonfly: {$e->getMessage()}\n";
|
||||
|
|
@ -128,7 +148,7 @@ private function cleanup_stucked_resources()
|
|||
$clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($clickhouses as $clickhouse) {
|
||||
echo "Deleting stuck clickhouse: {$clickhouse->name}\n";
|
||||
$clickhouse->forceDelete();
|
||||
DeleteResourceJob::dispatch($clickhouse);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck clickhouse: {$e->getMessage()}\n";
|
||||
|
|
@ -137,7 +157,7 @@ private function cleanup_stucked_resources()
|
|||
$mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($mongodbs as $mongodb) {
|
||||
echo "Deleting stuck mongodb: {$mongodb->name}\n";
|
||||
$mongodb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mongodb);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n";
|
||||
|
|
@ -146,7 +166,7 @@ private function cleanup_stucked_resources()
|
|||
$mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($mysqls as $mysql) {
|
||||
echo "Deleting stuck mysql: {$mysql->name}\n";
|
||||
$mysql->forceDelete();
|
||||
DeleteResourceJob::dispatch($mysql);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck mysql: {$e->getMessage()}\n";
|
||||
|
|
@ -155,7 +175,7 @@ private function cleanup_stucked_resources()
|
|||
$mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($mariadbs as $mariadb) {
|
||||
echo "Deleting stuck mariadb: {$mariadb->name}\n";
|
||||
$mariadb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mariadb);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n";
|
||||
|
|
@ -164,7 +184,7 @@ private function cleanup_stucked_resources()
|
|||
$services = Service::withTrashed()->whereNotNull('deleted_at')->get();
|
||||
foreach ($services as $service) {
|
||||
echo "Deleting stuck service: {$service->name}\n";
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning stuck service: {$e->getMessage()}\n";
|
||||
|
|
@ -202,9 +222,14 @@ private function cleanup_stucked_resources()
|
|||
try {
|
||||
$scheduled_backups = ScheduledDatabaseBackup::all();
|
||||
foreach ($scheduled_backups as $scheduled_backup) {
|
||||
if (! $scheduled_backup->server()) {
|
||||
echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
|
||||
$scheduled_backup->delete();
|
||||
try {
|
||||
$server = $scheduled_backup->server();
|
||||
if (! $server) {
|
||||
echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
|
||||
$scheduled_backup->delete();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error checking server for scheduledbackup {$scheduled_backup->id}: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -217,19 +242,19 @@ private function cleanup_stucked_resources()
|
|||
foreach ($applications as $application) {
|
||||
if (! data_get($application, 'environment')) {
|
||||
echo 'Application without environment: '.$application->name.'\n';
|
||||
$application->forceDelete();
|
||||
DeleteResourceJob::dispatch($application);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $application->destination()) {
|
||||
echo 'Application without destination: '.$application->name.'\n';
|
||||
$application->forceDelete();
|
||||
DeleteResourceJob::dispatch($application);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($application, 'destination.server')) {
|
||||
echo 'Application without server: '.$application->name.'\n';
|
||||
$application->forceDelete();
|
||||
DeleteResourceJob::dispatch($application);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -242,19 +267,19 @@ private function cleanup_stucked_resources()
|
|||
foreach ($postgresqls as $postgresql) {
|
||||
if (! data_get($postgresql, 'environment')) {
|
||||
echo 'Postgresql without environment: '.$postgresql->name.'\n';
|
||||
$postgresql->forceDelete();
|
||||
DeleteResourceJob::dispatch($postgresql);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $postgresql->destination()) {
|
||||
echo 'Postgresql without destination: '.$postgresql->name.'\n';
|
||||
$postgresql->forceDelete();
|
||||
DeleteResourceJob::dispatch($postgresql);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($postgresql, 'destination.server')) {
|
||||
echo 'Postgresql without server: '.$postgresql->name.'\n';
|
||||
$postgresql->forceDelete();
|
||||
DeleteResourceJob::dispatch($postgresql);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -267,19 +292,19 @@ private function cleanup_stucked_resources()
|
|||
foreach ($redis as $redis) {
|
||||
if (! data_get($redis, 'environment')) {
|
||||
echo 'Redis without environment: '.$redis->name.'\n';
|
||||
$redis->forceDelete();
|
||||
DeleteResourceJob::dispatch($redis);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $redis->destination()) {
|
||||
echo 'Redis without destination: '.$redis->name.'\n';
|
||||
$redis->forceDelete();
|
||||
DeleteResourceJob::dispatch($redis);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($redis, 'destination.server')) {
|
||||
echo 'Redis without server: '.$redis->name.'\n';
|
||||
$redis->forceDelete();
|
||||
DeleteResourceJob::dispatch($redis);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -293,19 +318,19 @@ private function cleanup_stucked_resources()
|
|||
foreach ($mongodbs as $mongodb) {
|
||||
if (! data_get($mongodb, 'environment')) {
|
||||
echo 'Mongodb without environment: '.$mongodb->name.'\n';
|
||||
$mongodb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mongodb);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $mongodb->destination()) {
|
||||
echo 'Mongodb without destination: '.$mongodb->name.'\n';
|
||||
$mongodb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mongodb);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($mongodb, 'destination.server')) {
|
||||
echo 'Mongodb without server: '.$mongodb->name.'\n';
|
||||
$mongodb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mongodb);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -319,19 +344,19 @@ private function cleanup_stucked_resources()
|
|||
foreach ($mysqls as $mysql) {
|
||||
if (! data_get($mysql, 'environment')) {
|
||||
echo 'Mysql without environment: '.$mysql->name.'\n';
|
||||
$mysql->forceDelete();
|
||||
DeleteResourceJob::dispatch($mysql);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $mysql->destination()) {
|
||||
echo 'Mysql without destination: '.$mysql->name.'\n';
|
||||
$mysql->forceDelete();
|
||||
DeleteResourceJob::dispatch($mysql);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($mysql, 'destination.server')) {
|
||||
echo 'Mysql without server: '.$mysql->name.'\n';
|
||||
$mysql->forceDelete();
|
||||
DeleteResourceJob::dispatch($mysql);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -345,19 +370,19 @@ private function cleanup_stucked_resources()
|
|||
foreach ($mariadbs as $mariadb) {
|
||||
if (! data_get($mariadb, 'environment')) {
|
||||
echo 'Mariadb without environment: '.$mariadb->name.'\n';
|
||||
$mariadb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mariadb);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $mariadb->destination()) {
|
||||
echo 'Mariadb without destination: '.$mariadb->name.'\n';
|
||||
$mariadb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mariadb);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($mariadb, 'destination.server')) {
|
||||
echo 'Mariadb without server: '.$mariadb->name.'\n';
|
||||
$mariadb->forceDelete();
|
||||
DeleteResourceJob::dispatch($mariadb);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -371,19 +396,19 @@ private function cleanup_stucked_resources()
|
|||
foreach ($services as $service) {
|
||||
if (! data_get($service, 'environment')) {
|
||||
echo 'Service without environment: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $service->destination()) {
|
||||
echo 'Service without destination: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! data_get($service, 'server')) {
|
||||
echo 'Service without server: '.$service->name.'\n';
|
||||
$service->forceDelete();
|
||||
DeleteResourceJob::dispatch($service);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -417,5 +442,18 @@ private function cleanup_stucked_resources()
|
|||
} catch (\Throwable $e) {
|
||||
echo "Error in ServiceDatabases: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$orphanedCerts = SslCertificate::whereNotIn('server_id', function ($query) {
|
||||
$query->select('id')->from('servers');
|
||||
})->get();
|
||||
|
||||
foreach ($orphanedCerts as $cert) {
|
||||
echo "Deleting orphaned SSL certificate: {$cert->id} (server_id: {$cert->server_id})\n";
|
||||
$cert->delete();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning orphaned SSL certificates: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
app/Console/Commands/ClearGlobalSearchCache.php
Normal file
83
app/Console/Commands/ClearGlobalSearchCache.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Livewire\GlobalSearch;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ClearGlobalSearchCache extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'search:clear {--team= : Clear cache for specific team ID} {--all : Clear cache for all teams}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Clear the global search cache for testing or manual refresh';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('all')) {
|
||||
return $this->clearAllTeamsCache();
|
||||
}
|
||||
|
||||
if ($teamId = $this->option('team')) {
|
||||
return $this->clearTeamCache($teamId);
|
||||
}
|
||||
|
||||
// If no options provided, clear cache for current user's team
|
||||
if (! auth()->check()) {
|
||||
$this->error('No authenticated user found. Use --team=ID or --all option.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
return $this->clearTeamCache($teamId);
|
||||
}
|
||||
|
||||
private function clearTeamCache(int $teamId): int
|
||||
{
|
||||
$team = Team::find($teamId);
|
||||
|
||||
if (! $team) {
|
||||
$this->error("Team with ID {$teamId} not found.");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
$this->info("✓ Cleared global search cache for team: {$team->name} (ID: {$teamId})");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function clearAllTeamsCache(): int
|
||||
{
|
||||
$teams = Team::all();
|
||||
|
||||
if ($teams->isEmpty()) {
|
||||
$this->warn('No teams found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($teams as $team) {
|
||||
GlobalSearch::clearTeamCache($team->id);
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->info("✓ Cleared global search cache for {$count} team(s)");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
879
app/Console/Commands/Cloud/CloudFixSubscription.php
Normal file
879
app/Console/Commands/Cloud/CloudFixSubscription.php
Normal file
|
|
@ -0,0 +1,879 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CloudFixSubscription extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'cloud:fix-subscription
|
||||
{--fix-canceled-subs : Fix canceled subscriptions in database}
|
||||
{--verify-all : Verify all active subscriptions against Stripe}
|
||||
{--fix-verified : Fix discrepancies found during verification}
|
||||
{--dry-run : Show what would be fixed without making changes}
|
||||
{--one : Only fix the first found subscription}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Fix Cloud subscriptions';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
|
||||
if ($this->option('verify-all')) {
|
||||
return $this->verifyAllActiveSubscriptions($stripe);
|
||||
}
|
||||
|
||||
if ($this->option('fix-canceled-subs') || $this->option('dry-run')) {
|
||||
return $this->fixCanceledSubscriptions($stripe);
|
||||
}
|
||||
|
||||
$activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
// CSV header
|
||||
fputcsv($out, [
|
||||
'team_id',
|
||||
'invoice_status',
|
||||
'stripe_customer_url',
|
||||
'stripe_subscription_id',
|
||||
'subscription_status',
|
||||
'subscription_url',
|
||||
'note',
|
||||
]);
|
||||
|
||||
foreach ($activeSubscribers as $team) {
|
||||
$stripeSubscriptionId = $team->subscription->stripe_subscription_id;
|
||||
$stripeInvoicePaid = $team->subscription->stripe_invoice_paid;
|
||||
$stripeCustomerId = $team->subscription->stripe_customer_id;
|
||||
|
||||
if (! $stripeSubscriptionId && str($stripeInvoicePaid)->lower() != 'past_due') {
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$stripeInvoicePaid,
|
||||
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'Missing subscription ID while invoice not past_due',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $stripeSubscriptionId) {
|
||||
// No subscription ID and invoice is past_due, still record for visibility
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$stripeInvoicePaid,
|
||||
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'Missing subscription ID',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId);
|
||||
if ($subscription->status === 'active') {
|
||||
continue;
|
||||
}
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$stripeInvoicePaid,
|
||||
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
|
||||
$stripeSubscriptionId,
|
||||
$subscription->status,
|
||||
"https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}",
|
||||
'Subscription not active',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix canceled subscriptions in the database
|
||||
*/
|
||||
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$checkOne = $this->option('one');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info('DRY RUN MODE - No changes will be made');
|
||||
if ($checkOne) {
|
||||
$this->info('Checking only the first canceled subscription...');
|
||||
} else {
|
||||
$this->info('Checking for canceled subscriptions...');
|
||||
}
|
||||
} else {
|
||||
if ($checkOne) {
|
||||
$this->info('Checking and fixing only the first canceled subscription...');
|
||||
} else {
|
||||
$this->info('Checking and fixing canceled subscriptions...');
|
||||
}
|
||||
}
|
||||
|
||||
$teamsWithSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
|
||||
$toFixCount = 0;
|
||||
$fixedCount = 0;
|
||||
$errors = [];
|
||||
$canceledSubscriptions = [];
|
||||
|
||||
foreach ($teamsWithSubscriptions as $team) {
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if (! $subscription->stripe_subscription_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$subscription->stripe_subscription_id
|
||||
);
|
||||
|
||||
if ($stripeSubscription->status === 'canceled') {
|
||||
$toFixCount++;
|
||||
|
||||
// Get team members' emails
|
||||
$memberEmails = $team->members->pluck('email')->toArray();
|
||||
|
||||
$canceledSubscriptions[] = [
|
||||
'team_id' => $team->id,
|
||||
'team_name' => $team->name,
|
||||
'customer_id' => $subscription->stripe_customer_id,
|
||||
'subscription_id' => $subscription->stripe_subscription_id,
|
||||
'status' => 'canceled',
|
||||
'member_emails' => $memberEmails,
|
||||
'subscription_model' => $subscription->toArray(),
|
||||
];
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('Would fix canceled subscription:');
|
||||
$this->line(" Team ID: {$team->id}");
|
||||
$this->line(" Team Name: {$team->name}");
|
||||
$this->line(' Team Members: '.implode(', ', $memberEmails));
|
||||
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
|
||||
$this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}");
|
||||
$this->line(' Current Subscription Data:');
|
||||
foreach ($subscription->getAttributes() as $key => $value) {
|
||||
if (is_null($value)) {
|
||||
$this->line(" - {$key}: null");
|
||||
} elseif (is_bool($value)) {
|
||||
$this->line(" - {$key}: ".($value ? 'true' : 'false'));
|
||||
} else {
|
||||
$this->line(" - {$key}: {$value}");
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
} else {
|
||||
$this->warn("Found canceled subscription for Team ID: {$team->id}");
|
||||
|
||||
// Send internal notification with all details before fixing
|
||||
$notificationMessage = "Fixing canceled subscription:\n";
|
||||
$notificationMessage .= "Team ID: {$team->id}\n";
|
||||
$notificationMessage .= "Team Name: {$team->name}\n";
|
||||
$notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n";
|
||||
$notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n";
|
||||
$notificationMessage .= "Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}\n";
|
||||
$notificationMessage .= "Subscription Data:\n";
|
||||
foreach ($subscription->getAttributes() as $key => $value) {
|
||||
if (is_null($value)) {
|
||||
$notificationMessage .= " - {$key}: null\n";
|
||||
} elseif (is_bool($value)) {
|
||||
$notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n";
|
||||
} else {
|
||||
$notificationMessage .= " - {$key}: {$value}\n";
|
||||
}
|
||||
}
|
||||
send_internal_notification($notificationMessage);
|
||||
|
||||
// Apply the same logic as customer.subscription.deleted webhook
|
||||
$team->subscriptionEnded();
|
||||
|
||||
$fixedCount++;
|
||||
$this->info(" ✓ Fixed subscription for Team ID: {$team->id}");
|
||||
$this->line(' Team Members: '.implode(', ', $memberEmails));
|
||||
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
|
||||
$this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}");
|
||||
}
|
||||
|
||||
// Break if --one flag is set
|
||||
if ($checkOne) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
if ($e->getStripeCode() === 'resource_missing') {
|
||||
$toFixCount++;
|
||||
|
||||
// Get team members' emails
|
||||
$memberEmails = $team->members->pluck('email')->toArray();
|
||||
|
||||
$canceledSubscriptions[] = [
|
||||
'team_id' => $team->id,
|
||||
'team_name' => $team->name,
|
||||
'customer_id' => $subscription->stripe_customer_id,
|
||||
'subscription_id' => $subscription->stripe_subscription_id,
|
||||
'status' => 'missing',
|
||||
'member_emails' => $memberEmails,
|
||||
'subscription_model' => $subscription->toArray(),
|
||||
];
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->error('Would fix missing subscription (not found in Stripe):');
|
||||
$this->line(" Team ID: {$team->id}");
|
||||
$this->line(" Team Name: {$team->name}");
|
||||
$this->line(' Team Members: '.implode(', ', $memberEmails));
|
||||
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
|
||||
$this->line(" Subscription ID (missing): {$subscription->stripe_subscription_id}");
|
||||
$this->line(' Current Subscription Data:');
|
||||
foreach ($subscription->getAttributes() as $key => $value) {
|
||||
if (is_null($value)) {
|
||||
$this->line(" - {$key}: null");
|
||||
} elseif (is_bool($value)) {
|
||||
$this->line(" - {$key}: ".($value ? 'true' : 'false'));
|
||||
} else {
|
||||
$this->line(" - {$key}: {$value}");
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
} else {
|
||||
$this->error("Subscription not found in Stripe for Team ID: {$team->id}");
|
||||
|
||||
// Send internal notification with all details before fixing
|
||||
$notificationMessage = "Fixing missing subscription (not found in Stripe):\n";
|
||||
$notificationMessage .= "Team ID: {$team->id}\n";
|
||||
$notificationMessage .= "Team Name: {$team->name}\n";
|
||||
$notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n";
|
||||
$notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n";
|
||||
$notificationMessage .= "Subscription ID (missing): {$subscription->stripe_subscription_id}\n";
|
||||
$notificationMessage .= "Subscription Data:\n";
|
||||
foreach ($subscription->getAttributes() as $key => $value) {
|
||||
if (is_null($value)) {
|
||||
$notificationMessage .= " - {$key}: null\n";
|
||||
} elseif (is_bool($value)) {
|
||||
$notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n";
|
||||
} else {
|
||||
$notificationMessage .= " - {$key}: {$value}\n";
|
||||
}
|
||||
}
|
||||
send_internal_notification($notificationMessage);
|
||||
|
||||
// Apply the same logic as customer.subscription.deleted webhook
|
||||
$team->subscriptionEnded();
|
||||
|
||||
$fixedCount++;
|
||||
$this->info(" ✓ Fixed missing subscription for Team ID: {$team->id}");
|
||||
$this->line(' Team Members: '.implode(', ', $memberEmails));
|
||||
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
|
||||
}
|
||||
|
||||
// Break if --one flag is set
|
||||
if ($checkOne) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$errors[] = "Team ID {$team->id}: ".$e->getMessage();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "Team ID {$team->id}: ".$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Summary:');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info(" - Found {$toFixCount} canceled/missing subscriptions that would be fixed");
|
||||
|
||||
if ($toFixCount > 0) {
|
||||
$this->newLine();
|
||||
$this->comment('Run with --fix-canceled-subs to apply these changes');
|
||||
}
|
||||
} else {
|
||||
$this->info(" - Fixed {$fixedCount} canceled/missing subscriptions");
|
||||
}
|
||||
|
||||
if (! empty($errors)) {
|
||||
$this->newLine();
|
||||
$this->error('Errors encountered:');
|
||||
foreach ($errors as $error) {
|
||||
$this->error(" - {$error}");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify all active subscriptions against Stripe API
|
||||
*/
|
||||
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$shouldFix = $this->option('fix-verified');
|
||||
|
||||
$this->info('Verifying all active subscriptions against Stripe...');
|
||||
if ($isDryRun) {
|
||||
$this->info('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
if ($shouldFix && ! $isDryRun) {
|
||||
$this->warn('FIX MODE - Discrepancies will be corrected');
|
||||
}
|
||||
|
||||
// Get all teams with active subscriptions
|
||||
$teamsWithActiveSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
|
||||
$totalCount = $teamsWithActiveSubscriptions->count();
|
||||
|
||||
$this->info("Found {$totalCount} teams with active subscriptions in database");
|
||||
$this->newLine();
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
|
||||
// CSV header
|
||||
fputcsv($out, [
|
||||
'team_id',
|
||||
'team_name',
|
||||
'customer_id',
|
||||
'subscription_id',
|
||||
'db_status',
|
||||
'stripe_status',
|
||||
'action',
|
||||
'member_emails',
|
||||
'customer_url',
|
||||
'subscription_url',
|
||||
]);
|
||||
|
||||
$stats = [
|
||||
'total' => $totalCount,
|
||||
'valid_active' => 0,
|
||||
'valid_past_due' => 0,
|
||||
'canceled' => 0,
|
||||
'missing' => 0,
|
||||
'invalid' => 0,
|
||||
'fixed' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
|
||||
$processedCount = 0;
|
||||
|
||||
foreach ($teamsWithActiveSubscriptions as $team) {
|
||||
$subscription = $team->subscription;
|
||||
$memberEmails = $team->members->pluck('email')->toArray();
|
||||
|
||||
// Database state
|
||||
$dbStatus = 'active';
|
||||
if ($subscription->stripe_past_due) {
|
||||
$dbStatus = 'past_due';
|
||||
}
|
||||
|
||||
$stripeStatus = null;
|
||||
$action = 'none';
|
||||
|
||||
if (! $subscription->stripe_subscription_id) {
|
||||
$this->line("Team {$team->id}: Missing subscription ID, searching in Stripe...");
|
||||
|
||||
$foundResult = null;
|
||||
$searchMethod = null;
|
||||
|
||||
// Search by customer ID
|
||||
if ($subscription->stripe_customer_id) {
|
||||
$this->line(" → Searching by customer ID: {$subscription->stripe_customer_id}");
|
||||
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
|
||||
if ($foundResult) {
|
||||
$searchMethod = $foundResult['method'];
|
||||
}
|
||||
} else {
|
||||
$this->line(' → No customer ID available');
|
||||
}
|
||||
|
||||
// Search by emails if not found
|
||||
if (! $foundResult && count($memberEmails) > 0) {
|
||||
$foundResult = $this->searchSubscriptionsByEmails($stripe, $memberEmails);
|
||||
if ($foundResult) {
|
||||
$searchMethod = $foundResult['method'];
|
||||
|
||||
// Update customer ID if different
|
||||
if (isset($foundResult['customer_id']) && $subscription->stripe_customer_id !== $foundResult['customer_id']) {
|
||||
if ($isDryRun) {
|
||||
$this->warn(" ⚠ Would update customer ID from {$subscription->stripe_customer_id} to {$foundResult['customer_id']}");
|
||||
} elseif ($shouldFix) {
|
||||
$subscription->update(['stripe_customer_id' => $foundResult['customer_id']]);
|
||||
$this->info(" ✓ Updated customer ID to {$foundResult['customer_id']}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($foundResult && isset($foundResult['subscription'])) {
|
||||
// Check if it's an active/past_due subscription
|
||||
if (in_array($foundResult['status'], ['active', 'past_due'])) {
|
||||
// Found an active subscription, handle update
|
||||
$result = $this->handleFoundSubscription(
|
||||
$team,
|
||||
$subscription,
|
||||
$foundResult['subscription'],
|
||||
$searchMethod,
|
||||
$isDryRun,
|
||||
$shouldFix,
|
||||
$stats
|
||||
);
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$result['id'],
|
||||
$dbStatus,
|
||||
$result['status'],
|
||||
$result['action'],
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
$result['url'],
|
||||
]);
|
||||
} else {
|
||||
// Found subscription but it's canceled/expired - needs to be deactivated
|
||||
$this->warn(" → Found {$foundResult['status']} subscription {$foundResult['subscription']->id} - needs deactivation");
|
||||
|
||||
$result = $this->handleMissingSubscription($team, $subscription, $foundResult['status'], $isDryRun, $shouldFix, $stats);
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$foundResult['subscription']->id,
|
||||
$dbStatus,
|
||||
$foundResult['status'],
|
||||
'needs_fix',
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
"https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}",
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// No subscription found at all
|
||||
$this->line(' → No subscription found');
|
||||
|
||||
$stripeStatus = 'not_found';
|
||||
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
'N/A',
|
||||
$dbStatus,
|
||||
$result['status'],
|
||||
$result['action'],
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
'N/A',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// First validate the subscription ID format
|
||||
if (! str_starts_with($subscription->stripe_subscription_id, 'sub_')) {
|
||||
$this->warn(" ⚠ Invalid subscription ID format (doesn't start with 'sub_')");
|
||||
}
|
||||
|
||||
try {
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$subscription->stripe_subscription_id
|
||||
);
|
||||
|
||||
$stripeStatus = $stripeSubscription->status;
|
||||
|
||||
// Determine if action is needed
|
||||
switch ($stripeStatus) {
|
||||
case 'active':
|
||||
$stats['valid_active']++;
|
||||
$action = 'valid';
|
||||
break;
|
||||
|
||||
case 'past_due':
|
||||
$stats['valid_past_due']++;
|
||||
$action = 'valid';
|
||||
// Ensure past_due flag is set
|
||||
if (! $subscription->stripe_past_due) {
|
||||
if ($isDryRun) {
|
||||
$this->info("Would set stripe_past_due=true for Team {$team->id}");
|
||||
} elseif ($shouldFix) {
|
||||
$subscription->update(['stripe_past_due' => true]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'canceled':
|
||||
case 'incomplete_expired':
|
||||
case 'unpaid':
|
||||
case 'incomplete':
|
||||
$stats['canceled']++;
|
||||
$action = 'needs_fix';
|
||||
|
||||
// Only output problematic subscriptions
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
$stripeStatus,
|
||||
$action,
|
||||
implode(', ', $memberEmails),
|
||||
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
|
||||
"https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}",
|
||||
]);
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info("Would deactivate subscription for Team {$team->id} - status: {$stripeStatus}");
|
||||
} elseif ($shouldFix) {
|
||||
$this->fixSubscription($team, $subscription, $stripeStatus);
|
||||
$stats['fixed']++;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$stats['invalid']++;
|
||||
$action = 'unknown';
|
||||
|
||||
// Only output problematic subscriptions
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
$stripeStatus,
|
||||
$action,
|
||||
implode(', ', $memberEmails),
|
||||
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
|
||||
"https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}",
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
$this->error(' → Error: '.$e->getMessage());
|
||||
|
||||
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
|
||||
// Subscription doesn't exist, try to find by customer ID
|
||||
$this->warn(" → Subscription not found, checking customer's subscriptions...");
|
||||
|
||||
$foundResult = null;
|
||||
if ($subscription->stripe_customer_id) {
|
||||
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
|
||||
}
|
||||
|
||||
if ($foundResult && isset($foundResult['subscription']) && in_array($foundResult['status'], ['active', 'past_due'])) {
|
||||
// Found an active subscription with different ID
|
||||
$this->warn(" → ID mismatch! DB: {$subscription->stripe_subscription_id}, Stripe: {$foundResult['subscription']->id}");
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
"WRONG ID: {$subscription->stripe_subscription_id} → {$foundResult['subscription']->id}",
|
||||
$dbStatus,
|
||||
$foundResult['status'],
|
||||
'id_mismatch',
|
||||
implode(', ', $memberEmails),
|
||||
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
|
||||
"https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}",
|
||||
]);
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn(" → Would update subscription ID to {$foundResult['subscription']->id}");
|
||||
} elseif ($shouldFix) {
|
||||
$subscription->update([
|
||||
'stripe_subscription_id' => $foundResult['subscription']->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => $foundResult['status'] === 'past_due',
|
||||
]);
|
||||
$stats['fixed']++;
|
||||
$this->info(' → Updated subscription ID');
|
||||
}
|
||||
|
||||
$stats[$foundResult['status'] === 'active' ? 'valid_active' : 'valid_past_due']++;
|
||||
} else {
|
||||
// No active subscription found
|
||||
$stripeStatus = $foundResult ? $foundResult['status'] : 'not_found';
|
||||
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
$result['status'],
|
||||
$result['action'],
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
$foundResult && isset($foundResult['subscription']) ? "https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}" : 'N/A',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Other API error
|
||||
$stats['errors']++;
|
||||
$this->error(' → API Error - not marking as deleted');
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
'error: '.$e->getStripeCode(),
|
||||
'error',
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
$subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A',
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' → Unexpected error: '.$e->getMessage());
|
||||
$stats['errors']++;
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
'error',
|
||||
'error',
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
$subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$processedCount++;
|
||||
if ($processedCount % 100 === 0) {
|
||||
$this->info("Processed {$processedCount}/{$totalCount} subscriptions...");
|
||||
}
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
|
||||
// Print summary
|
||||
$this->newLine(2);
|
||||
$this->info('=== Verification Summary ===');
|
||||
$this->info("Total subscriptions checked: {$stats['total']}");
|
||||
$this->newLine();
|
||||
|
||||
$this->info('Valid subscriptions in Stripe:');
|
||||
$this->line(" - Active: {$stats['valid_active']}");
|
||||
$this->line(" - Past Due: {$stats['valid_past_due']}");
|
||||
$validTotal = $stats['valid_active'] + $stats['valid_past_due'];
|
||||
$this->info(" Total valid: {$validTotal}");
|
||||
|
||||
$this->newLine();
|
||||
$this->warn('Invalid subscriptions:');
|
||||
$this->line(" - Canceled/Expired: {$stats['canceled']}");
|
||||
$this->line(" - Missing/Not Found: {$stats['missing']}");
|
||||
$this->line(" - Unknown status: {$stats['invalid']}");
|
||||
$invalidTotal = $stats['canceled'] + $stats['missing'] + $stats['invalid'];
|
||||
$this->warn(" Total invalid: {$invalidTotal}");
|
||||
|
||||
if ($stats['errors'] > 0) {
|
||||
$this->newLine();
|
||||
$this->error("Errors encountered: {$stats['errors']}");
|
||||
}
|
||||
|
||||
if ($shouldFix && ! $isDryRun) {
|
||||
$this->newLine();
|
||||
$this->info("Fixed subscriptions: {$stats['fixed']}");
|
||||
} elseif ($invalidTotal > 0 && ! $shouldFix) {
|
||||
$this->newLine();
|
||||
$this->comment('Run with --fix-verified to fix the discrepancies');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix a subscription based on its status
|
||||
*/
|
||||
private function fixSubscription($team, $subscription, $status)
|
||||
{
|
||||
$message = "Fixing subscription for Team ID: {$team->id} (Status: {$status})\n";
|
||||
$message .= "Team Name: {$team->name}\n";
|
||||
$message .= "Customer ID: {$subscription->stripe_customer_id}\n";
|
||||
$message .= "Subscription ID: {$subscription->stripe_subscription_id}\n";
|
||||
|
||||
send_internal_notification($message);
|
||||
|
||||
// Call the team's subscription ended method which properly cleans up
|
||||
$team->subscriptionEnded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for subscriptions by customer ID
|
||||
*/
|
||||
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false)
|
||||
{
|
||||
try {
|
||||
$subscriptions = $stripe->subscriptions->all([
|
||||
'customer' => $customerId,
|
||||
'limit' => 10,
|
||||
'status' => 'all',
|
||||
]);
|
||||
|
||||
$this->line(' → Found '.count($subscriptions->data).' subscription(s) for customer');
|
||||
|
||||
// Look for active/past_due first
|
||||
foreach ($subscriptions->data as $sub) {
|
||||
$this->line(" - Subscription {$sub->id}: status={$sub->status}");
|
||||
if (in_array($sub->status, ['active', 'past_due'])) {
|
||||
$this->info(" ✓ Found active/past_due subscription: {$sub->id}");
|
||||
|
||||
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id'];
|
||||
}
|
||||
}
|
||||
|
||||
// If not requiring active and there are subscriptions, return first one
|
||||
if (! $requireActive && count($subscriptions->data) > 0) {
|
||||
$sub = $subscriptions->data[0];
|
||||
$this->warn(" ⚠ Only found {$sub->status} subscription: {$sub->id}");
|
||||
|
||||
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id_first'];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' → Error searching by customer ID: '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for subscriptions by team member emails
|
||||
*/
|
||||
private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails)
|
||||
{
|
||||
$this->line(' → Searching by team member emails...');
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$this->line(" → Checking email: {$email}");
|
||||
|
||||
try {
|
||||
$customers = $stripe->customers->all([
|
||||
'email' => $email,
|
||||
'limit' => 5,
|
||||
]);
|
||||
|
||||
if (count($customers->data) === 0) {
|
||||
$this->line(' - No customers found');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(' - Found '.count($customers->data).' customer(s)');
|
||||
|
||||
foreach ($customers->data as $customer) {
|
||||
$this->line(" - Checking customer {$customer->id}");
|
||||
|
||||
$result = $this->searchSubscriptionsByCustomer($stripe, $customer->id, true);
|
||||
if ($result) {
|
||||
$result['method'] = "email:{$email}";
|
||||
$result['customer_id'] = $customer->id;
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" - Error searching for email {$email}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle found subscription update (only for active/past_due subscriptions)
|
||||
*/
|
||||
private function handleFoundSubscription($team, $subscription, $foundSub, $searchMethod, $isDryRun, $shouldFix, &$stats)
|
||||
{
|
||||
$stripeStatus = $foundSub->status;
|
||||
$this->info(" ✓ FOUND active/past_due subscription {$foundSub->id} (status: {$stripeStatus})");
|
||||
|
||||
// Only update if it's active or past_due
|
||||
if (! in_array($stripeStatus, ['active', 'past_due'])) {
|
||||
$this->error(" ERROR: handleFoundSubscription called with {$stripeStatus} subscription!");
|
||||
|
||||
return [
|
||||
'id' => $foundSub->id,
|
||||
'status' => $stripeStatus,
|
||||
'action' => 'error',
|
||||
'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}",
|
||||
];
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn(" → Would update subscription ID to {$foundSub->id} (status: {$stripeStatus})");
|
||||
} elseif ($shouldFix) {
|
||||
$subscription->update([
|
||||
'stripe_subscription_id' => $foundSub->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => $stripeStatus === 'past_due',
|
||||
]);
|
||||
$stats['fixed']++;
|
||||
$this->info(" → Updated subscription ID to {$foundSub->id}");
|
||||
}
|
||||
|
||||
// Update stats
|
||||
$stats[$stripeStatus === 'active' ? 'valid_active' : 'valid_past_due']++;
|
||||
|
||||
return [
|
||||
'id' => "FOUND: {$foundSub->id}",
|
||||
'status' => $stripeStatus,
|
||||
'action' => "will_update (via {$searchMethod})",
|
||||
'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle missing subscription
|
||||
*/
|
||||
private function handleMissingSubscription($team, $subscription, $status, $isDryRun, $shouldFix, &$stats)
|
||||
{
|
||||
$stats['missing']++;
|
||||
|
||||
if ($isDryRun) {
|
||||
$statusMsg = $status !== 'not_found' ? "status: {$status}" : 'no subscription found in Stripe';
|
||||
$this->warn(" → Would deactivate subscription - {$statusMsg}");
|
||||
} elseif ($shouldFix) {
|
||||
$this->fixSubscription($team, $subscription, $status);
|
||||
$stats['fixed']++;
|
||||
$this->info(' → Deactivated subscription');
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => 'N/A',
|
||||
'status' => $status,
|
||||
'action' => 'needs_fix',
|
||||
'url' => 'N/A',
|
||||
];
|
||||
}
|
||||
}
|
||||
219
app/Console/Commands/Cloud/RestoreDatabase.php
Normal file
219
app/Console/Commands/Cloud/RestoreDatabase.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RestoreDatabase extends Command
|
||||
{
|
||||
protected $signature = 'cloud:restore-database {file : Path to the database dump file} {--debug : Show detailed debug output}';
|
||||
|
||||
protected $description = 'Restore a PostgreSQL database from a dump file (development mode only)';
|
||||
|
||||
private bool $debug = false;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->debug = $this->option('debug');
|
||||
|
||||
if (! $this->isDevelopment()) {
|
||||
$this->error('This command can only be run in development mode.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$filePath = $this->argument('file');
|
||||
|
||||
if (! file_exists($filePath)) {
|
||||
$this->error("File not found: {$filePath}");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (! is_readable($filePath)) {
|
||||
$this->error("File is not readable: {$filePath}");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->info('Starting database restoration...');
|
||||
|
||||
$database = config('database.connections.pgsql.database');
|
||||
$host = config('database.connections.pgsql.host');
|
||||
$port = config('database.connections.pgsql.port');
|
||||
$username = config('database.connections.pgsql.username');
|
||||
$password = config('database.connections.pgsql.password');
|
||||
|
||||
if (! $database || ! $username) {
|
||||
$this->error('Database configuration is incomplete.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Restoring to database: {$database}");
|
||||
|
||||
// Drop all tables
|
||||
if (! $this->dropAllTables($database, $host, $port, $username, $password)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Restore the database dump
|
||||
if (! $this->restoreDatabaseDump($filePath, $database, $host, $port, $username, $password)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info('Database restoration completed successfully!');
|
||||
|
||||
return 0;
|
||||
} catch (\Exception $e) {
|
||||
$this->error("An error occurred: {$e->getMessage()}");
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private function dropAllTables(string $database, string $host, string $port, string $username, string $password): bool
|
||||
{
|
||||
$this->info('Dropping all tables...');
|
||||
|
||||
// SQL to drop all tables
|
||||
$dropTablesSQL = <<<'SQL'
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
SQL;
|
||||
|
||||
// Build the psql command to drop all tables
|
||||
$command = sprintf(
|
||||
'PGPASSWORD=%s psql -h %s -p %s -U %s -d %s -c %s',
|
||||
escapeshellarg($password),
|
||||
escapeshellarg($host),
|
||||
escapeshellarg($port),
|
||||
escapeshellarg($username),
|
||||
escapeshellarg($database),
|
||||
escapeshellarg($dropTablesSQL)
|
||||
);
|
||||
|
||||
if ($this->debug) {
|
||||
$this->line('<comment>Executing drop command:</comment>');
|
||||
$this->line($command);
|
||||
}
|
||||
|
||||
$output = shell_exec($command.' 2>&1');
|
||||
|
||||
if ($this->debug) {
|
||||
$this->line("<comment>Output:</comment> {$output}");
|
||||
}
|
||||
|
||||
$this->info('All tables dropped successfully.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function restoreDatabaseDump(string $filePath, string $database, string $host, string $port, string $username, string $password): bool
|
||||
{
|
||||
$this->info('Restoring database from dump file...');
|
||||
|
||||
// Handle gzipped files by decompressing first
|
||||
$actualFile = $filePath;
|
||||
if (str_ends_with($filePath, '.gz')) {
|
||||
$actualFile = rtrim($filePath, '.gz');
|
||||
$this->info('Decompressing gzipped dump file...');
|
||||
|
||||
$decompressCommand = sprintf(
|
||||
'gunzip -c %s > %s',
|
||||
escapeshellarg($filePath),
|
||||
escapeshellarg($actualFile)
|
||||
);
|
||||
|
||||
if ($this->debug) {
|
||||
$this->line('<comment>Executing decompress command:</comment>');
|
||||
$this->line($decompressCommand);
|
||||
}
|
||||
|
||||
$decompressOutput = shell_exec($decompressCommand.' 2>&1');
|
||||
if ($this->debug && $decompressOutput) {
|
||||
$this->line("<comment>Decompress output:</comment> {$decompressOutput}");
|
||||
}
|
||||
}
|
||||
|
||||
// Use pg_restore for custom format dumps
|
||||
$command = sprintf(
|
||||
'PGPASSWORD=%s pg_restore -h %s -p %s -U %s -d %s -v %s',
|
||||
escapeshellarg($password),
|
||||
escapeshellarg($host),
|
||||
escapeshellarg($port),
|
||||
escapeshellarg($username),
|
||||
escapeshellarg($database),
|
||||
escapeshellarg($actualFile)
|
||||
);
|
||||
|
||||
if ($this->debug) {
|
||||
$this->line('<comment>Executing restore command:</comment>');
|
||||
$this->line($command);
|
||||
}
|
||||
|
||||
// Execute the restore command
|
||||
$process = proc_open(
|
||||
$command,
|
||||
[
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
],
|
||||
$pipes
|
||||
);
|
||||
|
||||
if (! is_resource($process)) {
|
||||
$this->error('Failed to start restoration process.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
$error = stream_get_contents($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
// Clean up decompressed file if we created one
|
||||
if ($actualFile !== $filePath && file_exists($actualFile)) {
|
||||
unlink($actualFile);
|
||||
}
|
||||
|
||||
if ($this->debug) {
|
||||
if ($output) {
|
||||
$this->line('<comment>Output:</comment>');
|
||||
$this->line($output);
|
||||
}
|
||||
if ($error) {
|
||||
$this->line('<comment>Error output:</comment>');
|
||||
$this->line($error);
|
||||
}
|
||||
$this->line("<comment>Exit code:</comment> {$exitCode}");
|
||||
}
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$this->error("Restoration failed with exit code: {$exitCode}");
|
||||
if ($error) {
|
||||
$this->error('Error details:');
|
||||
$this->error($error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($output && ! $this->debug) {
|
||||
$this->line($output);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isDevelopment(): bool
|
||||
{
|
||||
return app()->environment(['local', 'development', 'dev']);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CloudCheckSubscription extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'cloud:check-subscription';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Check Cloud subscriptions';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
|
||||
foreach ($activeSubscribers as $team) {
|
||||
$stripeSubscriptionId = $team->subscription->stripe_subscription_id;
|
||||
$stripeInvoicePaid = $team->subscription->stripe_invoice_paid;
|
||||
$stripeCustomerId = $team->subscription->stripe_customer_id;
|
||||
if (! $stripeSubscriptionId) {
|
||||
echo "Team {$team->id} has no subscription, but invoice status is: {$stripeInvoicePaid}\n";
|
||||
echo "Link on Stripe: https://dashboard.stripe.com/customers/{$stripeCustomerId}\n";
|
||||
|
||||
continue;
|
||||
}
|
||||
$subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId);
|
||||
if ($subscription->status === 'active') {
|
||||
continue;
|
||||
}
|
||||
echo "Subscription {$stripeSubscriptionId} is not active ({$subscription->status})\n";
|
||||
echo "Link on Stripe: https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CloudCleanupSubscriptions extends Command
|
||||
{
|
||||
protected $signature = 'cloud:cleanup-subs';
|
||||
|
||||
protected $description = 'Cleanup subcriptions teams';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
if (! isCloud()) {
|
||||
$this->error('This command can only be run on cloud');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->info('Cleaning up subcriptions teams');
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
|
||||
$teams = Team::all()->filter(function ($team) {
|
||||
return $team->id !== 0;
|
||||
})->sortBy('id');
|
||||
foreach ($teams as $team) {
|
||||
if ($team) {
|
||||
$this->info("Checking team {$team->id}");
|
||||
}
|
||||
if (! data_get($team, 'subscription')) {
|
||||
$this->disableServers($team);
|
||||
|
||||
continue;
|
||||
}
|
||||
// If the team has no subscription id and the invoice is paid, we need to reset the invoice paid status
|
||||
if (! (data_get($team, 'subscription.stripe_subscription_id'))) {
|
||||
$this->info("Resetting invoice paid status for team {$team->id}");
|
||||
|
||||
$team->subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_subscription_id' => null,
|
||||
]);
|
||||
$this->disableServers($team);
|
||||
|
||||
continue;
|
||||
} else {
|
||||
$subscription = $stripe->subscriptions->retrieve(data_get($team, 'subscription.stripe_subscription_id'), []);
|
||||
$status = data_get($subscription, 'status');
|
||||
if ($status === 'active') {
|
||||
$team->subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_trial_already_ended' => false,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$this->info('Subscription status: '.$status);
|
||||
$this->info('Subscription id: '.data_get($team, 'subscription.stripe_subscription_id'));
|
||||
$confirm = $this->confirm('Do you want to cancel the subscription?', true);
|
||||
if (! $confirm) {
|
||||
$this->info("Skipping team {$team->id}");
|
||||
} else {
|
||||
$this->info("Cancelling subscription for team {$team->id}");
|
||||
$team->subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_subscription_id' => null,
|
||||
]);
|
||||
$this->disableServers($team);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function disableServers(Team $team)
|
||||
{
|
||||
foreach ($team->servers as $server) {
|
||||
if ($server->settings->is_usable === true || $server->settings->is_reachable === true || $server->ip !== '1.2.3.4') {
|
||||
$this->info("Disabling server {$server->id} {$server->name}");
|
||||
$server->settings()->update([
|
||||
'is_usable' => false,
|
||||
'is_reachable' => false,
|
||||
]);
|
||||
$server->update([
|
||||
'ip' => '1.2.3.4',
|
||||
]);
|
||||
|
||||
ServerReachabilityChanged::dispatch($server);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,11 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
|
|
@ -44,5 +48,44 @@ public function init()
|
|||
} else {
|
||||
echo "Instance already initialized.\n";
|
||||
}
|
||||
|
||||
// Clean up stuck jobs and stale locks on development startup
|
||||
try {
|
||||
echo "Cleaning up Redis (stuck jobs and stale locks)...\n";
|
||||
Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
|
||||
echo "Redis cleanup completed.\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:redis: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
if ($updatedTaskCount > 0) {
|
||||
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
if ($updatedBackupCount > 0) {
|
||||
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
CheckHelperImageJob::dispatch();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ public function handle()
|
|||
]);
|
||||
}
|
||||
$output = 'Because of an error, the backup of the database '.$db->name.' failed.';
|
||||
$this->mail = (new BackupFailed($backup, $db, $output))->toMail();
|
||||
$this->mail = (new BackupFailed($backup, $db, $output, $backup->database_name ?? 'unknown'))->toMail();
|
||||
$this->sendEmail();
|
||||
break;
|
||||
case 'backup-success':
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class Services extends Command
|
|||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $description = 'Generate service-templates.yaml based on /templates/compose directory';
|
||||
protected $description = 'Generates service-templates json file based on /templates/compose directory';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
|
|
@ -33,7 +33,10 @@ public function handle(): int
|
|||
];
|
||||
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesJson.PHP_EOL);
|
||||
file_put_contents(base_path('templates/'.config('constants.services.file_name')), $serviceTemplatesJson.PHP_EOL);
|
||||
|
||||
// Generate service-templates.json with SERVICE_URL changed to SERVICE_FQDN
|
||||
$this->generateServiceTemplatesWithFqdn();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
|
@ -71,6 +74,7 @@ private function processFile(string $file): false|array
|
|||
'slogan' => $data->get('slogan', str($file)->headline()),
|
||||
'compose' => $compose,
|
||||
'tags' => $tags,
|
||||
'category' => $data->get('category'),
|
||||
'logo' => $data->get('logo', 'svgs/default.webp'),
|
||||
'minversion' => $data->get('minversion', '0.0.0'),
|
||||
];
|
||||
|
|
@ -86,4 +90,145 @@ private function processFile(string $file): false|array
|
|||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function generateServiceTemplatesWithFqdn(): void
|
||||
{
|
||||
$serviceTemplatesWithFqdn = collect(array_merge(
|
||||
glob(base_path('templates/compose/*.yaml')),
|
||||
glob(base_path('templates/compose/*.yml'))
|
||||
))
|
||||
->mapWithKeys(function ($file): array {
|
||||
$file = basename($file);
|
||||
$parsed = $this->processFileWithFqdn($file);
|
||||
|
||||
return $parsed === false ? [] : [
|
||||
Arr::pull($parsed, 'name') => $parsed,
|
||||
];
|
||||
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesWithFqdn.PHP_EOL);
|
||||
|
||||
// Generate service-templates-raw.json with non-base64 encoded compose content
|
||||
// $this->generateServiceTemplatesRaw();
|
||||
}
|
||||
|
||||
private function processFileWithFqdn(string $file): false|array
|
||||
{
|
||||
$content = file_get_contents(base_path("templates/compose/$file"));
|
||||
|
||||
$data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array {
|
||||
preg_match('/^#(?<key>.*):(?<value>.*)$/U', $line, $m);
|
||||
|
||||
return $m ? [trim($m['key']) => trim($m['value'])] : [];
|
||||
});
|
||||
|
||||
if (str($data->get('ignore'))->toBoolean()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$documentation = $data->get('documentation');
|
||||
$documentation = $documentation ? $documentation.'?utm_source=coolify.io' : 'https://coolify.io/docs';
|
||||
|
||||
// Replace SERVICE_URL with SERVICE_FQDN in the content
|
||||
$modifiedContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $content);
|
||||
|
||||
$json = Yaml::parse($modifiedContent);
|
||||
$compose = base64_encode(Yaml::dump($json, 10, 2));
|
||||
|
||||
$tags = str($data->get('tags'))->lower()->explode(',')->map(fn ($tag) => trim($tag))->filter();
|
||||
$tags = $tags->isEmpty() ? null : $tags->all();
|
||||
|
||||
$payload = [
|
||||
'name' => pathinfo($file, PATHINFO_FILENAME),
|
||||
'documentation' => $documentation,
|
||||
'slogan' => $data->get('slogan', str($file)->headline()),
|
||||
'compose' => $compose,
|
||||
'tags' => $tags,
|
||||
'category' => $data->get('category'),
|
||||
'logo' => $data->get('logo', 'svgs/default.webp'),
|
||||
'minversion' => $data->get('minversion', '0.0.0'),
|
||||
];
|
||||
|
||||
if ($port = $data->get('port')) {
|
||||
$payload['port'] = $port;
|
||||
}
|
||||
|
||||
if ($envFile = $data->get('env_file')) {
|
||||
$envFileContent = file_get_contents(base_path("templates/compose/$envFile"));
|
||||
// Also replace SERVICE_URL with SERVICE_FQDN in env file content
|
||||
$modifiedEnvContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $envFileContent);
|
||||
$payload['envs'] = base64_encode($modifiedEnvContent);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function generateServiceTemplatesRaw(): void
|
||||
{
|
||||
$serviceTemplatesRaw = collect(array_merge(
|
||||
glob(base_path('templates/compose/*.yaml')),
|
||||
glob(base_path('templates/compose/*.yml'))
|
||||
))
|
||||
->mapWithKeys(function ($file): array {
|
||||
$file = basename($file);
|
||||
$parsed = $this->processFileWithFqdnRaw($file);
|
||||
|
||||
return $parsed === false ? [] : [
|
||||
Arr::pull($parsed, 'name') => $parsed,
|
||||
];
|
||||
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
file_put_contents(base_path('templates/service-templates-raw.json'), $serviceTemplatesRaw.PHP_EOL);
|
||||
}
|
||||
|
||||
private function processFileWithFqdnRaw(string $file): false|array
|
||||
{
|
||||
$content = file_get_contents(base_path("templates/compose/$file"));
|
||||
|
||||
$data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array {
|
||||
preg_match('/^#(?<key>.*):(?<value>.*)$/U', $line, $m);
|
||||
|
||||
return $m ? [trim($m['key']) => trim($m['value'])] : [];
|
||||
});
|
||||
|
||||
if (str($data->get('ignore'))->toBoolean()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$documentation = $data->get('documentation');
|
||||
$documentation = $documentation ? $documentation.'?utm_source=coolify.io' : 'https://coolify.io/docs';
|
||||
|
||||
// Replace SERVICE_URL with SERVICE_FQDN in the content
|
||||
$modifiedContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $content);
|
||||
|
||||
$json = Yaml::parse($modifiedContent);
|
||||
$compose = Yaml::dump($json, 10, 2); // Not base64 encoded
|
||||
|
||||
$tags = str($data->get('tags'))->lower()->explode(',')->map(fn ($tag) => trim($tag))->filter();
|
||||
$tags = $tags->isEmpty() ? null : $tags->all();
|
||||
|
||||
$payload = [
|
||||
'name' => pathinfo($file, PATHINFO_FILENAME),
|
||||
'documentation' => $documentation,
|
||||
'slogan' => $data->get('slogan', str($file)->headline()),
|
||||
'compose' => $compose,
|
||||
'tags' => $tags,
|
||||
'category' => $data->get('category'),
|
||||
'logo' => $data->get('logo', 'svgs/default.webp'),
|
||||
'minversion' => $data->get('minversion', '0.0.0'),
|
||||
];
|
||||
|
||||
if ($port = $data->get('port')) {
|
||||
$payload['port'] = $port;
|
||||
}
|
||||
|
||||
if ($envFile = $data->get('env_file')) {
|
||||
$envFileContent = file_get_contents(base_path("templates/compose/$envFile"));
|
||||
// Also replace SERVICE_URL with SERVICE_FQDN in env file content (not base64 encoded)
|
||||
$modifiedEnvContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $envFileContent);
|
||||
$payload['envs'] = $modifiedEnvContent;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,17 @@
|
|||
use App\Enums\ActivityTypes;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
|
@ -18,27 +23,49 @@
|
|||
|
||||
class Init extends Command
|
||||
{
|
||||
protected $signature = 'app:init {--force-cloud}';
|
||||
protected $signature = 'app:init';
|
||||
|
||||
protected $description = 'Cleanup instance related stuffs';
|
||||
|
||||
public $servers = null;
|
||||
|
||||
public InstanceSettings $settings;
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->optimize();
|
||||
Artisan::call('optimize:clear');
|
||||
Artisan::call('optimize');
|
||||
|
||||
if (isCloud() && ! $this->option('force-cloud')) {
|
||||
echo "Skipping init as we are on cloud and --force-cloud option is not set\n";
|
||||
try {
|
||||
$this->pullTemplatesFromCDN();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pullChangelogFromGitHub();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not changelogs from github: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pullHelperImage();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in pullHelperImage command: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
if (isCloud()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->settings = instanceSettings();
|
||||
$this->servers = Server::all();
|
||||
if (! isCloud()) {
|
||||
|
||||
$do_not_track = data_get($this->settings, 'do_not_track', true);
|
||||
if ($do_not_track == false) {
|
||||
$this->sendAliveSignal();
|
||||
get_public_ips();
|
||||
}
|
||||
get_public_ips();
|
||||
|
||||
// Backward compatibility
|
||||
$this->replaceSlashInEnvironmentName();
|
||||
|
|
@ -46,51 +73,83 @@ public function handle()
|
|||
$this->updateUserEmails();
|
||||
//
|
||||
$this->updateTraefikLabels();
|
||||
if (! isCloud() || $this->option('force-cloud')) {
|
||||
$this->cleanupUnusedNetworkFromCoolifyProxy();
|
||||
}
|
||||
|
||||
$this->call('cleanup:redis');
|
||||
|
||||
$this->call('cleanup:stucked-resources');
|
||||
$this->cleanupUnusedNetworkFromCoolifyProxy();
|
||||
|
||||
try {
|
||||
$this->pullHelperImage();
|
||||
$this->call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
//
|
||||
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$this->call('cleanup:names');
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:names command: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
$this->call('cleanup:stucked-resources');
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n";
|
||||
echo "Continuing with initialization - cleanup errors will not prevent Coolify from starting\n";
|
||||
}
|
||||
try {
|
||||
$updatedCount = ApplicationDeploymentQueue::whereIn('status', [
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
])->update([
|
||||
'status' => ApplicationDeploymentStatus::FAILED->value,
|
||||
]);
|
||||
|
||||
if (isCloud()) {
|
||||
try {
|
||||
$this->cleanupUnnecessaryDynamicProxyConfiguration();
|
||||
$this->pullTemplatesFromCDN();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
||||
if ($updatedCount > 0) {
|
||||
echo "Marked {$updatedCount} stuck deployments as failed\n";
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$this->cleanupInProgressApplicationDeployments();
|
||||
$this->pullTemplatesFromCDN();
|
||||
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
if ($updatedTaskCount > 0) {
|
||||
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
||||
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
if ($updatedBackupCount > 0) {
|
||||
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$localhost = $this->servers->where('id', 0)->first();
|
||||
$localhost->setupDynamicProxyConfiguration();
|
||||
if ($localhost) {
|
||||
$localhost->setupDynamicProxyConfiguration();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not setup dynamic configuration: {$e->getMessage()}\n";
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
|
||||
if (! is_null(config('constants.coolify.autoupdate', null))) {
|
||||
if (config('constants.coolify.autoupdate') == true) {
|
||||
echo "Enabling auto-update\n";
|
||||
$settings->update(['is_auto_update_enabled' => true]);
|
||||
$this->settings->update(['is_auto_update_enabled' => true]);
|
||||
} else {
|
||||
echo "Disabling auto-update\n";
|
||||
$settings->update(['is_auto_update_enabled' => false]);
|
||||
$this->settings->update(['is_auto_update_enabled' => false]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -105,21 +164,25 @@ private function pullTemplatesFromCDN()
|
|||
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
||||
if ($response->successful()) {
|
||||
$services = $response->json();
|
||||
File::put(base_path('templates/service-templates.json'), json_encode($services));
|
||||
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
|
||||
}
|
||||
}
|
||||
|
||||
private function optimize()
|
||||
private function pullChangelogFromGitHub()
|
||||
{
|
||||
Artisan::call('optimize:clear');
|
||||
Artisan::call('optimize');
|
||||
try {
|
||||
PullChangelog::dispatch();
|
||||
echo "Changelog fetch initiated\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not fetch changelog from GitHub: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
|
||||
private function updateUserEmails()
|
||||
{
|
||||
try {
|
||||
User::whereRaw('email ~ \'[A-Z]\'')->get()->each(function (User $user) {
|
||||
$user->update(['email' => strtolower($user->email)]);
|
||||
$user->update(['email' => $user->email]);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in updating user emails: {$e->getMessage()}\n";
|
||||
|
|
@ -135,27 +198,6 @@ private function updateTraefikLabels()
|
|||
}
|
||||
}
|
||||
|
||||
private function cleanupUnnecessaryDynamicProxyConfiguration()
|
||||
{
|
||||
foreach ($this->servers as $server) {
|
||||
try {
|
||||
if (! $server->isFunctional()) {
|
||||
continue;
|
||||
}
|
||||
if ($server->id === 0) {
|
||||
continue;
|
||||
}
|
||||
$file = $server->proxyPath().'/dynamic/coolify.yaml';
|
||||
|
||||
return instant_remote_process([
|
||||
"rm -f $file",
|
||||
], $server, false);
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleaning up unnecessary dynamic proxy configuration: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupUnusedNetworkFromCoolifyProxy()
|
||||
{
|
||||
foreach ($this->servers as $server) {
|
||||
|
|
@ -225,13 +267,6 @@ private function sendAliveSignal()
|
|||
{
|
||||
$id = config('app.id');
|
||||
$version = config('constants.coolify.version');
|
||||
$settings = instanceSettings();
|
||||
$do_not_track = data_get($settings, 'do_not_track');
|
||||
if ($do_not_track == true) {
|
||||
echo "Do_not_track is enabled\n";
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version");
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -239,23 +274,6 @@ private function sendAliveSignal()
|
|||
}
|
||||
}
|
||||
|
||||
private function cleanupInProgressApplicationDeployments()
|
||||
{
|
||||
// Cleanup any failed deployments
|
||||
try {
|
||||
if (isCloud()) {
|
||||
return;
|
||||
}
|
||||
$queued_inprogress_deployments = ApplicationDeploymentQueue::whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value])->get();
|
||||
foreach ($queued_inprogress_deployments as $deployment) {
|
||||
$deployment->status = ApplicationDeploymentStatus::FAILED->value;
|
||||
$deployment->save();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
|
||||
private function replaceSlashInEnvironmentName()
|
||||
{
|
||||
if (version_compare('4.0.0-beta.298', config('constants.coolify.version'), '<=')) {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue