Merge branch 'next' into shadow/fix-docker-time-command
This commit is contained in:
commit
1d054b23b8
318 changed files with 21712 additions and 4743 deletions
|
|
@ -1,156 +1,41 @@
|
|||
# AI Instructions Synchronization Guide
|
||||
|
||||
This document explains how AI instructions are organized and synchronized across different AI tools used with Coolify.
|
||||
**This file has moved!**
|
||||
|
||||
## Overview
|
||||
All AI documentation and synchronization guidelines are now in the `.ai/` directory.
|
||||
|
||||
Coolify maintains AI instructions in two parallel systems:
|
||||
## New Locations
|
||||
|
||||
1. **CLAUDE.md** - For Claude Code (claude.ai/code)
|
||||
2. **.cursor/rules/** - For Cursor IDE and other AI assistants
|
||||
- **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)
|
||||
|
||||
Both systems share core principles but are optimized for their respective workflows.
|
||||
## Quick Overview
|
||||
|
||||
## Structure
|
||||
|
||||
### CLAUDE.md
|
||||
- **Purpose**: Condensed, workflow-focused guide for Claude Code
|
||||
- **Format**: Single markdown file
|
||||
- **Includes**:
|
||||
- Quick-reference development commands
|
||||
- High-level architecture overview
|
||||
- Core patterns and guidelines
|
||||
- Embedded Laravel Boost guidelines
|
||||
- References to detailed .cursor/rules/ documentation
|
||||
|
||||
### .cursor/rules/
|
||||
- **Purpose**: Detailed, topic-specific documentation
|
||||
- **Format**: Multiple .mdc files organized by topic
|
||||
- **Structure**:
|
||||
- `README.mdc` - Main index and overview
|
||||
- `cursor_rules.mdc` - Maintenance guidelines
|
||||
- Topic-specific files (testing-patterns.mdc, security-patterns.mdc, etc.)
|
||||
- **Used by**: Cursor IDE, Claude Code (for detailed reference), other AI assistants
|
||||
|
||||
## Cross-References
|
||||
|
||||
Both systems reference each other:
|
||||
|
||||
- **CLAUDE.md** → references `.cursor/rules/` for detailed documentation
|
||||
- **.cursor/rules/README.mdc** → references `CLAUDE.md` for Claude Code workflow
|
||||
- **.cursor/rules/cursor_rules.mdc** → notes that changes should sync with CLAUDE.md
|
||||
|
||||
## Maintaining Consistency
|
||||
|
||||
When updating AI instructions, follow these guidelines:
|
||||
|
||||
### 1. Core Principles (MUST be consistent)
|
||||
- Laravel version (currently Laravel 12)
|
||||
- PHP version (8.4)
|
||||
- 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 workflow changes** (how to run commands, development setup):
|
||||
- Primary: `CLAUDE.md`
|
||||
- Secondary: `.cursor/rules/development-workflow.mdc`
|
||||
|
||||
**For architectural patterns** (how code should be structured):
|
||||
- Primary: `.cursor/rules/` topic files
|
||||
- Secondary: Reference in `CLAUDE.md` "Additional Documentation" section
|
||||
|
||||
**For testing patterns**:
|
||||
- Both: Must be synchronized
|
||||
- `CLAUDE.md` - Contains condensed testing execution rules
|
||||
- `.cursor/rules/testing-patterns.mdc` - Contains detailed examples and patterns
|
||||
|
||||
### 3. Update Checklist
|
||||
|
||||
When making significant changes:
|
||||
|
||||
- [ ] Identify if change affects core principles (version numbers, critical patterns)
|
||||
- [ ] Update primary location (CLAUDE.md or .cursor/rules/)
|
||||
- [ ] Check if update affects cross-referenced content
|
||||
- [ ] Update secondary location if needed
|
||||
- [ ] Verify cross-references are still accurate
|
||||
- [ ] Run: `./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc` (if applicable)
|
||||
|
||||
### 4. Common Inconsistencies to Watch
|
||||
|
||||
- **Version numbers**: Laravel, PHP, package versions
|
||||
- **Testing instructions**: Docker execution requirements
|
||||
- **File paths**: Ensure relative paths work from root
|
||||
- **Command syntax**: Docker commands, artisan commands
|
||||
- **Architecture decisions**: Laravel 10 structure vs Laravel 12+ structure
|
||||
|
||||
## File Organization
|
||||
All AI instructions are now organized in `.ai/` directory:
|
||||
|
||||
```
|
||||
/
|
||||
├── CLAUDE.md # Claude Code instructions (condensed)
|
||||
├── .AI_INSTRUCTIONS_SYNC.md # This file
|
||||
└── .cursor/
|
||||
└── rules/
|
||||
├── README.mdc # Index and overview
|
||||
├── cursor_rules.mdc # Maintenance guide
|
||||
├── testing-patterns.mdc # Testing details
|
||||
├── development-workflow.mdc # Dev setup details
|
||||
├── security-patterns.mdc # Security details
|
||||
├── application-architecture.mdc
|
||||
├── deployment-architecture.mdc
|
||||
├── database-patterns.mdc
|
||||
├── frontend-patterns.mdc
|
||||
├── api-and-routing.mdc
|
||||
├── form-components.mdc
|
||||
├── technology-stack.mdc
|
||||
├── project-overview.mdc
|
||||
└── laravel-boost.mdc # Laravel-specific patterns
|
||||
.ai/
|
||||
├── README.md # Navigation hub
|
||||
├── core/ # Project information
|
||||
├── development/ # Dev workflows
|
||||
├── patterns/ # Code patterns
|
||||
└── meta/ # Documentation guides
|
||||
```
|
||||
|
||||
## Recent Updates
|
||||
### For AI Assistants
|
||||
|
||||
### 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
|
||||
- ✅ Created this synchronization guide
|
||||
- **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
|
||||
|
||||
## Maintenance Commands
|
||||
### Key Principles
|
||||
|
||||
```bash
|
||||
# Check for version inconsistencies
|
||||
grep -r "Laravel [0-9]" CLAUDE.md .cursor/rules/*.mdc
|
||||
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`
|
||||
|
||||
# Check for PHP version consistency
|
||||
grep -r "PHP [0-9]" CLAUDE.md .cursor/rules/*.mdc
|
||||
## For More Information
|
||||
|
||||
# Format all documentation
|
||||
./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc
|
||||
|
||||
# Search for specific patterns across all docs
|
||||
grep -r "pattern_to_check" CLAUDE.md .cursor/rules/
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When contributing documentation:
|
||||
|
||||
1. Check both CLAUDE.md and .cursor/rules/ for existing documentation
|
||||
2. Add to appropriate location(s) based on guidelines above
|
||||
3. Add cross-references if creating new patterns
|
||||
4. Update this file if changing organizational structure
|
||||
5. Verify consistency before submitting PR
|
||||
|
||||
## Questions?
|
||||
|
||||
If unsure about where to document something:
|
||||
|
||||
- **Quick reference / workflow** → CLAUDE.md
|
||||
- **Detailed patterns / examples** → .cursor/rules/[topic].mdc
|
||||
- **Both?** → Start with .cursor/rules/, then reference in CLAUDE.md
|
||||
|
||||
When in doubt, prefer detailed documentation in .cursor/rules/ and concise references in CLAUDE.md.
|
||||
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)
|
||||
604
.ai/core/application-architecture.md
Normal file
604
.ai/core/application-architecture.md
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
# 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
|
||||
|
||||
## 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: High-level project mission, core concepts, and architectural overview
|
||||
globs: README.md, CONTRIBUTING.md, CHANGELOG.md, *.md
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Project Overview
|
||||
|
||||
## What is Coolify?
|
||||
|
|
@ -1,23 +1,19 @@
|
|||
---
|
||||
description: Complete technology stack, dependencies, and infrastructure components
|
||||
globs: composer.json, package.json, docker-compose*.yml, config/*.php
|
||||
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: Development setup, coding standards, contribution guidelines, and best practices
|
||||
globs: **/*.php, composer.json, package.json, *.md, .env.example
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Development Workflow
|
||||
|
||||
## Development Environment Setup
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
---
|
||||
description: Testing strategies with Pest PHP, Laravel Dusk, and quality assurance patterns
|
||||
globs: tests/**/*.php, database/factories/*.php
|
||||
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.
|
||||
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: RESTful API design, routing patterns, webhooks, and HTTP communication
|
||||
globs: routes/*.php, app/Http/Controllers/**/*.php, app/Http/Resources/*.php, app/Http/Requests/*.php
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify API & Routing Architecture
|
||||
|
||||
## Routing Structure
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
---
|
||||
description: Database architecture, models, migrations, relationships, and data management patterns
|
||||
globs: app/Models/*.php, database/migrations/*.php, database/seeders/*.php, app/Actions/Database/*.php
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Database Architecture & Patterns
|
||||
|
||||
## Database Strategy
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
---
|
||||
description: Enhanced form components with built-in authorization system
|
||||
globs: resources/views/**/*.blade.php, app/View/Components/Forms/*.php
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Enhanced Form Components with Authorization
|
||||
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
---
|
||||
description: Livewire components, Alpine.js patterns, Tailwind CSS, and enhanced form components
|
||||
globs: app/Livewire/**/*.php, resources/views/**/*.blade.php, resources/js/**/*.js, resources/css/**/*.css
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Frontend Architecture & Patterns
|
||||
|
||||
## Frontend Philosophy
|
||||
|
|
@ -263,7 +258,7 @@ ### Benefits
|
|||
- **Automatic disabling** for unauthorized users
|
||||
- **Smart behavior** (disables instantSave on checkboxes for unauthorized users)
|
||||
|
||||
For complete documentation, see **[form-components.mdc](mdc:.cursor/rules/form-components.mdc)**
|
||||
For complete documentation, see **[form-components.md](.ai/patterns/form-components.md)**
|
||||
|
||||
## Form Handling Patterns
|
||||
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
---
|
||||
description: Security architecture, authentication, authorization patterns, and enhanced form component security
|
||||
globs: app/Policies/*.php, app/View/Components/Forms/*.php, app/Http/Middleware/*.php, resources/views/**/*.blade.php
|
||||
alwaysApply: true
|
||||
---
|
||||
# Coolify Security Architecture & Patterns
|
||||
|
||||
## Security Philosophy
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
reviews:
|
||||
review_status: false
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
---
|
||||
description: Complete guide to Coolify Cursor rules and development patterns
|
||||
globs: .cursor/rules/*.mdc
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Cursor Rules - Complete Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This comprehensive set of Cursor Rules provides deep insights into **Coolify**, an open-source self-hostable alternative to Heroku/Netlify/Vercel. These rules will help you understand, navigate, and contribute to this complex Laravel-based deployment platform.
|
||||
|
||||
> **Cross-Reference**: This directory is for **detailed, topic-specific rules** used by Cursor IDE and other AI assistants. For Claude Code specifically, also see **[CLAUDE.md](mdc:CLAUDE.md)** which provides a condensed, workflow-focused guide. Both systems share core principles but are optimized for their respective tools.
|
||||
>
|
||||
> **Maintaining Rules**: When updating these rules, see **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for synchronization guidelines to keep CLAUDE.md and .cursor/rules/ consistent.
|
||||
|
||||
## Rule Categories
|
||||
|
||||
### 🏗️ Architecture & Foundation
|
||||
- **[project-overview.mdc](mdc:.cursor/rules/project-overview.mdc)** - What Coolify is and its core mission
|
||||
- **[technology-stack.mdc](mdc:.cursor/rules/technology-stack.mdc)** - Complete technology stack and dependencies
|
||||
- **[application-architecture.mdc](mdc:.cursor/rules/application-architecture.mdc)** - Laravel application structure and patterns
|
||||
|
||||
### 🎨 Frontend Development
|
||||
- **[frontend-patterns.mdc](mdc:.cursor/rules/frontend-patterns.mdc)** - Livewire + Alpine.js + Tailwind architecture
|
||||
- **[form-components.mdc](mdc:.cursor/rules/form-components.mdc)** - Enhanced form components with built-in authorization
|
||||
|
||||
### 🗄️ Data & Backend
|
||||
- **[database-patterns.mdc](mdc:.cursor/rules/database-patterns.mdc)** - Database architecture, models, and data management
|
||||
- **[deployment-architecture.mdc](mdc:.cursor/rules/deployment-architecture.mdc)** - Docker orchestration and deployment workflows
|
||||
|
||||
### 🌐 API & Communication
|
||||
- **[api-and-routing.mdc](mdc:.cursor/rules/api-and-routing.mdc)** - RESTful APIs, webhooks, and routing patterns
|
||||
|
||||
### 🧪 Quality Assurance
|
||||
- **[testing-patterns.mdc](mdc:.cursor/rules/testing-patterns.mdc)** - Testing strategies with Pest PHP and Laravel Dusk
|
||||
|
||||
### 🔧 Development Process
|
||||
- **[development-workflow.mdc](mdc:.cursor/rules/development-workflow.mdc)** - Development setup, coding standards, and contribution guidelines
|
||||
|
||||
### 🔒 Security
|
||||
- **[security-patterns.mdc](mdc:.cursor/rules/security-patterns.mdc)** - Security architecture, authentication, and best practices
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
### Core Application Files
|
||||
- **[app/Models/Application.php](mdc:app/Models/Application.php)** - Main application entity (74KB, highly complex)
|
||||
- **[app/Models/Server.php](mdc:app/Models/Server.php)** - Server management (46KB, complex)
|
||||
- **[app/Models/Service.php](mdc:app/Models/Service.php)** - Service definitions (58KB, complex)
|
||||
- **[app/Models/Team.php](mdc:app/Models/Team.php)** - Multi-tenant structure (8.9KB)
|
||||
|
||||
### Configuration Files
|
||||
- **[composer.json](mdc:composer.json)** - PHP dependencies and Laravel setup
|
||||
- **[package.json](mdc:package.json)** - Frontend dependencies and build scripts
|
||||
- **[vite.config.js](mdc:vite.config.js)** - Frontend build configuration
|
||||
- **[docker-compose.dev.yml](mdc:docker-compose.dev.yml)** - Development environment
|
||||
|
||||
### API Documentation
|
||||
- **[openapi.json](mdc:openapi.json)** - Complete API documentation (373KB)
|
||||
- **[routes/api.php](mdc:routes/api.php)** - API endpoint definitions (13KB)
|
||||
- **[routes/web.php](mdc:routes/web.php)** - Web application routes (21KB)
|
||||
|
||||
## Key Concepts to Understand
|
||||
|
||||
### 1. Multi-Tenant Architecture
|
||||
Coolify uses a **team-based multi-tenancy** model where:
|
||||
- Users belong to multiple teams
|
||||
- Resources are scoped to teams
|
||||
- Access control is team-based
|
||||
- Data isolation is enforced at the database level
|
||||
|
||||
### 2. Deployment Philosophy
|
||||
- **Docker-first** approach for all deployments
|
||||
- **Zero-downtime** deployments with health checks
|
||||
- **Git-based** workflows with webhook integration
|
||||
- **Multi-server** support with SSH connections
|
||||
|
||||
### 3. Technology Stack
|
||||
- **Backend**: Laravel 12 + 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: Laravel application structure, patterns, and architectural decisions
|
||||
globs: app/**/*.php, config/*.php, bootstrap/**/*.php
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Application Architecture
|
||||
|
||||
## Laravel Project Structure
|
||||
|
||||
### **Core Application Directory** ([app/](mdc:app))
|
||||
|
||||
```
|
||||
app/
|
||||
├── Actions/ # Business logic actions (Action pattern)
|
||||
├── Console/ # Artisan commands
|
||||
├── Contracts/ # Interface definitions
|
||||
├── Data/ # Data Transfer Objects (Spatie Laravel Data)
|
||||
├── Enums/ # Enumeration classes
|
||||
├── Events/ # Event classes
|
||||
├── Exceptions/ # Custom exception classes
|
||||
├── Helpers/ # Utility helper classes
|
||||
├── Http/ # HTTP layer (Controllers, Middleware, Requests)
|
||||
├── Jobs/ # Background job classes
|
||||
├── Listeners/ # Event listeners
|
||||
├── Livewire/ # Livewire components (Frontend)
|
||||
├── Models/ # Eloquent models (Domain entities)
|
||||
├── Notifications/ # Notification classes
|
||||
├── Policies/ # Authorization policies
|
||||
├── Providers/ # Service providers
|
||||
├── Repositories/ # Repository pattern implementations
|
||||
├── Services/ # Service layer classes
|
||||
├── Traits/ # Reusable trait classes
|
||||
└── View/ # View composers and creators
|
||||
```
|
||||
|
||||
## Core Domain Models
|
||||
|
||||
### **Infrastructure Management**
|
||||
|
||||
#### **[Server.php](mdc:app/Models/Server.php)** (46KB, 1343 lines)
|
||||
- **Purpose**: Physical/virtual server management
|
||||
- **Key Relationships**:
|
||||
- `hasMany(Application::class)` - Deployed applications
|
||||
- `hasMany(StandalonePostgresql::class)` - Database instances
|
||||
- `belongsTo(Team::class)` - Team ownership
|
||||
- **Key Features**:
|
||||
- SSH connection management
|
||||
- Resource monitoring
|
||||
- Proxy configuration (Traefik/Caddy)
|
||||
- Docker daemon interaction
|
||||
|
||||
#### **[Application.php](mdc:app/Models/Application.php)** (74KB, 1734 lines)
|
||||
- **Purpose**: Application deployment and management
|
||||
- **Key Relationships**:
|
||||
- `belongsTo(Server::class)` - Deployment target
|
||||
- `belongsTo(Environment::class)` - Environment context
|
||||
- `hasMany(ApplicationDeploymentQueue::class)` - Deployment history
|
||||
- **Key Features**:
|
||||
- Git repository integration
|
||||
- Docker build and deployment
|
||||
- Environment variable management
|
||||
- SSL certificate handling
|
||||
|
||||
#### **[Service.php](mdc:app/Models/Service.php)** (58KB, 1325 lines)
|
||||
- **Purpose**: Multi-container service orchestration
|
||||
- **Key Relationships**:
|
||||
- `hasMany(ServiceApplication::class)` - Service components
|
||||
- `hasMany(ServiceDatabase::class)` - Service databases
|
||||
- `belongsTo(Environment::class)` - Environment context
|
||||
- **Key Features**:
|
||||
- Docker Compose generation
|
||||
- Service dependency management
|
||||
- Health check configuration
|
||||
|
||||
### **Team & Project Organization**
|
||||
|
||||
#### **[Team.php](mdc:app/Models/Team.php)** (8.9KB, 308 lines)
|
||||
- **Purpose**: Multi-tenant team management
|
||||
- **Key Relationships**:
|
||||
- `hasMany(User::class)` - Team members
|
||||
- `hasMany(Project::class)` - Team projects
|
||||
- `hasMany(Server::class)` - Team servers
|
||||
- **Key Features**:
|
||||
- Resource limits and quotas
|
||||
- Team-based access control
|
||||
- Subscription management
|
||||
|
||||
#### **[Project.php](mdc:app/Models/Project.php)** (4.3KB, 156 lines)
|
||||
- **Purpose**: Project organization and grouping
|
||||
- **Key Relationships**:
|
||||
- `hasMany(Environment::class)` - Project environments
|
||||
- `belongsTo(Team::class)` - Team ownership
|
||||
- **Key Features**:
|
||||
- Environment isolation
|
||||
- Resource organization
|
||||
|
||||
#### **[Environment.php](mdc:app/Models/Environment.php)**
|
||||
- **Purpose**: Environment-specific configuration
|
||||
- **Key Relationships**:
|
||||
- `hasMany(Application::class)` - Environment applications
|
||||
- `hasMany(Service::class)` - Environment services
|
||||
- `belongsTo(Project::class)` - Project context
|
||||
|
||||
### **Database Management Models**
|
||||
|
||||
#### **Standalone Database Models**
|
||||
- **[StandalonePostgresql.php](mdc:app/Models/StandalonePostgresql.php)** (11KB, 351 lines)
|
||||
- **[StandaloneMysql.php](mdc:app/Models/StandaloneMysql.php)** (11KB, 351 lines)
|
||||
- **[StandaloneMariadb.php](mdc:app/Models/StandaloneMariadb.php)** (10KB, 337 lines)
|
||||
- **[StandaloneMongodb.php](mdc:app/Models/StandaloneMongodb.php)** (12KB, 370 lines)
|
||||
- **[StandaloneRedis.php](mdc:app/Models/StandaloneRedis.php)** (12KB, 394 lines)
|
||||
- **[StandaloneKeydb.php](mdc:app/Models/StandaloneKeydb.php)** (11KB, 347 lines)
|
||||
- **[StandaloneDragonfly.php](mdc:app/Models/StandaloneDragonfly.php)** (11KB, 347 lines)
|
||||
- **[StandaloneClickhouse.php](mdc:app/Models/StandaloneClickhouse.php)** (10KB, 336 lines)
|
||||
|
||||
**Common Features**:
|
||||
- Database configuration management
|
||||
- Backup scheduling and execution
|
||||
- Connection string generation
|
||||
- Health monitoring
|
||||
|
||||
### **Configuration & Settings**
|
||||
|
||||
#### **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** (7.6KB, 219 lines)
|
||||
- **Purpose**: Application environment variable management
|
||||
- **Key Features**:
|
||||
- Encrypted value storage
|
||||
- Build-time vs runtime variables
|
||||
- Shared variable inheritance
|
||||
|
||||
#### **[InstanceSettings.php](mdc:app/Models/InstanceSettings.php)** (3.2KB, 124 lines)
|
||||
- **Purpose**: Global Coolify instance configuration
|
||||
- **Key Features**:
|
||||
- FQDN and port configuration
|
||||
- Auto-update settings
|
||||
- Security configurations
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### **Action Pattern** ([app/Actions/](mdc:app/Actions))
|
||||
|
||||
Using [lorisleiva/laravel-actions](mdc:composer.json) for business logic encapsulation:
|
||||
|
||||
```php
|
||||
// Example Action structure
|
||||
class DeployApplication extends Action
|
||||
{
|
||||
public function handle(Application $application): void
|
||||
{
|
||||
// Business logic for deployment
|
||||
}
|
||||
|
||||
public function asJob(Application $application): void
|
||||
{
|
||||
// Queue job implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Action Categories**:
|
||||
- **Application/**: Deployment and management actions
|
||||
- **Database/**: Database operations
|
||||
- **Server/**: Server management actions
|
||||
- **Service/**: Service orchestration actions
|
||||
|
||||
### **Repository Pattern** ([app/Repositories/](mdc:app/Repositories))
|
||||
|
||||
Data access abstraction layer:
|
||||
- Encapsulates database queries
|
||||
- Provides testable data layer
|
||||
- Abstracts complex query logic
|
||||
|
||||
### **Service Layer** ([app/Services/](mdc:app/Services))
|
||||
|
||||
Business logic services:
|
||||
- External API integrations
|
||||
- Complex business operations
|
||||
- Cross-cutting concerns
|
||||
|
||||
## Data Flow Architecture
|
||||
|
||||
### **Request Lifecycle**
|
||||
|
||||
1. **HTTP Request** → [routes/web.php](mdc:routes/web.php)
|
||||
2. **Middleware** → Authentication, authorization
|
||||
3. **Livewire Component** → [app/Livewire/](mdc:app/Livewire)
|
||||
4. **Action/Service** → Business logic execution
|
||||
5. **Model/Repository** → Data persistence
|
||||
6. **Response** → Livewire reactive update
|
||||
|
||||
### **Background Processing**
|
||||
|
||||
1. **Job Dispatch** → Queue system (Redis)
|
||||
2. **Job Processing** → [app/Jobs/](mdc:app/Jobs)
|
||||
3. **Action Execution** → Business logic
|
||||
4. **Event Broadcasting** → Real-time updates
|
||||
5. **Notification** → User feedback
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### **Multi-Tenant Isolation**
|
||||
|
||||
```php
|
||||
// Team-based query scoping
|
||||
class Application extends Model
|
||||
{
|
||||
public function scopeOwnedByCurrentTeam($query)
|
||||
{
|
||||
return $query->whereHas('environment.project.team', function ($q) {
|
||||
$q->where('id', currentTeam()->id);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Authorization Layers**
|
||||
|
||||
1. **Team Membership** → User belongs to team
|
||||
2. **Resource Ownership** → Resource belongs to team
|
||||
3. **Policy Authorization** → [app/Policies/](mdc:app/Policies)
|
||||
4. **Environment Isolation** → Project/environment boundaries
|
||||
|
||||
### **Data Protection**
|
||||
|
||||
- **Environment Variables**: Encrypted at rest
|
||||
- **SSH Keys**: Secure storage and transmission
|
||||
- **API Tokens**: Sanctum-based authentication
|
||||
- **Audit Logging**: [spatie/laravel-activitylog](mdc:composer.json)
|
||||
|
||||
## Configuration Hierarchy
|
||||
|
||||
### **Global Configuration**
|
||||
- **[InstanceSettings](mdc:app/Models/InstanceSettings.php)**: System-wide settings
|
||||
- **[config/](mdc:config)**: Laravel configuration files
|
||||
|
||||
### **Team Configuration**
|
||||
- **[Team](mdc:app/Models/Team.php)**: Team-specific settings
|
||||
- **[ServerSetting](mdc:app/Models/ServerSetting.php)**: Server configurations
|
||||
|
||||
### **Project Configuration**
|
||||
- **[ProjectSetting](mdc:app/Models/ProjectSetting.php)**: Project settings
|
||||
- **[Environment](mdc:app/Models/Environment.php)**: Environment variables
|
||||
|
||||
### **Application Configuration**
|
||||
- **[ApplicationSetting](mdc:app/Models/ApplicationSetting.php)**: App-specific settings
|
||||
- **[EnvironmentVariable](mdc:app/Models/EnvironmentVariable.php)**: Runtime configuration
|
||||
|
||||
## Event-Driven Architecture
|
||||
|
||||
### **Event Broadcasting** ([app/Events/](mdc:app/Events))
|
||||
|
||||
Real-time updates using Laravel Echo and WebSockets:
|
||||
|
||||
```php
|
||||
// Example event structure
|
||||
class ApplicationDeploymentStarted implements ShouldBroadcast
|
||||
{
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("team.{$this->application->team->id}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Event Listeners** ([app/Listeners/](mdc:app/Listeners))
|
||||
|
||||
- Deployment status updates
|
||||
- Resource monitoring alerts
|
||||
- Notification dispatching
|
||||
- Audit log creation
|
||||
|
||||
## Database Design Patterns
|
||||
|
||||
### **Polymorphic Relationships**
|
||||
|
||||
```php
|
||||
// Environment variables can belong to multiple resource types
|
||||
class EnvironmentVariable extends Model
|
||||
{
|
||||
public function resource(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Team-Based Soft Scoping**
|
||||
|
||||
All major resources include team-based query scoping:
|
||||
|
||||
```php
|
||||
// Automatic team filtering
|
||||
$applications = Application::ownedByCurrentTeam()->get();
|
||||
$servers = Server::ownedByCurrentTeam()->get();
|
||||
```
|
||||
|
||||
### **Configuration Inheritance**
|
||||
|
||||
Environment variables cascade from:
|
||||
1. **Shared Variables** → Team-wide defaults
|
||||
2. **Project Variables** → Project-specific overrides
|
||||
3. **Application Variables** → Application-specific values
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### **Git Provider Integration**
|
||||
|
||||
Abstracted git operations supporting:
|
||||
- **GitHub**: [app/Models/GithubApp.php](mdc:app/Models/GithubApp.php)
|
||||
- **GitLab**: [app/Models/GitlabApp.php](mdc:app/Models/GitlabApp.php)
|
||||
- **Bitbucket**: Webhook integration
|
||||
- **Gitea**: Self-hosted Git support
|
||||
|
||||
### **Docker Integration**
|
||||
|
||||
- **Container Management**: Direct Docker API communication
|
||||
- **Image Building**: Dockerfile and Buildpack support
|
||||
- **Network Management**: Custom Docker networks
|
||||
- **Volume Management**: Persistent storage handling
|
||||
|
||||
### **SSH Communication**
|
||||
|
||||
- **[phpseclib/phpseclib](mdc:composer.json)**: Secure SSH connections
|
||||
- **Multiplexing**: Connection pooling for efficiency
|
||||
- **Key Management**: [PrivateKey](mdc:app/Models/PrivateKey.php) model
|
||||
|
||||
## Testing Architecture
|
||||
|
||||
### **Test Structure** ([tests/](mdc:tests))
|
||||
|
||||
```
|
||||
tests/
|
||||
├── Feature/ # Integration tests
|
||||
├── Unit/ # Unit tests
|
||||
├── Browser/ # Dusk browser tests
|
||||
├── Traits/ # Test helper traits
|
||||
├── Pest.php # Pest configuration
|
||||
└── TestCase.php # Base test case
|
||||
```
|
||||
|
||||
### **Testing Patterns**
|
||||
|
||||
- **Feature Tests**: Full request lifecycle testing
|
||||
- **Unit Tests**: Individual class/method testing
|
||||
- **Browser Tests**: End-to-end user workflows
|
||||
- **Database Testing**: Factories and seeders
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### **Query Optimization**
|
||||
|
||||
- **Eager Loading**: Prevent N+1 queries
|
||||
- **Query Scoping**: Team-based filtering
|
||||
- **Database Indexing**: Optimized for common queries
|
||||
|
||||
### **Caching Strategy**
|
||||
|
||||
- **Redis**: Session and cache storage
|
||||
- **Model Caching**: Frequently accessed data
|
||||
- **Query Caching**: Expensive query results
|
||||
|
||||
### **Background Processing**
|
||||
|
||||
- **Queue Workers**: Horizon-managed job processing
|
||||
- **Job Batching**: Related job grouping
|
||||
- **Failed Job Handling**: Automatic retry logic
|
||||
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,59 +0,0 @@
|
|||
---
|
||||
description: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness.
|
||||
globs: .cursor/rules/*.mdc
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Cursor Rules Maintenance Guide
|
||||
|
||||
> **Important**: These rules in `.cursor/rules/` are shared between Cursor IDE and other AI assistants. Changes here should be reflected in **[CLAUDE.md](mdc:CLAUDE.md)** when they affect core workflows or patterns.
|
||||
>
|
||||
> **Synchronization Guide**: See **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for detailed guidelines on maintaining consistency between CLAUDE.md and .cursor/rules/.
|
||||
|
||||
- **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: Docker orchestration, deployment workflows, and containerization patterns
|
||||
globs: app/Jobs/*.php, app/Actions/Application/*.php, app/Actions/Server/*.php, docker/*.*, *.yml, *.yaml
|
||||
alwaysApply: false
|
||||
---
|
||||
# Coolify Deployment Architecture
|
||||
|
||||
## Deployment Philosophy
|
||||
|
||||
Coolify orchestrates **Docker-based deployments** across multiple servers with automated configuration generation, zero-downtime deployments, and comprehensive monitoring.
|
||||
|
||||
## Core Deployment Components
|
||||
|
||||
### Deployment Models
|
||||
- **[Application.php](mdc:app/Models/Application.php)** - Main application entity with deployment configurations
|
||||
- **[ApplicationDeploymentQueue.php](mdc:app/Models/ApplicationDeploymentQueue.php)** - Deployment job orchestration
|
||||
- **[Service.php](mdc:app/Models/Service.php)** - Multi-container service definitions
|
||||
- **[Server.php](mdc:app/Models/Server.php)** - Target deployment infrastructure
|
||||
|
||||
### Infrastructure Management
|
||||
- **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - SSH key management for secure server access
|
||||
- **[StandaloneDocker.php](mdc:app/Models/StandaloneDocker.php)** - Single container deployments
|
||||
- **[SwarmDocker.php](mdc:app/Models/SwarmDocker.php)** - Docker Swarm orchestration
|
||||
|
||||
## Deployment Workflow
|
||||
|
||||
### 1. Source Code Integration
|
||||
```
|
||||
Git Repository → Webhook → Coolify → Build & Deploy
|
||||
```
|
||||
|
||||
#### Source Control Models
|
||||
- **[GithubApp.php](mdc:app/Models/GithubApp.php)** - GitHub integration and webhooks
|
||||
- **[GitlabApp.php](mdc:app/Models/GitlabApp.php)** - GitLab CI/CD integration
|
||||
|
||||
#### Deployment Triggers
|
||||
- **Git push** to configured branches
|
||||
- **Manual deployment** via UI
|
||||
- **Scheduled deployments** via cron
|
||||
- **API-triggered** deployments
|
||||
|
||||
### 2. Build Process
|
||||
```
|
||||
Source Code → Docker Build → Image Registry → Deployment
|
||||
```
|
||||
|
||||
#### Build Configurations
|
||||
- **Dockerfile detection** and custom Dockerfile support
|
||||
- **Buildpack integration** for framework detection
|
||||
- **Multi-stage builds** for optimization
|
||||
- **Cache layer** management for faster builds
|
||||
|
||||
### 3. Deployment Orchestration
|
||||
```
|
||||
Queue Job → Configuration Generation → Container Deployment → Health Checks
|
||||
```
|
||||
|
||||
## Deployment Actions
|
||||
|
||||
### Location: [app/Actions/](mdc:app/Actions)
|
||||
|
||||
#### Application Deployment Actions
|
||||
- **Application/** - Core application deployment logic
|
||||
- **Docker/** - Docker container management
|
||||
- **Service/** - Multi-container service orchestration
|
||||
- **Proxy/** - Reverse proxy configuration
|
||||
|
||||
#### Database Actions
|
||||
- **Database/** - Database deployment and management
|
||||
- Automated backup scheduling
|
||||
- Connection management and health checks
|
||||
|
||||
#### Server Management Actions
|
||||
- **Server/** - Server provisioning and configuration
|
||||
- SSH connection establishment
|
||||
- Docker daemon management
|
||||
|
||||
## Configuration Generation
|
||||
|
||||
### Dynamic Configuration
|
||||
- **[ConfigurationGenerator.php](mdc:app/Services/ConfigurationGenerator.php)** - Generates deployment configurations
|
||||
- **[ConfigurationRepository.php](mdc:app/Services/ConfigurationRepository.php)** - Configuration management
|
||||
|
||||
### Generated Configurations
|
||||
#### Docker Compose Files
|
||||
```yaml
|
||||
# Generated docker-compose.yml structure
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: ${APP_IMAGE}
|
||||
environment:
|
||||
- ${ENV_VARIABLES}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.app.rule=Host(`${FQDN}`)
|
||||
volumes:
|
||||
- ${VOLUME_MAPPINGS}
|
||||
networks:
|
||||
- coolify
|
||||
```
|
||||
|
||||
#### Nginx Configurations
|
||||
- **Reverse proxy** setup
|
||||
- **SSL termination** with automatic certificates
|
||||
- **Load balancing** for multiple instances
|
||||
- **Custom headers** and routing rules
|
||||
|
||||
## Container Orchestration
|
||||
|
||||
### Docker Integration
|
||||
- **[DockerImageParser.php](mdc:app/Services/DockerImageParser.php)** - Parse and validate Docker images
|
||||
- **Container lifecycle** management
|
||||
- **Resource allocation** and limits
|
||||
- **Network isolation** and communication
|
||||
|
||||
### Volume Management
|
||||
- **[LocalFileVolume.php](mdc:app/Models/LocalFileVolume.php)** - Persistent file storage
|
||||
- **[LocalPersistentVolume.php](mdc:app/Models/LocalPersistentVolume.php)** - Data persistence
|
||||
- **Backup integration** for volume data
|
||||
|
||||
### Network Configuration
|
||||
- **Custom Docker networks** for isolation
|
||||
- **Service discovery** between containers
|
||||
- **Port mapping** and exposure
|
||||
- **SSL/TLS termination**
|
||||
|
||||
## Environment Management
|
||||
|
||||
### Environment Isolation
|
||||
- **[Environment.php](mdc:app/Models/Environment.php)** - Development, staging, production environments
|
||||
- **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** - Application-specific variables
|
||||
- **[SharedEnvironmentVariable.php](mdc:app/Models/SharedEnvironmentVariable.php)** - Cross-application variables
|
||||
|
||||
### Configuration Hierarchy
|
||||
```
|
||||
Instance Settings → Server Settings → Project Settings → Application Settings
|
||||
```
|
||||
|
||||
## Preview Environments
|
||||
|
||||
### Git-Based Previews
|
||||
- **[ApplicationPreview.php](mdc:app/Models/ApplicationPreview.php)** - Preview environment management
|
||||
- **Automatic PR/MR previews** for feature branches
|
||||
- **Isolated environments** for testing
|
||||
- **Automatic cleanup** after merge/close
|
||||
|
||||
### Preview Workflow
|
||||
```
|
||||
Feature Branch → Auto-Deploy → Preview URL → Review → Cleanup
|
||||
```
|
||||
|
||||
## SSL & Security
|
||||
|
||||
### Certificate Management
|
||||
- **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate automation
|
||||
- **Let's Encrypt** integration for free certificates
|
||||
- **Custom certificate** upload support
|
||||
- **Automatic renewal** and monitoring
|
||||
|
||||
### Security Patterns
|
||||
- **Private Docker networks** for container isolation
|
||||
- **SSH key-based** server authentication
|
||||
- **Environment variable** encryption
|
||||
- **Access control** via team permissions
|
||||
|
||||
## Backup & Recovery
|
||||
|
||||
### Database Backups
|
||||
- **[ScheduledDatabaseBackup.php](mdc:app/Models/ScheduledDatabaseBackup.php)** - Automated database backups
|
||||
- **[ScheduledDatabaseBackupExecution.php](mdc:app/Models/ScheduledDatabaseBackupExecution.php)** - Backup execution tracking
|
||||
- **S3-compatible storage** for backup destinations
|
||||
|
||||
### Application Backups
|
||||
- **Volume snapshots** for persistent data
|
||||
- **Configuration export** for disaster recovery
|
||||
- **Cross-region replication** for high availability
|
||||
|
||||
## Monitoring & Logging
|
||||
|
||||
### Real-Time Monitoring
|
||||
- **[ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php)** - Live deployment monitoring
|
||||
- **WebSocket-based** log streaming
|
||||
- **Container health checks** and alerts
|
||||
- **Resource usage** tracking
|
||||
|
||||
### Deployment Logs
|
||||
- **Build process** logging
|
||||
- **Container startup** logs
|
||||
- **Application runtime** logs
|
||||
- **Error tracking** and alerting
|
||||
|
||||
## Queue System
|
||||
|
||||
### Background Jobs
|
||||
Location: [app/Jobs/](mdc:app/Jobs)
|
||||
- **Deployment jobs** for async processing
|
||||
- **Server monitoring** jobs
|
||||
- **Backup scheduling** jobs
|
||||
- **Notification delivery** jobs
|
||||
|
||||
### Queue Processing
|
||||
- **Redis-backed** job queues
|
||||
- **Laravel Horizon** for queue monitoring
|
||||
- **Failed job** retry mechanisms
|
||||
- **Queue worker** auto-scaling
|
||||
|
||||
## Multi-Server Deployment
|
||||
|
||||
### Server Types
|
||||
- **Standalone servers** - Single Docker host
|
||||
- **Docker Swarm** - Multi-node orchestration
|
||||
- **Remote servers** - SSH-based deployment
|
||||
- **Local development** - Docker Desktop integration
|
||||
|
||||
### Load Balancing
|
||||
- **Traefik integration** for automatic load balancing
|
||||
- **Health check** based routing
|
||||
- **Blue-green deployments** for zero downtime
|
||||
- **Rolling updates** with configurable strategies
|
||||
|
||||
## Deployment Strategies
|
||||
|
||||
### Zero-Downtime Deployment
|
||||
```
|
||||
Old Container → New Container Build → Health Check → Traffic Switch → Old Container Cleanup
|
||||
```
|
||||
|
||||
### Blue-Green Deployment
|
||||
- **Parallel environments** for safe deployments
|
||||
- **Instant rollback** capability
|
||||
- **Database migration** handling
|
||||
- **Configuration synchronization**
|
||||
|
||||
### Rolling Updates
|
||||
- **Gradual instance** replacement
|
||||
- **Configurable update** strategy
|
||||
- **Automatic rollback** on failure
|
||||
- **Health check** validation
|
||||
|
||||
## API Integration
|
||||
|
||||
### Deployment API
|
||||
Routes: [routes/api.php](mdc:routes/api.php)
|
||||
- **RESTful endpoints** for deployment management
|
||||
- **Webhook receivers** for CI/CD integration
|
||||
- **Status reporting** endpoints
|
||||
- **Deployment triggering** via API
|
||||
|
||||
### Authentication
|
||||
- **Laravel Sanctum** API tokens
|
||||
- **Team-based** access control
|
||||
- **Rate limiting** for API calls
|
||||
- **Audit logging** for API usage
|
||||
|
||||
## Error Handling & Recovery
|
||||
|
||||
### Deployment Failure Recovery
|
||||
- **Automatic rollback** on deployment failure
|
||||
- **Health check** failure handling
|
||||
- **Container crash** recovery
|
||||
- **Resource exhaustion** protection
|
||||
|
||||
### Monitoring & Alerting
|
||||
- **Failed deployment** notifications
|
||||
- **Resource threshold** alerts
|
||||
- **SSL certificate** expiry warnings
|
||||
- **Backup failure** notifications
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Build Optimization
|
||||
- **Docker layer** caching
|
||||
- **Multi-stage builds** for smaller images
|
||||
- **Build artifact** reuse
|
||||
- **Parallel build** processing
|
||||
|
||||
### Runtime Optimization
|
||||
- **Container resource** limits
|
||||
- **Auto-scaling** based on metrics
|
||||
- **Connection pooling** for databases
|
||||
- **CDN integration** for static assets
|
||||
|
||||
## Compliance & Governance
|
||||
|
||||
### Audit Trail
|
||||
- **Deployment history** tracking
|
||||
- **Configuration changes** logging
|
||||
- **User action** auditing
|
||||
- **Resource access** monitoring
|
||||
|
||||
### Backup Compliance
|
||||
- **Retention policies** for backups
|
||||
- **Encryption at rest** for sensitive data
|
||||
- **Cross-region** backup replication
|
||||
- **Recovery testing** automation
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### CI/CD Integration
|
||||
- **GitHub Actions** compatibility
|
||||
- **GitLab CI** pipeline integration
|
||||
- **Custom webhook** endpoints
|
||||
- **Build status** reporting
|
||||
|
||||
### External Services
|
||||
- **S3-compatible** storage integration
|
||||
- **External database** connections
|
||||
- **Third-party monitoring** tools
|
||||
- **Custom notification** channels
|
||||
|
|
@ -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,59 +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
|
||||
|
||||
|
||||
- **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.
|
||||
69
.github/workflows/coolify-helper-next.yml
vendored
69
.github/workflows/coolify-helper-next.yml
vendored
|
|
@ -17,8 +17,17 @@ env:
|
|||
IMAGE_NAME: "coollabsio/coolify-helper"
|
||||
|
||||
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@v5
|
||||
with:
|
||||
|
|
@ -43,60 +52,22 @@ jobs:
|
|||
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 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -126,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
|
||||
|
||||
|
|
|
|||
69
.github/workflows/coolify-helper.yml
vendored
69
.github/workflows/coolify-helper.yml
vendored
|
|
@ -17,8 +17,17 @@ env:
|
|||
IMAGE_NAME: "coollabsio/coolify-helper"
|
||||
|
||||
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@v5
|
||||
with:
|
||||
|
|
@ -43,59 +52,21 @@ jobs:
|
|||
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 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -125,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
|
||||
|
||||
|
|
|
|||
68
.github/workflows/coolify-production-build.yml
vendored
68
.github/workflows/coolify-production-build.yml
vendored
|
|
@ -24,8 +24,17 @@ env:
|
|||
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@v5
|
||||
with:
|
||||
|
|
@ -50,57 +59,20 @@ jobs:
|
|||
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@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
needs: [amd64, aarch64]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -130,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
|
||||
|
||||
|
|
|
|||
71
.github/workflows/coolify-realtime-next.yml
vendored
71
.github/workflows/coolify-realtime-next.yml
vendored
|
|
@ -21,8 +21,17 @@ env:
|
|||
IMAGE_NAME: "coollabsio/coolify-realtime"
|
||||
|
||||
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@v5
|
||||
with:
|
||||
|
|
@ -47,62 +56,22 @@ jobs:
|
|||
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 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
- 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
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -132,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
|
||||
|
||||
|
|
|
|||
70
.github/workflows/coolify-realtime.yml
vendored
70
.github/workflows/coolify-realtime.yml
vendored
|
|
@ -21,8 +21,17 @@ env:
|
|||
IMAGE_NAME: "coollabsio/coolify-realtime"
|
||||
|
||||
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@v5
|
||||
with:
|
||||
|
|
@ -47,61 +56,22 @@ jobs:
|
|||
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 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -131,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
|
||||
|
||||
|
|
|
|||
65
.github/workflows/coolify-testing-host.yml
vendored
65
.github/workflows/coolify-testing-host.yml
vendored
|
|
@ -17,8 +17,17 @@ env:
|
|||
IMAGE_NAME: "coollabsio/coolify-testing-host"
|
||||
|
||||
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@v5
|
||||
with:
|
||||
|
|
@ -38,56 +47,22 @@ jobs:
|
|||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_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 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -112,13 +87,15 @@ jobs:
|
|||
- 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
|
||||
|
|
|
|||
491
CLAUDE.md
491
CLAUDE.md
|
|
@ -2,9 +2,9 @@ # 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. If you're using Cursor IDE, refer to the `.cursor/rules/` directory for detailed rule files. Both systems share core principles but are optimized for their respective workflows.
|
||||
> **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_INSTRUCTIONS_SYNC.md](.AI_INSTRUCTIONS_SYNC.md) for synchronization guidelines between CLAUDE.md and .cursor/rules/.
|
||||
> **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
|
||||
|
||||
|
|
@ -27,7 +27,8 @@ ### Backend Development
|
|||
### Code Quality
|
||||
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
|
||||
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
|
||||
- `./vendor/bin/pest` - Run Pest tests (unit tests only, without database)
|
||||
- `./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:
|
||||
|
|
@ -39,12 +40,14 @@ ### Running Tests
|
|||
## Architecture Overview
|
||||
|
||||
### Technology Stack
|
||||
- **Backend**: Laravel 12 (PHP 8.4)
|
||||
- **Frontend**: Livewire 3.5+ with Alpine.js and Tailwind CSS 4.1+
|
||||
- **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
|
||||
- **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
|
||||
|
||||
|
|
@ -256,453 +259,61 @@ ## Important Reminders
|
|||
|
||||
## Additional Documentation
|
||||
|
||||
This file contains high-level guidelines for Claude Code. For **more detailed, topic-specific documentation**, refer to the `.cursor/rules/` directory (also accessible by Cursor IDE and other AI assistants):
|
||||
This file contains high-level guidelines for Claude Code. For **more detailed, topic-specific documentation**, refer to the `.ai/` directory:
|
||||
|
||||
> **Cross-Reference**: The `.cursor/rules/` directory contains comprehensive, detailed documentation organized by topic. Start with [.cursor/rules/README.mdc](.cursor/rules/README.mdc) for an overview, then explore specific topics below.
|
||||
> **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.
|
||||
|
||||
### Architecture & Patterns
|
||||
- [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure
|
||||
- [Deployment Architecture](.cursor/rules/deployment-architecture.mdc) - Deployment patterns and flows
|
||||
- [Database Patterns](.cursor/rules/database-patterns.mdc) - Database design and query patterns
|
||||
- [Frontend Patterns](.cursor/rules/frontend-patterns.mdc) - Livewire and Alpine.js patterns
|
||||
- [API & Routing](.cursor/rules/api-and-routing.mdc) - API design and routing conventions
|
||||
### 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 & Security
|
||||
- [Development Workflow](.cursor/rules/development-workflow.mdc) - Development best practices
|
||||
- [Security Patterns](.cursor/rules/security-patterns.mdc) - Security implementation details
|
||||
- [Form Components](.cursor/rules/form-components.mdc) - Enhanced form components with authorization
|
||||
- [Testing Patterns](.cursor/rules/testing-patterns.mdc) - Testing strategies and examples
|
||||
### 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
|
||||
|
||||
### Project Information
|
||||
- [Project Overview](.cursor/rules/project-overview.mdc) - High-level project structure
|
||||
- [Technology Stack](.cursor/rules/technology-stack.mdc) - Detailed tech stack information
|
||||
- [Cursor Rules Guide](.cursor/rules/cursor_rules.mdc) - How to maintain cursor rules
|
||||
### 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>
|
||||
=== foundation rules ===
|
||||
## Laravel Boost Guidelines
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
> **Full Guidelines**: See [.ai/development/laravel-boost.md](.ai/development/laravel-boost.md) for complete 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.
|
||||
### Essential Laravel Patterns
|
||||
|
||||
## 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.
|
||||
- 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)
|
||||
|
||||
- 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
|
||||
### 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
|
||||
|
||||
## 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.
|
||||
### Livewire & Frontend
|
||||
|
||||
## 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.
|
||||
- **Unit tests** MUST use mocking and avoid database. They can run outside Docker.
|
||||
- **Feature tests** can use database but MUST run inside Docker container.
|
||||
- **Design for testability**: Structure code to be testable without database when possible. Use dependency injection and interfaces.
|
||||
- **Mock by default**: Prefer `Mockery::mock()` over `Model::factory()->create()` in unit tests.
|
||||
- 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
|
||||
**IMPORTANT**: Always run tests in the correct environment based on database dependencies:
|
||||
|
||||
**Unit Tests (no database):**
|
||||
- Run outside Docker: `./vendor/bin/pest tests/Unit`
|
||||
- Run specific file: `./vendor/bin/pest tests/Unit/ProxyCustomCommandsTest.php`
|
||||
- These tests use mocking and don't require PostgreSQL
|
||||
|
||||
**Feature Tests (with database):**
|
||||
- Run inside Docker: `docker exec coolify php artisan test`
|
||||
- Run specific file: `docker exec coolify php artisan test tests/Feature/ExampleTest.php`
|
||||
- Filter by name: `docker exec coolify php artisan test --filter=testName`
|
||||
- These tests require PostgreSQL and use factories/migrations
|
||||
|
||||
**General Guidelines:**
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite
|
||||
- If you get database connection errors, you're running a Feature test outside Docker - move it inside
|
||||
|
||||
### 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.
|
||||
- **For Unit tests**: Use `./vendor/bin/pest tests/Unit/YourTest.php` (runs outside Docker)
|
||||
- **For Feature tests**: Use `docker exec coolify php artisan test --filter=YourTest` (runs inside Docker)
|
||||
- Choose the correct test type based on database dependency:
|
||||
- No database needed? → Unit test with mocking
|
||||
- Database needed? → Feature test in Docker
|
||||
</laravel-boost-guidelines>
|
||||
- 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:
|
||||
|
|
|
|||
32
README.md
32
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,14 +48,14 @@ ## 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
|
||||
|
||||
* [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
|
||||
|
|
@ -88,7 +92,7 @@ ## Big Sponsors
|
|||
* [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>
|
||||
|
|
@ -141,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">
|
||||
|
|
@ -157,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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
@ -28,6 +32,10 @@ class GetContainersStatus
|
|||
|
||||
protected ?Collection $applicationContainerStatuses;
|
||||
|
||||
protected ?Collection $applicationContainerRestartCounts;
|
||||
|
||||
protected ?Collection $serviceContainerStatuses;
|
||||
|
||||
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
||||
{
|
||||
$this->containers = $containers;
|
||||
|
|
@ -95,11 +103,15 @@ 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');
|
||||
$containerHealth = data_get($container, 'State.Health.Status');
|
||||
if ($containerStatus === 'restarting') {
|
||||
$containerStatus = "restarting ($containerHealth)";
|
||||
$healthSuffix = $containerHealth ?? 'unknown';
|
||||
$containerStatus = "restarting:$healthSuffix";
|
||||
} elseif ($containerStatus === 'exited') {
|
||||
// Keep as-is, no health suffix for exited containers
|
||||
} else {
|
||||
$containerStatus = "$containerStatus ($containerHealth)";
|
||||
$healthSuffix = $containerHealth ?? 'unknown';
|
||||
$containerStatus = "$containerStatus:$healthSuffix";
|
||||
}
|
||||
$labels = Arr::undot(format_docker_labels_to_json($labels));
|
||||
$applicationId = data_get($labels, 'coolify.applicationId');
|
||||
|
|
@ -136,6 +148,18 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
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.
|
||||
}
|
||||
|
|
@ -207,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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -291,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) {
|
||||
|
|
@ -340,88 +392,144 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
continue;
|
||||
}
|
||||
|
||||
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
|
||||
if ($aggregatedStatus) {
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $aggregatedStatus) {
|
||||
$application->update(['status' => $aggregatedStatus]);
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
// 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): ?string
|
||||
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 = collect();
|
||||
|
||||
if ($dockerComposeRaw) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
// Check if container should be excluded
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse, treat all containers as included
|
||||
}
|
||||
}
|
||||
$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, don't update status
|
||||
// If all containers are excluded, calculate status from excluded containers
|
||||
if ($relevantStatuses->isEmpty()) {
|
||||
return null;
|
||||
return $this->calculateExcludedStatusFromStrings($containerStatuses);
|
||||
}
|
||||
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasExited = false;
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
|
||||
foreach ($relevantStatuses as $status) {
|
||||
if (str($status)->contains('restarting')) {
|
||||
$hasRestarting = true;
|
||||
} elseif (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) {
|
||||
$hasUnhealthy = true;
|
||||
return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount);
|
||||
}
|
||||
|
||||
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
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses);
|
||||
|
||||
// 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()]);
|
||||
}
|
||||
} elseif (str($status)->contains('exited')) {
|
||||
$hasExited = true;
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasRestarting) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning) {
|
||||
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
}
|
||||
|
||||
// All containers are exited
|
||||
return 'exited (unhealthy)';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ 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) {
|
||||
|
|
@ -22,7 +22,10 @@ public function handle(Server $server, bool $async = true, bool $force = false):
|
|||
$server->proxy->set('status', 'starting');
|
||||
$server->save();
|
||||
$server->refresh();
|
||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||
|
||||
if (! $restarting) {
|
||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||
}
|
||||
|
||||
$commands = collect([]);
|
||||
$proxy_path = $server->proxyPath();
|
||||
|
|
@ -60,9 +63,22 @@ 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.'",
|
||||
|
|
|
|||
|
|
@ -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 -t $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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,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',
|
||||
|
|
@ -70,35 +68,6 @@ 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...'",
|
||||
]);
|
||||
|
|
|
|||
57
app/Actions/Server/InstallPrerequisites.php
Normal file
57
app/Actions/Server/InstallPrerequisites.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?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',
|
||||
]);
|
||||
} else {
|
||||
throw new \Exception('Unsupported OS type for prerequisites installation');
|
||||
}
|
||||
|
||||
$command->push("echo 'Prerequisites installed successfully.'");
|
||||
|
||||
return remote_process($command, $server);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Jobs\PullHelperImageJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Sleep;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
|
@ -50,7 +49,9 @@ public function handle($manual_update = false)
|
|||
|
||||
private function update()
|
||||
{
|
||||
PullHelperImageJob::dispatch($this->server);
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$latest_version = getHelperVersion();
|
||||
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
|
||||
|
||||
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
|
||||
instant_remote_process(["docker pull -q $image"], $this->server, false);
|
||||
|
|
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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,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,11 +20,11 @@ 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;
|
||||
}
|
||||
|
|
@ -46,11 +49,11 @@ public function handle(Application $application)
|
|||
}
|
||||
} 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;
|
||||
}
|
||||
|
|
@ -61,74 +64,25 @@ public function handle(Application $application)
|
|||
private function aggregateContainerStatuses($application, $containers)
|
||||
{
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = collect();
|
||||
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||
|
||||
if ($dockerComposeRaw) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse, treat all containers as included
|
||||
}
|
||||
}
|
||||
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasExited = false;
|
||||
$relevantContainerCount = 0;
|
||||
|
||||
foreach ($containers as $container) {
|
||||
// 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');
|
||||
|
||||
if ($serviceName && $excludedContainers->contains($serviceName)) {
|
||||
continue;
|
||||
}
|
||||
return ! ($serviceName && $excludedContainers->contains($serviceName));
|
||||
});
|
||||
|
||||
$relevantContainerCount++;
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
|
||||
if ($containerStatus === 'restarting') {
|
||||
$hasRestarting = true;
|
||||
$hasUnhealthy = true;
|
||||
} elseif ($containerStatus === 'running') {
|
||||
$hasRunning = true;
|
||||
if ($containerHealth === 'unhealthy') {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
} elseif ($containerStatus === 'exited') {
|
||||
$hasExited = true;
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
// If 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);
|
||||
}
|
||||
|
||||
if ($relevantContainerCount === 0) {
|
||||
return 'running:healthy';
|
||||
}
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
|
||||
if ($hasRestarting) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
if ($hasRunning) {
|
||||
return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy';
|
||||
}
|
||||
|
||||
return 'exited:unhealthy';
|
||||
return $aggregator->aggregateFromContainers($relevantContainers);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -63,8 +63,6 @@ class CleanupNames extends Command
|
|||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('🔍 Scanning for invalid characters in name fields...');
|
||||
|
||||
if ($this->option('backup') && ! $this->option('dry-run')) {
|
||||
$this->createBackup();
|
||||
}
|
||||
|
|
@ -75,7 +73,7 @@ public function handle(): int
|
|||
: $this->modelsToClean;
|
||||
|
||||
if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
|
||||
$this->error("❌ Unknown model: {$modelFilter}");
|
||||
$this->error("Unknown model: {$modelFilter}");
|
||||
$this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
|
||||
|
||||
return self::FAILURE;
|
||||
|
|
@ -88,19 +86,21 @@ public function handle(): int
|
|||
$this->processModel($modelName, $modelClass);
|
||||
}
|
||||
|
||||
$this->displaySummary();
|
||||
|
||||
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
|
||||
{
|
||||
$this->info("\n📋 Processing {$modelName}...");
|
||||
|
||||
try {
|
||||
$records = $modelClass::all(['id', 'name']);
|
||||
$cleaned = 0;
|
||||
|
|
@ -128,21 +128,17 @@ protected function processModel(string $modelName, string $modelClass): void
|
|||
$cleaned++;
|
||||
$this->totalCleaned++;
|
||||
|
||||
$this->warn(" 🧹 {$modelName} #{$record->id}:");
|
||||
$this->line(' From: '.$this->truncate($originalName, 80));
|
||||
$this->line(' To: '.$this->truncate($sanitizedName, 80));
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($cleaned > 0) {
|
||||
$action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized';
|
||||
$this->info(" ✅ {$cleaned}/{$records->count()} records {$action}");
|
||||
} else {
|
||||
$this->info(' ✨ No invalid characters found');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ❌ Error processing {$modelName}: ".$e->getMessage());
|
||||
$this->error("Error processing {$modelName}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,28 +161,6 @@ protected function sanitizeName(string $name): string
|
|||
return $sanitized;
|
||||
}
|
||||
|
||||
protected function displaySummary(): void
|
||||
{
|
||||
$this->info("\n".str_repeat('=', 60));
|
||||
$this->info('📊 CLEANUP SUMMARY');
|
||||
$this->info(str_repeat('=', 60));
|
||||
|
||||
$this->line("Records processed: {$this->totalProcessed}");
|
||||
$this->line("Records with invalid characters: {$this->totalCleaned}");
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->warn("\n🔍 DRY RUN - No changes were made to the database");
|
||||
$this->info('Run without --dry-run to apply these changes');
|
||||
} else {
|
||||
if ($this->totalCleaned > 0) {
|
||||
$this->info("\n✅ Database successfully sanitized!");
|
||||
$this->info('Changes logged to storage/logs/name-cleanup.log');
|
||||
} else {
|
||||
$this->info("\n✨ No cleanup needed - all names are valid!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function logChanges(): void
|
||||
{
|
||||
$logFile = storage_path('logs/name-cleanup.log');
|
||||
|
|
@ -208,8 +182,6 @@ protected function logChanges(): void
|
|||
|
||||
protected function createBackup(): void
|
||||
{
|
||||
$this->info('💾 Creating database backup...');
|
||||
|
||||
try {
|
||||
$backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
|
||||
|
||||
|
|
@ -229,15 +201,9 @@ protected function createBackup(): void
|
|||
);
|
||||
|
||||
exec($command, $output, $returnCode);
|
||||
|
||||
if ($returnCode === 0) {
|
||||
$this->info("✅ Backup created: {$backupFile}");
|
||||
} else {
|
||||
$this->warn('⚠️ Backup creation may have failed. Proceeding anyway...');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->warn('⚠️ Could not create backup: '.$e->getMessage());
|
||||
$this->warn('Proceeding without backup...');
|
||||
// Log failure but continue - backup is optional safeguard
|
||||
Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
class CleanupRedis extends Command
|
||||
{
|
||||
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}';
|
||||
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, cache locks, and related data)';
|
||||
|
||||
|
|
@ -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,22 +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')) {
|
||||
$this->info('Cleaning up stale cache 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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,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;
|
||||
|
|
@ -107,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;
|
||||
|
|
@ -124,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;
|
||||
|
|
@ -152,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) {
|
||||
|
|
@ -185,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) {
|
||||
|
|
@ -236,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++;
|
||||
}
|
||||
|
|
@ -263,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;
|
||||
}
|
||||
|
|
@ -299,13 +280,9 @@ private function cleanupCacheLocks(bool $dryRun): int
|
|||
}
|
||||
}
|
||||
if (empty($lockKeys)) {
|
||||
$this->info(' No cache locks found.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info(' Found '.count($lockKeys).' cache lock(s)');
|
||||
|
||||
foreach ($lockKeys as $lockKey) {
|
||||
// Check TTL to identify stale locks
|
||||
$ttl = $redis->ttl($lockKey);
|
||||
|
|
@ -318,16 +295,129 @@ private function cleanupCacheLocks(bool $dryRun): int
|
|||
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
|
||||
} else {
|
||||
$redis->del($lockKey);
|
||||
$this->info(" ✓ Deleted STALE lock: {$lockKey}");
|
||||
}
|
||||
$cleanedCount++;
|
||||
} elseif ($ttl > 0) {
|
||||
$this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($cleanedCount === 0) {
|
||||
$this->info(' No stale locks found (all locks have expiration set)');
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -222,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) {
|
||||
|
|
@ -416,7 +421,7 @@ private function cleanup_stucked_resources()
|
|||
foreach ($serviceApplications as $service) {
|
||||
if (! data_get($service, 'service')) {
|
||||
echo 'ServiceApplication without service: '.$service->name.'\n';
|
||||
DeleteResourceJob::dispatch($service);
|
||||
$service->forceDelete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -429,7 +434,7 @@ private function cleanup_stucked_resources()
|
|||
foreach ($serviceDatabases as $service) {
|
||||
if (! data_get($service, 'service')) {
|
||||
echo 'ServiceDatabase without service: '.$service->name.'\n';
|
||||
DeleteResourceJob::dispatch($service);
|
||||
$service->forceDelete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
|
||||
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;
|
||||
|
||||
|
|
@ -45,6 +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':
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@
|
|||
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;
|
||||
|
|
@ -73,7 +76,7 @@ public function handle()
|
|||
$this->cleanupUnusedNetworkFromCoolifyProxy();
|
||||
|
||||
try {
|
||||
$this->call('cleanup:redis', ['--clear-locks' => true]);
|
||||
$this->call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
|
||||
}
|
||||
|
|
@ -86,6 +89,7 @@ public function handle()
|
|||
$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', [
|
||||
|
|
@ -102,6 +106,34 @@ public function handle()
|
|||
echo "Could not cleanup inprogress deployments: {$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";
|
||||
}
|
||||
|
||||
try {
|
||||
$localhost = $this->servers->where('id', 0)->first();
|
||||
if ($localhost) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class SyncBunny extends Command
|
|||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}';
|
||||
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
|
|
@ -50,6 +50,7 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
|
||||
// Clone the repository
|
||||
$this->info('Cloning coolify-cdn repository...');
|
||||
$output = [];
|
||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||
|
|
@ -59,6 +60,7 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
|
||||
// Create feature branch
|
||||
$this->info('Creating feature branch...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||
|
|
@ -70,12 +72,25 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
// Write releases.json
|
||||
$this->info('Writing releases.json...');
|
||||
$releasesPath = "$tmpDir/json/releases.json";
|
||||
$releasesDir = dirname($releasesPath);
|
||||
|
||||
// Ensure directory exists
|
||||
if (! is_dir($releasesDir)) {
|
||||
$this->info("Creating directory: $releasesDir");
|
||||
if (! mkdir($releasesDir, 0755, true)) {
|
||||
$this->error("Failed to create directory: $releasesDir");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
|
||||
|
||||
if ($bytesWritten === false) {
|
||||
$this->error("Failed to write releases.json to: $releasesPath");
|
||||
$this->error('Possible reasons: directory does not exist, permission denied, or disk full.');
|
||||
$this->error('Possible reasons: permission denied or disk full.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
|
|
@ -83,6 +98,7 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
|
||||
// Stage and commit
|
||||
$this->info('Committing changes...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||
|
|
@ -120,6 +136,7 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
|
||||
// Push to remote
|
||||
$this->info('Pushing branch to remote...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||
|
|
@ -133,6 +150,7 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
|
||||
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
|
||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||
$output = [];
|
||||
exec($prCommand, $output, $returnCode);
|
||||
|
||||
// Clean up
|
||||
|
|
@ -158,6 +176,343 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync both releases.json and versions.json to GitHub repository in one PR
|
||||
*/
|
||||
private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
|
||||
{
|
||||
$this->info('Syncing releases.json and versions.json to GitHub repository...');
|
||||
try {
|
||||
// 1. Fetch releases from GitHub API
|
||||
$this->info('Fetching releases from GitHub API...');
|
||||
$response = Http::timeout(30)
|
||||
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
|
||||
'per_page' => 30,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$this->error('Failed to fetch releases from GitHub: '.$response->status());
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$releases = $response->json();
|
||||
|
||||
// 2. Read versions.json
|
||||
if (! file_exists($versionsLocation)) {
|
||||
$this->error("versions.json not found at: $versionsLocation");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$file = file_get_contents($versionsLocation);
|
||||
$versionsJson = json_decode($file, true);
|
||||
$actualVersion = data_get($versionsJson, 'coolify.v4.version');
|
||||
|
||||
$timestamp = time();
|
||||
$tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
|
||||
$branchName = 'update-releases-and-versions-'.$timestamp;
|
||||
$versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
|
||||
|
||||
// 3. Clone the repository
|
||||
$this->info('Cloning coolify-cdn repository...');
|
||||
$output = [];
|
||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Create feature branch
|
||||
$this->info('Creating feature branch...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. Write releases.json
|
||||
$this->info('Writing releases.json...');
|
||||
$releasesPath = "$tmpDir/json/releases.json";
|
||||
$releasesDir = dirname($releasesPath);
|
||||
|
||||
if (! is_dir($releasesDir)) {
|
||||
if (! mkdir($releasesDir, 0755, true)) {
|
||||
$this->error("Failed to create directory: $releasesDir");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
|
||||
$this->error("Failed to write releases.json to: $releasesPath");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 6. Write versions.json
|
||||
$this->info('Writing versions.json...');
|
||||
$versionsPath = "$tmpDir/$versionsTargetPath";
|
||||
$versionsDir = dirname($versionsPath);
|
||||
|
||||
if (! is_dir($versionsDir)) {
|
||||
if (! mkdir($versionsDir, 0755, true)) {
|
||||
$this->error("Failed to create directory: $versionsDir");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
|
||||
$this->error("Failed to write versions.json to: $versionsPath");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 7. Stage both files
|
||||
$this->info('Staging changes...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 8. Check for changes
|
||||
$this->info('Checking for changes...');
|
||||
$statusOutput = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty(array_filter($statusOutput))) {
|
||||
$this->info('Both files are already up to date. No changes to commit.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 9. Commit changes
|
||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||
$commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to commit changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 10. Push to remote
|
||||
$this->info('Pushing branch to remote...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 11. Create pull request
|
||||
$this->info('Creating pull request...');
|
||||
$prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||
$prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
|
||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||
$output = [];
|
||||
exec($prCommand, $output, $returnCode);
|
||||
|
||||
// 12. Clean up
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create PR: '.implode("\n", $output));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->info('Pull request created successfully!');
|
||||
if (! empty($output)) {
|
||||
$this->info('PR URL: '.implode("\n", $output));
|
||||
}
|
||||
$this->info("Version synced: $actualVersion");
|
||||
$this->info('Total releases synced: '.count($releases));
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Error syncing to GitHub: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync versions.json to GitHub repository via PR
|
||||
*/
|
||||
private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
|
||||
{
|
||||
$this->info('Syncing versions.json to GitHub repository...');
|
||||
try {
|
||||
if (! file_exists($versionsLocation)) {
|
||||
$this->error("versions.json not found at: $versionsLocation");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$file = file_get_contents($versionsLocation);
|
||||
$json = json_decode($file, true);
|
||||
$actualVersion = data_get($json, 'coolify.v4.version');
|
||||
|
||||
$timestamp = time();
|
||||
$tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
|
||||
$branchName = 'update-versions-'.$timestamp;
|
||||
$targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
|
||||
|
||||
// Clone the repository
|
||||
$this->info('Cloning coolify-cdn repository...');
|
||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create feature branch
|
||||
$this->info('Creating feature branch...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write versions.json
|
||||
$this->info('Writing versions.json...');
|
||||
$versionsPath = "$tmpDir/$targetPath";
|
||||
$versionsDir = dirname($versionsPath);
|
||||
|
||||
// Ensure directory exists
|
||||
if (! is_dir($versionsDir)) {
|
||||
$this->info("Creating directory: $versionsDir");
|
||||
if (! mkdir($versionsDir, 0755, true)) {
|
||||
$this->error("Failed to create directory: $versionsDir");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
$bytesWritten = file_put_contents($versionsPath, $jsonContent);
|
||||
|
||||
if ($bytesWritten === false) {
|
||||
$this->error("Failed to write versions.json to: $versionsPath");
|
||||
$this->error('Possible reasons: permission denied or disk full.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stage and commit
|
||||
$this->info('Committing changes...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->info('Checking for changes...');
|
||||
$statusOutput = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty(array_filter($statusOutput))) {
|
||||
$this->info('versions.json is already up to date. No changes to commit.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||
$commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to commit changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Push to remote
|
||||
$this->info('Pushing branch to remote...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create pull request
|
||||
$this->info('Creating pull request...');
|
||||
$prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||
$prBody = "Automated update of $envLabel versions.json to version $actualVersion";
|
||||
$output = [];
|
||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||
exec($prCommand, $output, $returnCode);
|
||||
|
||||
// Clean up
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create PR: '.implode("\n", $output));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->info('Pull request created successfully!');
|
||||
if (! empty($output)) {
|
||||
$this->info('PR URL: '.implode("\n", $output));
|
||||
}
|
||||
$this->info("Version synced: $actualVersion");
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Error syncing versions.json: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
|
|
@ -167,6 +522,7 @@ public function handle()
|
|||
$only_template = $this->option('templates');
|
||||
$only_version = $this->option('release');
|
||||
$only_github_releases = $this->option('github-releases');
|
||||
$only_github_versions = $this->option('github-versions');
|
||||
$nightly = $this->option('nightly');
|
||||
$bunny_cdn = 'https://cdn.coollabs.io';
|
||||
$bunny_cdn_path = 'coolify';
|
||||
|
|
@ -224,7 +580,7 @@ public function handle()
|
|||
$install_script_location = "$parent_dir/other/nightly/$install_script";
|
||||
$versions_location = "$parent_dir/other/nightly/$versions";
|
||||
}
|
||||
if (! $only_template && ! $only_version && ! $only_github_releases) {
|
||||
if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
|
||||
if ($nightly) {
|
||||
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
|
||||
} else {
|
||||
|
|
@ -250,25 +606,47 @@ public function handle()
|
|||
return;
|
||||
} elseif ($only_version) {
|
||||
if ($nightly) {
|
||||
$this->info('About to sync NIGHLTY versions.json to BunnyCDN.');
|
||||
$this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.');
|
||||
} else {
|
||||
$this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
|
||||
$this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.');
|
||||
}
|
||||
$file = file_get_contents($versions_location);
|
||||
$json = json_decode($file, true);
|
||||
$actual_version = data_get($json, 'coolify.v4.version');
|
||||
|
||||
$confirmed = confirm("Are you sure you want to sync to {$actual_version}?");
|
||||
$this->info("Version: {$actual_version}");
|
||||
$this->info('This will:');
|
||||
$this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)');
|
||||
$this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
|
||||
$this->newLine();
|
||||
|
||||
$confirmed = confirm('Are you sure you want to proceed?');
|
||||
if (! $confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync versions.json to BunnyCDN
|
||||
// 1. Sync versions.json to BunnyCDN (deprecated but still needed)
|
||||
$this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
|
||||
Http::pool(fn (Pool $pool) => [
|
||||
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
|
||||
]);
|
||||
$this->info('versions.json uploaded & purged...');
|
||||
$this->info('✓ versions.json uploaded & purged to BunnyCDN');
|
||||
$this->newLine();
|
||||
|
||||
// 2. Create GitHub PR with both releases.json and versions.json
|
||||
$this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
|
||||
$githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
|
||||
if ($githubSuccess) {
|
||||
$this->info('✓ GitHub PR created successfully with both files');
|
||||
} else {
|
||||
$this->error('✗ Failed to create GitHub PR');
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
$this->info('=== Summary ===');
|
||||
$this->info('BunnyCDN sync: ✓ Complete');
|
||||
$this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed'));
|
||||
|
||||
return;
|
||||
} elseif ($only_github_releases) {
|
||||
|
|
@ -281,6 +659,22 @@ public function handle()
|
|||
// Sync releases to GitHub repository
|
||||
$this->syncReleasesToGitHubRepo();
|
||||
|
||||
return;
|
||||
} elseif ($only_github_versions) {
|
||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||
$file = file_get_contents($versions_location);
|
||||
$json = json_decode($file, true);
|
||||
$actual_version = data_get($json, 'coolify.v4.version');
|
||||
|
||||
$this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository.");
|
||||
$confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?');
|
||||
if (! $confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync versions.json to GitHub repository
|
||||
$this->syncVersionsToGitHubRepo($versions_location, $nightly);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Jobs\CheckForUpdatesJob;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\CheckTraefikVersionJob;
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Jobs\PullTemplatesFromCDN;
|
||||
|
|
@ -83,6 +84,8 @@ protected function schedule(Schedule $schedule): void
|
|||
|
||||
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
|
||||
|
||||
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();
|
||||
|
||||
$this->scheduleInstance->command('cleanup:database --yes')->daily();
|
||||
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ class ServerMetadata extends Data
|
|||
{
|
||||
public function __construct(
|
||||
public ?ProxyTypes $type,
|
||||
public ?ProxyStatus $status
|
||||
public ?ProxyStatus $status,
|
||||
public ?string $last_saved_settings = null,
|
||||
public ?string $last_applied_settings = null
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,17 +17,23 @@ public function __construct($data)
|
|||
$tmpPath = data_get($data, 'tmpPath');
|
||||
$container = data_get($data, 'container');
|
||||
$serverId = data_get($data, 'serverId');
|
||||
if (filled($scriptPath) && filled($tmpPath) && filled($container) && filled($serverId)) {
|
||||
if (str($tmpPath)->startsWith('/tmp/')
|
||||
&& str($scriptPath)->startsWith('/tmp/')
|
||||
&& ! str($tmpPath)->contains('..')
|
||||
&& ! str($scriptPath)->contains('..')
|
||||
&& strlen($tmpPath) > 5 // longer than just "/tmp/"
|
||||
&& strlen($scriptPath) > 5
|
||||
) {
|
||||
$commands[] = "docker exec {$container} sh -c 'rm {$scriptPath}'";
|
||||
$commands[] = "docker exec {$container} sh -c 'rm {$tmpPath}'";
|
||||
instant_remote_process($commands, Server::find($serverId), throwError: true);
|
||||
|
||||
if (filled($container) && filled($serverId)) {
|
||||
$commands = [];
|
||||
|
||||
if (isSafeTmpPath($scriptPath)) {
|
||||
$commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($scriptPath)." 2>/dev/null || true'";
|
||||
}
|
||||
|
||||
if (isSafeTmpPath($tmpPath)) {
|
||||
$commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($tmpPath)." 2>/dev/null || true'";
|
||||
}
|
||||
|
||||
if (! empty($commands)) {
|
||||
$server = Server::find($serverId);
|
||||
if ($server) {
|
||||
instant_remote_process($commands, $server, throwError: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
app/Events/S3RestoreJobFinished.php
Normal file
56
app/Events/S3RestoreJobFinished.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class S3RestoreJobFinished
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct($data)
|
||||
{
|
||||
$containerName = data_get($data, 'containerName');
|
||||
$serverTmpPath = data_get($data, 'serverTmpPath');
|
||||
$scriptPath = data_get($data, 'scriptPath');
|
||||
$containerTmpPath = data_get($data, 'containerTmpPath');
|
||||
$container = data_get($data, 'container');
|
||||
$serverId = data_get($data, 'serverId');
|
||||
|
||||
// Most cleanup now happens inline during restore process
|
||||
// This acts as a safety net for edge cases (errors, interruptions)
|
||||
if (filled($serverId)) {
|
||||
$commands = [];
|
||||
|
||||
// Ensure helper container is removed (may already be gone from inline cleanup)
|
||||
if (filled($containerName)) {
|
||||
$commands[] = 'docker rm -f '.escapeshellarg($containerName).' 2>/dev/null || true';
|
||||
}
|
||||
|
||||
// Clean up server temp file if still exists (should already be cleaned)
|
||||
if (isSafeTmpPath($serverTmpPath)) {
|
||||
$commands[] = 'rm -f '.escapeshellarg($serverTmpPath).' 2>/dev/null || true';
|
||||
}
|
||||
|
||||
// Clean up any remaining files in database container (may already be cleaned)
|
||||
if (filled($container)) {
|
||||
if (isSafeTmpPath($containerTmpPath)) {
|
||||
$commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($containerTmpPath).' 2>/dev/null || true';
|
||||
}
|
||||
if (isSafeTmpPath($scriptPath)) {
|
||||
$commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($scriptPath).' 2>/dev/null || true';
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($commands)) {
|
||||
$server = Server::find($serverId);
|
||||
if ($server) {
|
||||
instant_remote_process($commands, $server, throwError: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/Exceptions/DeploymentException.php
Normal file
32
app/Exceptions/DeploymentException.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Exception for expected deployment failures caused by user/application errors.
|
||||
* These are not Coolify bugs and should not be logged to laravel.log.
|
||||
* Examples: Nixpacks detection failures, missing Dockerfiles, invalid configs, etc.
|
||||
*/
|
||||
class DeploymentException extends Exception
|
||||
{
|
||||
/**
|
||||
* Create a new deployment exception instance.
|
||||
*
|
||||
* @param string $message
|
||||
* @param int $code
|
||||
*/
|
||||
public function __construct($message = '', $code = 0, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from another exception, preserving its message and stack trace.
|
||||
*/
|
||||
public static function fromException(\Throwable $exception): static
|
||||
{
|
||||
return new static($exception->getMessage(), $exception->getCode(), $exception);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ class Handler extends ExceptionHandler
|
|||
protected $dontReport = [
|
||||
ProcessException::class,
|
||||
NonReportableException::class,
|
||||
DeploymentException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -351,7 +351,7 @@ public function create_service(Request $request)
|
|||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
];
|
||||
if ($oneClickServiceName === 'cloudflared') {
|
||||
if ($oneClickServiceName === 'pgadmin') {
|
||||
data_set($servicePayload, 'connect_to_docker_network', true);
|
||||
}
|
||||
$service = Service::create($servicePayload);
|
||||
|
|
|
|||
|
|
@ -246,6 +246,40 @@ public function manual(Request $request)
|
|||
if ($action === 'closed') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
// Cancel any active deployments for this PR immediately
|
||||
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->whereIn('status', [
|
||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($activeDeployment) {
|
||||
try {
|
||||
// Mark deployment as cancelled
|
||||
$activeDeployment->update([
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Add cancellation log entry
|
||||
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||
|
||||
// Check if helper container exists and kill it
|
||||
$deployment_uuid = $activeDeployment->deployment_uuid;
|
||||
$server = $application->destination->server;
|
||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
|
||||
$activeDeployment->addLogEntry('Deployment container stopped.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Silently handle errors during deployment cancellation
|
||||
}
|
||||
}
|
||||
|
||||
DeleteResourceJob::dispatch($found);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
|
|
@ -481,6 +515,42 @@ public function normal(Request $request)
|
|||
if ($action === 'closed' || $action === 'close') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
// Cancel any active deployments for this PR immediately
|
||||
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->whereIn('status', [
|
||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($activeDeployment) {
|
||||
try {
|
||||
// Mark deployment as cancelled
|
||||
$activeDeployment->update([
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Add cancellation log entry
|
||||
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||
|
||||
// Check if helper container exists and kill it
|
||||
$deployment_uuid = $activeDeployment->deployment_uuid;
|
||||
$server = $application->destination->server;
|
||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
|
||||
$activeDeployment->addLogEntry('Deployment container stopped.');
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// Silently handle errors during deployment cancellation
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any deployed containers
|
||||
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
|
||||
if ($containers->isNotEmpty()) {
|
||||
$containers->each(function ($container) use ($application) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -33,6 +33,9 @@ public function handle(): void
|
|||
// New version available
|
||||
$settings->update(['new_version_available' => true]);
|
||||
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
|
||||
|
||||
// Invalidate cache to ensure fresh data is loaded
|
||||
invalidate_versions_cache();
|
||||
} else {
|
||||
$settings->update(['new_version_available' => false]);
|
||||
}
|
||||
|
|
|
|||
173
app/Jobs/CheckTraefikVersionForServerJob.php
Normal file
173
app/Jobs/CheckTraefikVersionForServerJob.php
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Notifications\Server\TraefikVersionOutdated;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CheckTraefikVersionForServerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 3;
|
||||
|
||||
public $timeout = 60;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public Server $server,
|
||||
public array $traefikVersions
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
// Detect current version (makes SSH call)
|
||||
$currentVersion = getTraefikVersionFromDockerCompose($this->server);
|
||||
|
||||
// Update detected version in database
|
||||
$this->server->update(['detected_traefik_version' => $currentVersion]);
|
||||
|
||||
if (! $currentVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if image tag is 'latest' by inspecting the image (makes SSH call)
|
||||
$imageTag = instant_remote_process([
|
||||
"docker inspect coolify-proxy --format '{{.Config.Image}}' 2>/dev/null",
|
||||
], $this->server, false);
|
||||
|
||||
// Handle empty/null response from SSH command
|
||||
if (empty(trim($imageTag))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_contains(strtolower(trim($imageTag)), ':latest')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse current version to extract major.minor.patch
|
||||
$current = ltrim($currentVersion, 'v');
|
||||
if (! preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentBranch = $matches[1]; // e.g., "3.6"
|
||||
|
||||
// Find the latest version for this branch
|
||||
$latestForBranch = $this->traefikVersions["v{$currentBranch}"] ?? null;
|
||||
|
||||
if (! $latestForBranch) {
|
||||
// User is on a branch we don't track - check if newer branches exist
|
||||
$newerBranchInfo = $this->getNewerBranchInfo($currentBranch);
|
||||
|
||||
if ($newerBranchInfo) {
|
||||
$this->storeOutdatedInfo($current, $newerBranchInfo['latest'], 'minor_upgrade', $newerBranchInfo['target']);
|
||||
} else {
|
||||
// No newer branch found, clear outdated info
|
||||
$this->server->update(['traefik_outdated_info' => null]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare patch version within the same branch
|
||||
$latest = ltrim($latestForBranch, 'v');
|
||||
|
||||
// Always check for newer branches first
|
||||
$newerBranchInfo = $this->getNewerBranchInfo($currentBranch);
|
||||
|
||||
if (version_compare($current, $latest, '<')) {
|
||||
// Patch update available
|
||||
$this->storeOutdatedInfo($current, $latest, 'patch_update', null, $newerBranchInfo);
|
||||
} elseif ($newerBranchInfo) {
|
||||
// Only newer branch available (no patch update)
|
||||
$this->storeOutdatedInfo($current, $newerBranchInfo['latest'], 'minor_upgrade', $newerBranchInfo['target']);
|
||||
} else {
|
||||
// Fully up to date
|
||||
$this->server->update(['traefik_outdated_info' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about newer branches if available.
|
||||
*/
|
||||
private function getNewerBranchInfo(string $currentBranch): ?array
|
||||
{
|
||||
$newestBranch = null;
|
||||
$newestVersion = null;
|
||||
|
||||
foreach ($this->traefikVersions as $branch => $version) {
|
||||
$branchNum = ltrim($branch, 'v');
|
||||
if (version_compare($branchNum, $currentBranch, '>')) {
|
||||
if (! $newestVersion || version_compare($version, $newestVersion, '>')) {
|
||||
$newestBranch = $branchNum;
|
||||
$newestVersion = $version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($newestVersion) {
|
||||
return [
|
||||
'target' => "v{$newestBranch}",
|
||||
'latest' => ltrim($newestVersion, 'v'),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store outdated information in database and send immediate notification.
|
||||
*/
|
||||
private function storeOutdatedInfo(string $current, string $latest, string $type, ?string $upgradeTarget = null, ?array $newerBranchInfo = null): void
|
||||
{
|
||||
$outdatedInfo = [
|
||||
'current' => $current,
|
||||
'latest' => $latest,
|
||||
'type' => $type,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
// For minor upgrades, add the upgrade_target field (e.g., "v3.6")
|
||||
if ($type === 'minor_upgrade' && $upgradeTarget) {
|
||||
$outdatedInfo['upgrade_target'] = $upgradeTarget;
|
||||
}
|
||||
|
||||
// If there's a newer branch available (even for patch updates), include that info
|
||||
if ($newerBranchInfo) {
|
||||
$outdatedInfo['newer_branch_target'] = $newerBranchInfo['target'];
|
||||
$outdatedInfo['newer_branch_latest'] = $newerBranchInfo['latest'];
|
||||
}
|
||||
|
||||
$this->server->update(['traefik_outdated_info' => $outdatedInfo]);
|
||||
|
||||
// Send immediate notification to the team
|
||||
$this->sendNotification($outdatedInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to team about outdated Traefik.
|
||||
*/
|
||||
private function sendNotification(array $outdatedInfo): void
|
||||
{
|
||||
// Attach the outdated info as a dynamic property for the notification
|
||||
$this->server->outdatedInfo = $outdatedInfo;
|
||||
|
||||
// Get the team and send notification
|
||||
$team = $this->server->team()->first();
|
||||
|
||||
if ($team) {
|
||||
$team->notify(new TraefikVersionOutdated(collect([$this->server])));
|
||||
}
|
||||
}
|
||||
}
|
||||
45
app/Jobs/CheckTraefikVersionJob.php
Normal file
45
app/Jobs/CheckTraefikVersionJob.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CheckTraefikVersionJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 3;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// Load versions from cached data
|
||||
$traefikVersions = get_traefik_versions();
|
||||
|
||||
if (empty($traefikVersions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Query all servers with Traefik proxy that are reachable
|
||||
$servers = Server::whereNotNull('proxy')
|
||||
->whereProxyType(ProxyTypes::TRAEFIK->value)
|
||||
->whereRelation('settings', 'is_reachable', true)
|
||||
->whereRelation('settings', 'is_usable', true)
|
||||
->get();
|
||||
|
||||
if ($servers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch individual server check jobs in parallel
|
||||
// Each job will send immediate notifications when outdated Traefik is detected
|
||||
foreach ($servers as $server) {
|
||||
CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
|
|
@ -20,10 +22,51 @@ public function __construct(public Server $server) {}
|
|||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
// Get all active deployments on this server
|
||||
$activeDeployments = ApplicationDeploymentQueue::where('server_id', $this->server->id)
|
||||
->whereIn('status', [
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
])
|
||||
->pluck('deployment_uuid')
|
||||
->toArray();
|
||||
|
||||
\Log::info('CleanupHelperContainersJob - Active deployments', [
|
||||
'server' => $this->server->name,
|
||||
'active_deployment_uuids' => $activeDeployments,
|
||||
]);
|
||||
|
||||
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
|
||||
$containerIds = collect(json_decode($containers))->pluck('ID');
|
||||
if ($containerIds->count() > 0) {
|
||||
foreach ($containerIds as $containerId) {
|
||||
$helperContainers = collect(json_decode($containers));
|
||||
|
||||
if ($helperContainers->count() > 0) {
|
||||
foreach ($helperContainers as $container) {
|
||||
$containerId = data_get($container, 'ID');
|
||||
$containerName = data_get($container, 'Names');
|
||||
|
||||
// Check if this container belongs to an active deployment
|
||||
$isActiveDeployment = false;
|
||||
foreach ($activeDeployments as $deploymentUuid) {
|
||||
if (str_contains($containerName, $deploymentUuid)) {
|
||||
$isActiveDeployment = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isActiveDeployment) {
|
||||
\Log::info('CleanupHelperContainersJob - Skipping active deployment container', [
|
||||
'container' => $containerName,
|
||||
'id' => $containerId,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
\Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [
|
||||
'container' => $containerName,
|
||||
'id' => $containerId,
|
||||
]);
|
||||
|
||||
instant_remote_process_with_timeout(['docker container rm -f '.$containerId], $this->server, false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,35 @@
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\CoolifyTask\RunRemoteProcess;
|
||||
use App\Enums\ProcessStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public $tries = 3;
|
||||
|
||||
/**
|
||||
* The maximum number of unhandled exceptions to allow before failing.
|
||||
*/
|
||||
public $maxExceptions = 1;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
*/
|
||||
public $timeout = 600;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
|
|
@ -42,4 +59,53 @@ public function handle(): void
|
|||
|
||||
$remote_process();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public function backoff(): array
|
||||
{
|
||||
return [30, 90, 180]; // 30s, 90s, 180s between retries
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
Log::channel('scheduled-errors')->error('CoolifyTask permanently failed', [
|
||||
'job' => 'CoolifyTask',
|
||||
'activity_id' => $this->activity->id,
|
||||
'server_uuid' => $this->activity->getExtraProperty('server_uuid'),
|
||||
'command_preview' => substr($this->activity->getExtraProperty('command') ?? '', 0, 200),
|
||||
'error' => $exception?->getMessage(),
|
||||
'total_attempts' => $this->attempts(),
|
||||
'trace' => $exception?->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// Update activity status to reflect permanent failure
|
||||
$this->activity->properties = $this->activity->properties->merge([
|
||||
'status' => ProcessStatus::ERROR->value,
|
||||
'error' => $exception?->getMessage() ?? 'Job permanently failed',
|
||||
'failed_at' => now()->toIso8601String(),
|
||||
]);
|
||||
$this->activity->save();
|
||||
|
||||
// Dispatch cleanup event on failure (same as on success)
|
||||
if ($this->call_event_on_finish) {
|
||||
try {
|
||||
$eventClass = "App\\Events\\$this->call_event_on_finish";
|
||||
if (! is_null($this->call_event_data)) {
|
||||
event(new $eventClass($this->call_event_data));
|
||||
} else {
|
||||
event(new $eventClass($this->activity->causer_id));
|
||||
}
|
||||
Log::info('Cleanup event dispatched after job failure', [
|
||||
'event' => $this->call_event_on_finish,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Error dispatching cleanup event on failure: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
|
@ -31,6 +32,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $maxExceptions = 1;
|
||||
|
||||
public ?Team $team = null;
|
||||
|
||||
public Server $server;
|
||||
|
|
@ -74,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
public function __construct(public ScheduledDatabaseBackup $backup)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
$this->timeout = $backup->timeout;
|
||||
$this->timeout = $backup->timeout ?? 3600;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
|
|
@ -486,17 +489,22 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
|
|||
$collectionsToExclude = collect();
|
||||
}
|
||||
$commands[] = 'mkdir -p '.$this->backup_dir;
|
||||
|
||||
// Validate and escape database name to prevent command injection
|
||||
validateShellSafePath($databaseName, 'database name');
|
||||
$escapedDatabaseName = escapeshellarg($databaseName);
|
||||
|
||||
if ($collectionsToExclude->count() === 0) {
|
||||
if (str($this->database->image)->startsWith('mongo:4')) {
|
||||
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
|
||||
} else {
|
||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --archive > $this->backup_location";
|
||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
|
||||
}
|
||||
} else {
|
||||
if (str($this->database->image)->startsWith('mongo:4')) {
|
||||
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
||||
} else {
|
||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -522,7 +530,10 @@ private function backup_standalone_postgresql(string $database): void
|
|||
if ($this->backup->dump_all) {
|
||||
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
|
||||
} else {
|
||||
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $database > $this->backup_location";
|
||||
// Validate and escape database name to prevent command injection
|
||||
validateShellSafePath($database, 'database name');
|
||||
$escapedDatabase = escapeshellarg($database);
|
||||
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location";
|
||||
}
|
||||
|
||||
$commands[] = $backupCommand;
|
||||
|
|
@ -544,7 +555,10 @@ private function backup_standalone_mysql(string $database): void
|
|||
if ($this->backup->dump_all) {
|
||||
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
|
||||
} else {
|
||||
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $database > $this->backup_location";
|
||||
// Validate and escape database name to prevent command injection
|
||||
validateShellSafePath($database, 'database name');
|
||||
$escapedDatabase = escapeshellarg($database);
|
||||
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location";
|
||||
}
|
||||
$this->backup_output = instant_remote_process($commands, $this->server);
|
||||
$this->backup_output = trim($this->backup_output);
|
||||
|
|
@ -564,7 +578,10 @@ private function backup_standalone_mariadb(string $database): void
|
|||
if ($this->backup->dump_all) {
|
||||
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
|
||||
} else {
|
||||
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $database > $this->backup_location";
|
||||
// Validate and escape database name to prevent command injection
|
||||
validateShellSafePath($database, 'database name');
|
||||
$escapedDatabase = escapeshellarg($database);
|
||||
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location";
|
||||
}
|
||||
$this->backup_output = instant_remote_process($commands, $this->server);
|
||||
$this->backup_output = trim($this->backup_output);
|
||||
|
|
@ -636,7 +653,13 @@ private function upload_to_s3(): void
|
|||
} else {
|
||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
|
||||
}
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
|
||||
|
||||
// Escape S3 credentials to prevent command injection
|
||||
$escapedEndpoint = escapeshellarg($endpoint);
|
||||
$escapedKey = escapeshellarg($key);
|
||||
$escapedSecret = escapeshellarg($secret);
|
||||
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
||||
instant_remote_process($commands, $this->server);
|
||||
|
||||
|
|
@ -661,15 +684,34 @@ private function getFullImageName(): string
|
|||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [
|
||||
'job' => 'DatabaseBackupJob',
|
||||
'backup_id' => $this->backup->uuid,
|
||||
'database' => $this->database?->name ?? 'unknown',
|
||||
'database_type' => get_class($this->database ?? new \stdClass),
|
||||
'server' => $this->server?->name ?? 'unknown',
|
||||
'total_attempts' => $this->attempts(),
|
||||
'error' => $exception?->getMessage(),
|
||||
'trace' => $exception?->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
|
||||
|
||||
if ($log) {
|
||||
$log->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Job failed: '.($exception?->getMessage() ?? 'Unknown error'),
|
||||
'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
|
||||
'size' => 0,
|
||||
'filename' => null,
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Notify team about permanent failure
|
||||
if ($this->team) {
|
||||
$databaseName = $log?->database_name ?? 'unknown';
|
||||
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
|
||||
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,16 +124,54 @@ private function deleteApplicationPreview()
|
|||
$this->resource->delete();
|
||||
}
|
||||
|
||||
// Cancel any active deployments for this PR (same logic as API cancel_deployment)
|
||||
$activeDeployments = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->whereIn('status', [
|
||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->get();
|
||||
|
||||
foreach ($activeDeployments as $activeDeployment) {
|
||||
try {
|
||||
// Mark deployment as cancelled
|
||||
$activeDeployment->update([
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Add cancellation log entry
|
||||
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||
|
||||
// Check if helper container exists and kill it
|
||||
$deployment_uuid = $activeDeployment->deployment_uuid;
|
||||
$escapedDeploymentUuid = escapeshellarg($deployment_uuid);
|
||||
$checkCommand = "docker ps -a --filter name={$escapedDeploymentUuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process(["docker rm -f {$escapedDeploymentUuid}"], $server);
|
||||
$activeDeployment->addLogEntry('Deployment container stopped.');
|
||||
} else {
|
||||
$activeDeployment->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// Silently handle errors during deployment cancellation
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if ($server->isSwarm()) {
|
||||
instant_remote_process(["docker stack rm {$application->uuid}-{$pull_request_id}"], $server);
|
||||
$escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
|
||||
instant_remote_process(["docker stack rm {$escapedStackName}"], $server);
|
||||
} else {
|
||||
$containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray();
|
||||
$this->stopPreviewContainers($containers, $server);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Log the error but don't fail the job
|
||||
ray('Error stopping preview containers: '.$e->getMessage());
|
||||
\Log::warning('Error stopping preview containers for application '.$application->uuid.', PR #'.$pull_request_id.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
// Finally, force delete to trigger resource cleanup
|
||||
|
|
@ -156,7 +194,6 @@ private function stopPreviewContainers(array $containers, $server, int $timeout
|
|||
"docker stop -t $timeout $containerList",
|
||||
"docker rm -f $containerList",
|
||||
];
|
||||
|
||||
instant_remote_process(
|
||||
command: $commands,
|
||||
server: $server,
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 1000;
|
||||
|
||||
public function __construct(public Server $server)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$latest_version = getHelperVersion();
|
||||
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Notifications\Container\ContainerRestarted;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Traits\CalculatesExcludedStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
|
@ -25,6 +27,7 @@
|
|||
|
||||
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
{
|
||||
use CalculatesExcludedStatus;
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 1;
|
||||
|
|
@ -67,6 +70,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
|
||||
public Collection $applicationContainerStatuses;
|
||||
|
||||
public Collection $serviceContainerStatuses;
|
||||
|
||||
public bool $foundProxy = false;
|
||||
|
||||
public bool $foundLogDrainContainer = false;
|
||||
|
|
@ -90,6 +95,7 @@ public function __construct(public Server $server, public $data)
|
|||
$this->foundApplicationPreviewsIds = collect();
|
||||
$this->foundServiceDatabaseIds = collect();
|
||||
$this->applicationContainerStatuses = collect();
|
||||
$this->serviceContainerStatuses = collect();
|
||||
$this->allApplicationIds = collect();
|
||||
$this->allDatabaseUuids = collect();
|
||||
$this->allTcpProxyUuids = collect();
|
||||
|
|
@ -99,6 +105,20 @@ public function __construct(public Server $server, public $data)
|
|||
|
||||
public function handle()
|
||||
{
|
||||
// Defensive initialization for Collection properties to handle queue deserialization edge cases
|
||||
$this->serviceContainerStatuses ??= collect();
|
||||
$this->applicationContainerStatuses ??= collect();
|
||||
$this->foundApplicationIds ??= collect();
|
||||
$this->foundDatabaseUuids ??= collect();
|
||||
$this->foundServiceApplicationIds ??= collect();
|
||||
$this->foundApplicationPreviewsIds ??= collect();
|
||||
$this->foundServiceDatabaseIds ??= collect();
|
||||
$this->allApplicationIds ??= collect();
|
||||
$this->allDatabaseUuids ??= collect();
|
||||
$this->allTcpProxyUuids ??= collect();
|
||||
$this->allServiceApplicationIds ??= collect();
|
||||
$this->allServiceDatabaseIds ??= collect();
|
||||
|
||||
// TODO: Swarm is not supported yet
|
||||
if (! $this->data) {
|
||||
throw new \Exception('No data provided');
|
||||
|
|
@ -108,7 +128,6 @@ public function handle()
|
|||
$this->server->sentinelHeartbeat();
|
||||
|
||||
$this->containers = collect(data_get($data, 'containers'));
|
||||
|
||||
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
|
||||
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
|
||||
|
||||
|
|
@ -141,65 +160,88 @@ public function handle()
|
|||
|
||||
foreach ($this->containers as $container) {
|
||||
$containerStatus = data_get($container, 'state', 'exited');
|
||||
$containerHealth = data_get($container, 'health_status', 'unhealthy');
|
||||
$containerStatus = "$containerStatus ($containerHealth)";
|
||||
$rawHealthStatus = data_get($container, 'health_status');
|
||||
$containerHealth = $rawHealthStatus ?? 'unknown';
|
||||
// Only append health status if container is not exited
|
||||
if ($containerStatus !== 'exited') {
|
||||
$containerStatus = "$containerStatus:$containerHealth";
|
||||
}
|
||||
$labels = collect(data_get($container, 'labels'));
|
||||
$coolify_managed = $labels->has('coolify.managed');
|
||||
if ($coolify_managed) {
|
||||
$name = data_get($container, 'name');
|
||||
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
|
||||
$this->foundLogDrainContainer = true;
|
||||
}
|
||||
if ($labels->has('coolify.applicationId')) {
|
||||
$applicationId = $labels->get('coolify.applicationId');
|
||||
$pullRequestId = $labels->get('coolify.pullRequestId', '0');
|
||||
try {
|
||||
if ($pullRequestId === '0') {
|
||||
if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) {
|
||||
$this->foundApplicationIds->push($applicationId);
|
||||
}
|
||||
// Store container status for aggregation
|
||||
if (! $this->applicationContainerStatuses->has($applicationId)) {
|
||||
$this->applicationContainerStatuses->put($applicationId, collect());
|
||||
}
|
||||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||
}
|
||||
} else {
|
||||
$previewKey = $applicationId.':'.$pullRequestId;
|
||||
if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) {
|
||||
$this->foundApplicationPreviewsIds->push($previewKey);
|
||||
}
|
||||
$this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
|
||||
|
||||
if (! $coolify_managed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = data_get($container, 'name');
|
||||
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
|
||||
$this->foundLogDrainContainer = true;
|
||||
}
|
||||
if ($labels->has('coolify.applicationId')) {
|
||||
$applicationId = $labels->get('coolify.applicationId');
|
||||
$pullRequestId = $labels->get('coolify.pullRequestId', '0');
|
||||
try {
|
||||
if ($pullRequestId === '0') {
|
||||
if ($this->allApplicationIds->contains($applicationId)) {
|
||||
$this->foundApplicationIds->push($applicationId);
|
||||
}
|
||||
// Store container status for aggregation
|
||||
if (! $this->applicationContainerStatuses->has($applicationId)) {
|
||||
$this->applicationContainerStatuses->put($applicationId, collect());
|
||||
}
|
||||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
} elseif ($labels->has('coolify.serviceId')) {
|
||||
$serviceId = $labels->get('coolify.serviceId');
|
||||
$subType = $labels->get('coolify.service.subType');
|
||||
$subId = $labels->get('coolify.service.subId');
|
||||
if ($subType === 'application' && $this->isRunning($containerStatus)) {
|
||||
$this->foundServiceApplicationIds->push($subId);
|
||||
$this->updateServiceSubStatus($serviceId, $subType, $subId, $containerStatus);
|
||||
} elseif ($subType === 'database' && $this->isRunning($containerStatus)) {
|
||||
$this->foundServiceDatabaseIds->push($subId);
|
||||
$this->updateServiceSubStatus($serviceId, $subType, $subId, $containerStatus);
|
||||
}
|
||||
} else {
|
||||
$uuid = $labels->get('com.docker.compose.service');
|
||||
$type = $labels->get('coolify.type');
|
||||
if ($name === 'coolify-proxy' && $this->isRunning($containerStatus)) {
|
||||
$this->foundProxy = true;
|
||||
} elseif ($type === 'service' && $this->isRunning($containerStatus)) {
|
||||
} else {
|
||||
if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
||||
$this->foundDatabaseUuids->push($uuid);
|
||||
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
|
||||
} else {
|
||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false);
|
||||
}
|
||||
$previewKey = $applicationId.':'.$pullRequestId;
|
||||
if ($this->allApplicationPreviewsIds->contains($previewKey)) {
|
||||
$this->foundApplicationPreviewsIds->push($previewKey);
|
||||
}
|
||||
$this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
} elseif ($labels->has('coolify.serviceId')) {
|
||||
$serviceId = $labels->get('coolify.serviceId');
|
||||
$subType = $labels->get('coolify.service.subType');
|
||||
$subId = $labels->get('coolify.service.subId');
|
||||
if ($subType === 'application') {
|
||||
$this->foundServiceApplicationIds->push($subId);
|
||||
// Store container status for aggregation
|
||||
$key = $serviceId.':'.$subType.':'.$subId;
|
||||
if (! $this->serviceContainerStatuses->has($key)) {
|
||||
$this->serviceContainerStatuses->put($key, collect());
|
||||
}
|
||||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
|
||||
}
|
||||
} elseif ($subType === 'database') {
|
||||
$this->foundServiceDatabaseIds->push($subId);
|
||||
// Store container status for aggregation
|
||||
$key = $serviceId.':'.$subType.':'.$subId;
|
||||
if (! $this->serviceContainerStatuses->has($key)) {
|
||||
$this->serviceContainerStatuses->put($key, collect());
|
||||
}
|
||||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$uuid = $labels->get('com.docker.compose.service');
|
||||
$type = $labels->get('coolify.type');
|
||||
if ($name === 'coolify-proxy' && $this->isRunning($containerStatus)) {
|
||||
$this->foundProxy = true;
|
||||
} elseif ($type === 'service' && $this->isRunning($containerStatus)) {
|
||||
} else {
|
||||
if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
||||
$this->foundDatabaseUuids->push($uuid);
|
||||
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
|
||||
} else {
|
||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -218,6 +260,9 @@ public function handle()
|
|||
// Aggregate multi-container application statuses
|
||||
$this->aggregateMultiContainerStatuses();
|
||||
|
||||
// Aggregate multi-container service statuses
|
||||
$this->aggregateServiceContainerStatuses();
|
||||
|
||||
$this->checkLogDrainContainer();
|
||||
}
|
||||
|
||||
|
|
@ -235,57 +280,28 @@ private function aggregateMultiContainerStatuses()
|
|||
|
||||
// Parse docker compose to check for excluded containers
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = collect();
|
||||
|
||||
if ($dockerComposeRaw) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
// Check if container should be excluded
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse, treat all containers as included
|
||||
}
|
||||
}
|
||||
$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, don't update status
|
||||
// If all containers are excluded, calculate status from excluded containers
|
||||
if ($relevantStatuses->isEmpty()) {
|
||||
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
|
||||
|
||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
$application->status = $aggregatedStatus;
|
||||
$application->save();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Aggregate status: if any container is running, app is running
|
||||
$hasRunning = false;
|
||||
$hasUnhealthy = false;
|
||||
|
||||
foreach ($relevantStatuses as $status) {
|
||||
if (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$aggregatedStatus = null;
|
||||
if ($hasRunning) {
|
||||
$aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
} else {
|
||||
// All containers are exited
|
||||
$aggregatedStatus = 'exited (unhealthy)';
|
||||
}
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
|
||||
|
||||
// Update application status with aggregated result
|
||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
|
|
@ -295,6 +311,66 @@ private function aggregateMultiContainerStatuses()
|
|||
}
|
||||
}
|
||||
|
||||
private function aggregateServiceContainerStatuses()
|
||||
{
|
||||
if ($this->serviceContainerStatuses->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->serviceContainerStatuses as $key => $containerStatuses) {
|
||||
// Parse key: serviceId:subType:subId
|
||||
[$serviceId, $subType, $subId] = explode(':', $key);
|
||||
|
||||
$service = $this->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 && $subResource->status !== $aggregatedStatus) {
|
||||
$subResource->status = $aggregatedStatus;
|
||||
$subResource->save();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
// NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
|
||||
|
||||
// Update service sub-resource status with aggregated result
|
||||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||
$subResource->status = $aggregatedStatus;
|
||||
$subResource->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function updateApplicationStatus(string $applicationId, string $containerStatus)
|
||||
{
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
|
|
|
|||
|
|
@ -31,12 +31,12 @@ public function __construct(public Server $server) {}
|
|||
public function handle()
|
||||
{
|
||||
try {
|
||||
StopProxy::run($this->server);
|
||||
StopProxy::run($this->server, restarting: true);
|
||||
|
||||
$this->server->proxy->force_stop = false;
|
||||
$this->server->save();
|
||||
|
||||
StartProxy::run($this->server, force: true);
|
||||
StartProxy::run($this->server, force: true, restarting: true);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ public function middleware(): array
|
|||
{
|
||||
return [
|
||||
(new WithoutOverlapping('scheduled-job-manager'))
|
||||
->expireAfter(60) // Lock expires after 1 minute to prevent stale locks
|
||||
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
|
||||
->dontRelease(), // Don't re-queue on lock conflict
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,14 +18,30 @@
|
|||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ScheduledTaskJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public $tries = 3;
|
||||
|
||||
/**
|
||||
* The maximum number of unhandled exceptions to allow before failing.
|
||||
*/
|
||||
public $maxExceptions = 1;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
*/
|
||||
public $timeout = 300;
|
||||
|
||||
public Team $team;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public ScheduledTask $task;
|
||||
|
||||
|
|
@ -33,6 +49,11 @@ class ScheduledTaskJob implements ShouldQueue
|
|||
|
||||
public ?ScheduledTaskExecution $task_log = null;
|
||||
|
||||
/**
|
||||
* Store execution ID to survive job serialization for timeout handling.
|
||||
*/
|
||||
protected ?int $executionId = null;
|
||||
|
||||
public string $task_status = 'failed';
|
||||
|
||||
public ?string $task_output = null;
|
||||
|
|
@ -55,6 +76,9 @@ public function __construct($task)
|
|||
}
|
||||
$this->team = Team::findOrFail($task->team_id);
|
||||
$this->server_timezone = $this->getServerTimezone();
|
||||
|
||||
// Set timeout from task configuration
|
||||
$this->timeout = $this->task->timeout ?? 300;
|
||||
}
|
||||
|
||||
private function getServerTimezone(): string
|
||||
|
|
@ -70,11 +94,18 @@ private function getServerTimezone(): string
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
$startTime = Carbon::now();
|
||||
|
||||
try {
|
||||
$this->task_log = ScheduledTaskExecution::create([
|
||||
'scheduled_task_id' => $this->task->id,
|
||||
'started_at' => $startTime,
|
||||
'retry_count' => $this->attempts() - 1,
|
||||
]);
|
||||
|
||||
// Store execution ID for timeout handling
|
||||
$this->executionId = $this->task_log->id;
|
||||
|
||||
$this->server = $this->resource->destination->server;
|
||||
|
||||
if ($this->resource->type() === 'application') {
|
||||
|
|
@ -129,15 +160,101 @@ public function handle(): void
|
|||
'message' => $this->task_output ?? $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
$this->team?->notify(new TaskFailed($this->task, $e->getMessage()));
|
||||
|
||||
// Log the error to the scheduled-errors channel
|
||||
Log::channel('scheduled-errors')->error('ScheduledTask execution failed', [
|
||||
'job' => 'ScheduledTaskJob',
|
||||
'task_id' => $this->task->uuid,
|
||||
'task_name' => $this->task->name,
|
||||
'server' => $this->server?->name ?? 'unknown',
|
||||
'attempt' => $this->attempts(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// Only notify and throw on final failure
|
||||
|
||||
// Re-throw to trigger Laravel's retry mechanism with backoff
|
||||
throw $e;
|
||||
} finally {
|
||||
ScheduledTaskDone::dispatch($this->team->id);
|
||||
if ($this->task_log) {
|
||||
$finishedAt = Carbon::now();
|
||||
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
|
||||
|
||||
$this->task_log->update([
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
'finished_at' => $finishedAt->toImmutable(),
|
||||
'duration' => $duration,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public function backoff(): array
|
||||
{
|
||||
return [30, 60, 120]; // 30s, 60s, 120s between retries
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
|
||||
'job' => 'ScheduledTaskJob',
|
||||
'task_id' => $this->task->uuid,
|
||||
'task_name' => $this->task->name,
|
||||
'server' => $this->server?->name ?? 'unknown',
|
||||
'total_attempts' => $this->attempts(),
|
||||
'error' => $exception?->getMessage(),
|
||||
'trace' => $exception?->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// Reload execution log from database
|
||||
// When a job times out, failed() is called in a fresh process with the original
|
||||
// queue payload, so $executionId will be null. We need to query for the latest execution.
|
||||
$execution = null;
|
||||
|
||||
// Try to find execution using stored ID first (works for non-timeout failures)
|
||||
if ($this->executionId) {
|
||||
$execution = ScheduledTaskExecution::find($this->executionId);
|
||||
}
|
||||
|
||||
// If no stored ID or not found, query for the most recent execution log for this task
|
||||
if (! $execution) {
|
||||
$execution = ScheduledTaskExecution::query()
|
||||
->where('scheduled_task_id', $this->task->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
}
|
||||
|
||||
// Last resort: check task_log property
|
||||
if (! $execution && $this->task_log) {
|
||||
$execution = $this->task_log;
|
||||
}
|
||||
|
||||
if ($execution) {
|
||||
$errorMessage = 'Job permanently failed after '.$this->attempts().' attempts';
|
||||
if ($exception) {
|
||||
$errorMessage .= ': '.$exception->getMessage();
|
||||
}
|
||||
|
||||
$execution->update([
|
||||
'status' => 'failed',
|
||||
'message' => $errorMessage,
|
||||
'error_details' => $exception?->getTraceAsString(),
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
]);
|
||||
} else {
|
||||
Log::channel('scheduled-errors')->warning('Could not find execution log to update', [
|
||||
'execution_id' => $this->executionId,
|
||||
'task_id' => $this->task->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
// Notify team about permanent failure
|
||||
$this->team?->notify(new TaskFailed($this->task, $exception?->getMessage() ?? 'Unknown error'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ private function dispatchConnectionChecks(Collection $servers): void
|
|||
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => get_class($e).': '.$e->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
|
@ -103,7 +103,7 @@ private function processScheduledTasks(Collection $servers): void
|
|||
Log::channel('scheduled-errors')->error('Error processing server tasks', [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => get_class($e).': '.$e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,42 @@ public function handle(): void
|
|||
return;
|
||||
}
|
||||
|
||||
// Check and install prerequisites
|
||||
$validationResult = $this->server->validatePrerequisites();
|
||||
if (! $validationResult['success']) {
|
||||
if ($this->numberOfTries >= $this->maxTries) {
|
||||
$missingCommands = implode(', ', $validationResult['missing']);
|
||||
$errorMessage = "Prerequisites ({$missingCommands}) could not be installed after {$this->maxTries} attempts. Please install them manually before continuing.";
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: Prerequisites installation failed after max tries', [
|
||||
'server_id' => $this->server->id,
|
||||
'attempts' => $this->numberOfTries,
|
||||
'missing_commands' => $validationResult['missing'],
|
||||
'found_commands' => $validationResult['found'],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('ValidateAndInstallServer: Installing prerequisites', [
|
||||
'server_id' => $this->server->id,
|
||||
'attempt' => $this->numberOfTries + 1,
|
||||
'missing_commands' => $validationResult['missing'],
|
||||
'found_commands' => $validationResult['found'],
|
||||
]);
|
||||
|
||||
// Install prerequisites
|
||||
$this->server->installPrerequisites();
|
||||
|
||||
// Retry validation after installation
|
||||
self::dispatch($this->server, $this->numberOfTries + 1)->delay(now()->addSeconds(30));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Docker is installed
|
||||
$dockerInstalled = $this->server->validateDockerEngine();
|
||||
$dockerComposeInstalled = $this->server->validateDockerCompose();
|
||||
|
|
@ -132,6 +168,9 @@ public function handle(): void
|
|||
if (! $this->server->isBuildServer()) {
|
||||
$proxyShouldRun = CheckProxy::run($this->server, true);
|
||||
if ($proxyShouldRun) {
|
||||
// Ensure networks exist BEFORE dispatching async proxy startup
|
||||
// This prevents race condition where proxy tries to start before networks are created
|
||||
instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
|
||||
StartProxy::dispatch($this->server);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class ActivityMonitor extends Component
|
|||
{
|
||||
public ?string $header = null;
|
||||
|
||||
public $activityId;
|
||||
public $activityId = null;
|
||||
|
||||
public $eventToDispatch = 'activityFinished';
|
||||
|
||||
|
|
@ -28,12 +28,20 @@ class ActivityMonitor extends Component
|
|||
|
||||
protected $listeners = ['activityMonitor' => 'newMonitorActivity'];
|
||||
|
||||
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null)
|
||||
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null)
|
||||
{
|
||||
// Reset event dispatched flag for new activity
|
||||
self::$eventDispatched = false;
|
||||
|
||||
$this->activityId = $activityId;
|
||||
$this->eventToDispatch = $eventToDispatch;
|
||||
$this->eventData = $eventData;
|
||||
|
||||
// Update header if provided
|
||||
if ($header !== null) {
|
||||
$this->header = $header;
|
||||
}
|
||||
|
||||
$this->hydrateActivity();
|
||||
|
||||
$this->isPollingActive = true;
|
||||
|
|
@ -41,9 +49,24 @@ public function newMonitorActivity($activityId, $eventToDispatch = 'activityFini
|
|||
|
||||
public function hydrateActivity()
|
||||
{
|
||||
if ($this->activityId === null) {
|
||||
$this->activity = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->activity = Activity::find($this->activityId);
|
||||
}
|
||||
|
||||
public function updatedActivityId($value)
|
||||
{
|
||||
if ($value) {
|
||||
$this->hydrateActivity();
|
||||
$this->isPollingActive = true;
|
||||
self::$eventDispatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function polling()
|
||||
{
|
||||
$this->hydrateActivity();
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@
|
|||
|
||||
class Index extends Component
|
||||
{
|
||||
protected $listeners = ['refreshBoardingIndex' => 'validateServer'];
|
||||
protected $listeners = [
|
||||
'refreshBoardingIndex' => 'validateServer',
|
||||
'prerequisitesInstalled' => 'handlePrerequisitesInstalled',
|
||||
];
|
||||
|
||||
#[\Livewire\Attributes\Url(as: 'step', history: true)]
|
||||
public string $currentState = 'welcome';
|
||||
|
|
@ -76,6 +79,10 @@ class Index extends Component
|
|||
|
||||
public ?string $minDockerVersion = null;
|
||||
|
||||
public int $prerequisiteInstallAttempts = 0;
|
||||
|
||||
public int $maxPrerequisiteInstallAttempts = 3;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (auth()->user()?->isMember() && auth()->user()->currentTeam()->show_boarding === true) {
|
||||
|
|
@ -320,6 +327,62 @@ public function validateServer()
|
|||
return handleError(error: $e, livewire: $this);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check prerequisites
|
||||
$validationResult = $this->createdServer->validatePrerequisites();
|
||||
if (! $validationResult['success']) {
|
||||
// Check if we've exceeded max attempts
|
||||
if ($this->prerequisiteInstallAttempts >= $this->maxPrerequisiteInstallAttempts) {
|
||||
$missingCommands = implode(', ', $validationResult['missing']);
|
||||
throw new \Exception("Prerequisites ({$missingCommands}) could not be installed after {$this->maxPrerequisiteInstallAttempts} attempts. Please install them manually.");
|
||||
}
|
||||
|
||||
// Start async installation and wait for completion via ActivityMonitor
|
||||
$activity = $this->createdServer->installPrerequisites();
|
||||
$this->prerequisiteInstallAttempts++;
|
||||
$this->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
|
||||
|
||||
// Return early - handlePrerequisitesInstalled() will be called when installation completes
|
||||
return;
|
||||
}
|
||||
|
||||
// Prerequisites are already installed, continue with validation
|
||||
$this->continueValidation();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError(error: $e, livewire: $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function handlePrerequisitesInstalled()
|
||||
{
|
||||
try {
|
||||
// Revalidate prerequisites after installation completes
|
||||
$validationResult = $this->createdServer->validatePrerequisites();
|
||||
if (! $validationResult['success']) {
|
||||
// Installation completed but prerequisites still missing - retry
|
||||
$missingCommands = implode(', ', $validationResult['missing']);
|
||||
|
||||
if ($this->prerequisiteInstallAttempts >= $this->maxPrerequisiteInstallAttempts) {
|
||||
throw new \Exception("Prerequisites ({$missingCommands}) could not be installed after {$this->maxPrerequisiteInstallAttempts} attempts. Please install them manually.");
|
||||
}
|
||||
|
||||
// Try again
|
||||
$activity = $this->createdServer->installPrerequisites();
|
||||
$this->prerequisiteInstallAttempts++;
|
||||
$this->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Prerequisites validated successfully - continue with Docker validation
|
||||
$this->continueValidation();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError(error: $e, livewire: $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function continueValidation()
|
||||
{
|
||||
try {
|
||||
$dockerVersion = instant_remote_process(["docker version|head -2|grep -i version| awk '{print $2}'"], $this->createdServer, true);
|
||||
$dockerVersion = checkMinimumDockerEngineVersion($dockerVersion);
|
||||
|
|
@ -347,6 +410,8 @@ public function selectProxy(?string $proxyType = null)
|
|||
}
|
||||
$this->createdServer->proxy->type = $proxyType;
|
||||
$this->createdServer->proxy->status = 'exited';
|
||||
$this->createdServer->proxy->last_saved_settings = null;
|
||||
$this->createdServer->proxy->last_applied_settings = null;
|
||||
$this->createdServer->save();
|
||||
$this->getProjects();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ class Discord extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchDiscordNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedDiscordNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $discordPingEnabled = true;
|
||||
|
||||
|
|
@ -98,6 +101,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->settings->server_reachable_discord_notifications = $this->serverReachableDiscordNotifications;
|
||||
$this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications;
|
||||
$this->settings->server_patch_discord_notifications = $this->serverPatchDiscordNotifications;
|
||||
$this->settings->traefik_outdated_discord_notifications = $this->traefikOutdatedDiscordNotifications;
|
||||
|
||||
$this->settings->discord_ping_enabled = $this->discordPingEnabled;
|
||||
|
||||
|
|
@ -120,6 +124,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->serverReachableDiscordNotifications = $this->settings->server_reachable_discord_notifications;
|
||||
$this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications;
|
||||
$this->serverPatchDiscordNotifications = $this->settings->server_patch_discord_notifications;
|
||||
$this->traefikOutdatedDiscordNotifications = $this->settings->traefik_outdated_discord_notifications;
|
||||
|
||||
$this->discordPingEnabled = $this->settings->discord_ping_enabled;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,9 @@ class Email extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchEmailNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedEmailNotifications = true;
|
||||
|
||||
#[Validate(['nullable', 'email'])]
|
||||
public ?string $testEmailAddress = null;
|
||||
|
||||
|
|
@ -155,6 +158,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->settings->server_reachable_email_notifications = $this->serverReachableEmailNotifications;
|
||||
$this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications;
|
||||
$this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications;
|
||||
$this->settings->traefik_outdated_email_notifications = $this->traefikOutdatedEmailNotifications;
|
||||
$this->settings->save();
|
||||
|
||||
} else {
|
||||
|
|
@ -187,6 +191,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->serverReachableEmailNotifications = $this->settings->server_reachable_email_notifications;
|
||||
$this->serverUnreachableEmailNotifications = $this->settings->server_unreachable_email_notifications;
|
||||
$this->serverPatchEmailNotifications = $this->settings->server_patch_email_notifications;
|
||||
$this->traefikOutdatedEmailNotifications = $this->settings->traefik_outdated_email_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ class Pushover extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchPushoverNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedPushoverNotifications = true;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
|
|
@ -104,6 +107,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->settings->server_reachable_pushover_notifications = $this->serverReachablePushoverNotifications;
|
||||
$this->settings->server_unreachable_pushover_notifications = $this->serverUnreachablePushoverNotifications;
|
||||
$this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications;
|
||||
$this->settings->traefik_outdated_pushover_notifications = $this->traefikOutdatedPushoverNotifications;
|
||||
|
||||
$this->settings->save();
|
||||
refreshSession();
|
||||
|
|
@ -125,6 +129,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->serverReachablePushoverNotifications = $this->settings->server_reachable_pushover_notifications;
|
||||
$this->serverUnreachablePushoverNotifications = $this->settings->server_unreachable_pushover_notifications;
|
||||
$this->serverPatchPushoverNotifications = $this->settings->server_patch_pushover_notifications;
|
||||
$this->traefikOutdatedPushoverNotifications = $this->settings->traefik_outdated_pushover_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ class Slack extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchSlackNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedSlackNotifications = true;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
|
|
@ -100,6 +103,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->settings->server_reachable_slack_notifications = $this->serverReachableSlackNotifications;
|
||||
$this->settings->server_unreachable_slack_notifications = $this->serverUnreachableSlackNotifications;
|
||||
$this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications;
|
||||
$this->settings->traefik_outdated_slack_notifications = $this->traefikOutdatedSlackNotifications;
|
||||
|
||||
$this->settings->save();
|
||||
refreshSession();
|
||||
|
|
@ -120,6 +124,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->serverReachableSlackNotifications = $this->settings->server_reachable_slack_notifications;
|
||||
$this->serverUnreachableSlackNotifications = $this->settings->server_unreachable_slack_notifications;
|
||||
$this->serverPatchSlackNotifications = $this->settings->server_patch_slack_notifications;
|
||||
$this->traefikOutdatedSlackNotifications = $this->settings->traefik_outdated_slack_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ class Telegram extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchTelegramNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedTelegramNotifications = true;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $telegramNotificationsDeploymentSuccessThreadId = null;
|
||||
|
||||
|
|
@ -109,6 +112,9 @@ class Telegram extends Component
|
|||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $telegramNotificationsServerPatchThreadId = null;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $telegramNotificationsTraefikOutdatedThreadId = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
|
|
@ -143,6 +149,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->settings->server_reachable_telegram_notifications = $this->serverReachableTelegramNotifications;
|
||||
$this->settings->server_unreachable_telegram_notifications = $this->serverUnreachableTelegramNotifications;
|
||||
$this->settings->server_patch_telegram_notifications = $this->serverPatchTelegramNotifications;
|
||||
$this->settings->traefik_outdated_telegram_notifications = $this->traefikOutdatedTelegramNotifications;
|
||||
|
||||
$this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId;
|
||||
$this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId;
|
||||
|
|
@ -157,6 +164,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->settings->telegram_notifications_server_reachable_thread_id = $this->telegramNotificationsServerReachableThreadId;
|
||||
$this->settings->telegram_notifications_server_unreachable_thread_id = $this->telegramNotificationsServerUnreachableThreadId;
|
||||
$this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId;
|
||||
$this->settings->telegram_notifications_traefik_outdated_thread_id = $this->telegramNotificationsTraefikOutdatedThreadId;
|
||||
|
||||
$this->settings->save();
|
||||
} else {
|
||||
|
|
@ -177,6 +185,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->serverReachableTelegramNotifications = $this->settings->server_reachable_telegram_notifications;
|
||||
$this->serverUnreachableTelegramNotifications = $this->settings->server_unreachable_telegram_notifications;
|
||||
$this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications;
|
||||
$this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications;
|
||||
|
||||
$this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id;
|
||||
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
|
||||
|
|
@ -191,6 +200,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
|
||||
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
|
||||
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
|
||||
$this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ class Webhook extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchWebhookNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedWebhookNotifications = true;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
|
|
@ -95,6 +98,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->settings->server_reachable_webhook_notifications = $this->serverReachableWebhookNotifications;
|
||||
$this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications;
|
||||
$this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications;
|
||||
$this->settings->traefik_outdated_webhook_notifications = $this->traefikOutdatedWebhookNotifications;
|
||||
|
||||
$this->settings->save();
|
||||
refreshSession();
|
||||
|
|
@ -115,6 +119,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->serverReachableWebhookNotifications = $this->settings->server_reachable_webhook_notifications;
|
||||
$this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications;
|
||||
$this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications;
|
||||
$this->traefikOutdatedWebhookNotifications = $this->settings->traefik_outdated_webhook_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ class Advanced extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $disableBuildCache = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $injectBuildArgsToDockerfile = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $includeSourceCommitInBuild = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
|
|
@ -110,6 +116,8 @@ public function syncData(bool $toModel = false)
|
|||
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
|
||||
$this->application->settings->connect_to_docker_network = $this->isConnectToDockerNetworkEnabled;
|
||||
$this->application->settings->disable_build_cache = $this->disableBuildCache;
|
||||
$this->application->settings->inject_build_args_to_dockerfile = $this->injectBuildArgsToDockerfile;
|
||||
$this->application->settings->include_source_commit_in_build = $this->includeSourceCommitInBuild;
|
||||
$this->application->settings->save();
|
||||
} else {
|
||||
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
|
||||
|
|
@ -134,6 +142,8 @@ public function syncData(bool $toModel = false)
|
|||
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
|
||||
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
|
||||
$this->disableBuildCache = $this->application->settings->disable_build_cache;
|
||||
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
|
||||
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -641,8 +641,6 @@ public function updatedBuildPack()
|
|||
$this->application->settings->is_static = false;
|
||||
$this->application->settings->save();
|
||||
} else {
|
||||
$this->portsExposes = '3000';
|
||||
$this->application->ports_exposes = '3000';
|
||||
$this->resetDefaultLabels(false);
|
||||
}
|
||||
if ($this->buildPack === 'dockercompose') {
|
||||
|
|
@ -655,18 +653,6 @@ public function updatedBuildPack()
|
|||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User doesn't have update permission, just continue without saving
|
||||
}
|
||||
} else {
|
||||
// Clear Docker Compose specific data when switching away from dockercompose
|
||||
if ($this->application->getOriginal('build_pack') === 'dockercompose') {
|
||||
$this->application->docker_compose_domains = null;
|
||||
$this->application->docker_compose_raw = null;
|
||||
|
||||
// Remove SERVICE_FQDN_* and SERVICE_URL_* environment variables
|
||||
$this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete();
|
||||
$this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
|
||||
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete();
|
||||
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
|
||||
}
|
||||
}
|
||||
if ($this->buildPack === 'static') {
|
||||
$this->portsExposes = '80';
|
||||
|
|
@ -1000,4 +986,60 @@ private function updateServiceEnvironmentVariables()
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getDetectedPortInfoProperty(): ?array
|
||||
{
|
||||
$detectedPort = $this->application->detectPortFromEnvironment();
|
||||
|
||||
if (! $detectedPort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$portsExposesArray = $this->application->ports_exposes_array;
|
||||
$isMatch = in_array($detectedPort, $portsExposesArray);
|
||||
$isEmpty = empty($portsExposesArray);
|
||||
|
||||
return [
|
||||
'port' => $detectedPort,
|
||||
'matches' => $isMatch,
|
||||
'isEmpty' => $isEmpty,
|
||||
];
|
||||
}
|
||||
|
||||
public function getDockerComposeBuildCommandPreviewProperty(): string
|
||||
{
|
||||
if (! $this->dockerComposeCustomBuildCommand) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Normalize baseDirectory to prevent double slashes (e.g., when baseDirectory is '/')
|
||||
$normalizedBase = $this->baseDirectory === '/' ? '' : rtrim($this->baseDirectory, '/');
|
||||
|
||||
// Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
|
||||
// Actual deployment uses absolute path: /artifacts/{deployment_uuid}{base_directory}{docker_compose_location}
|
||||
// Build-time env path references ApplicationDeploymentJob::BUILD_TIME_ENV_PATH as source of truth
|
||||
return injectDockerComposeFlags(
|
||||
$this->dockerComposeCustomBuildCommand,
|
||||
".{$normalizedBase}{$this->dockerComposeLocation}",
|
||||
\App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH
|
||||
);
|
||||
}
|
||||
|
||||
public function getDockerComposeStartCommandPreviewProperty(): string
|
||||
{
|
||||
if (! $this->dockerComposeCustomStartCommand) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Normalize baseDirectory to prevent double slashes (e.g., when baseDirectory is '/')
|
||||
$normalizedBase = $this->baseDirectory === '/' ? '' : rtrim($this->baseDirectory, '/');
|
||||
|
||||
// Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
|
||||
// Placeholder {workdir}/.env shows it's the workdir .env file (runtime env, not build-time)
|
||||
return injectDockerComposeFlags(
|
||||
$this->dockerComposeCustomStartCommand,
|
||||
".{$normalizedBase}{$this->dockerComposeLocation}",
|
||||
'{workdir}/.env'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,11 +101,18 @@ public function deploy(bool $force_rebuild = false)
|
|||
force_rebuild: $force_rebuild,
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||
$this->dispatch('error', 'Deployment skipped', $result['message']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset restart count on successful deployment
|
||||
$this->application->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
|
||||
return $this->redirectRoute('project.application.deployment.show', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'application_uuid' => $this->parameters['application_uuid'],
|
||||
|
|
@ -137,6 +144,7 @@ public function restart()
|
|||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setDeploymentUuid();
|
||||
$result = queue_application_deployment(
|
||||
application: $this->application,
|
||||
|
|
@ -149,6 +157,13 @@ public function restart()
|
|||
return;
|
||||
}
|
||||
|
||||
// Reset restart count on manual restart
|
||||
$this->application->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => now(),
|
||||
'last_restart_type' => 'manual',
|
||||
]);
|
||||
|
||||
return $this->redirectRoute('project.application.deployment.show', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'application_uuid' => $this->parameters['application_uuid'],
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ class BackupEdit extends Component
|
|||
#[Validate(['required', 'boolean'])]
|
||||
public bool $dumpAll = false;
|
||||
|
||||
#[Validate(['required', 'int', 'min:1', 'max:36000'])]
|
||||
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
|
||||
public int $timeout = 3600;
|
||||
|
||||
public function mount()
|
||||
|
|
@ -107,6 +107,25 @@ public function syncData(bool $toModel = false)
|
|||
$this->backup->save_s3 = $this->saveS3;
|
||||
$this->backup->disable_local_backup = $this->disableLocalBackup;
|
||||
$this->backup->s3_storage_id = $this->s3StorageId;
|
||||
|
||||
// Validate databases_to_backup to prevent command injection
|
||||
if (filled($this->databasesToBackup)) {
|
||||
$databases = str($this->databasesToBackup)->explode(',');
|
||||
foreach ($databases as $index => $db) {
|
||||
$dbName = trim($db);
|
||||
try {
|
||||
validateShellSafePath($dbName, 'database name');
|
||||
} catch (\Exception $e) {
|
||||
// Provide specific error message indicating which database failed validation
|
||||
$position = $index + 1;
|
||||
throw new \Exception(
|
||||
"Database #{$position} ('{$dbName}') validation failed: ".
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->backup->databases_to_backup = $this->databasesToBackup;
|
||||
$this->backup->dump_all = $this->dumpAll;
|
||||
$this->backup->timeout = $this->timeout;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -12,6 +13,92 @@ class Import extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* Validate that a string is safe for use as an S3 bucket name.
|
||||
* Allows alphanumerics, dots, dashes, and underscores.
|
||||
*/
|
||||
private function validateBucketName(string $bucket): bool
|
||||
{
|
||||
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string is safe for use as an S3 path.
|
||||
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
|
||||
*/
|
||||
private function validateS3Path(string $path): bool
|
||||
{
|
||||
// Must not be empty
|
||||
if (empty($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not contain dangerous shell metacharacters or command injection patterns
|
||||
$dangerousPatterns = [
|
||||
'..', // Directory traversal
|
||||
'$(', // Command substitution
|
||||
'`', // Backtick command substitution
|
||||
'|', // Pipe
|
||||
';', // Command separator
|
||||
'&', // Background/AND
|
||||
'>', // Redirect
|
||||
'<', // Redirect
|
||||
"\n", // Newline
|
||||
"\r", // Carriage return
|
||||
"\0", // Null byte
|
||||
"'", // Single quote
|
||||
'"', // Double quote
|
||||
'\\', // Backslash
|
||||
];
|
||||
|
||||
foreach ($dangerousPatterns as $pattern) {
|
||||
if (str_contains($path, $pattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
|
||||
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string is safe for use as a file path on the server.
|
||||
*/
|
||||
private function validateServerPath(string $path): bool
|
||||
{
|
||||
// Must be an absolute path
|
||||
if (! str_starts_with($path, '/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not contain dangerous shell metacharacters or command injection patterns
|
||||
$dangerousPatterns = [
|
||||
'..', // Directory traversal
|
||||
'$(', // Command substitution
|
||||
'`', // Backtick command substitution
|
||||
'|', // Pipe
|
||||
';', // Command separator
|
||||
'&', // Background/AND
|
||||
'>', // Redirect
|
||||
'<', // Redirect
|
||||
"\n", // Newline
|
||||
"\r", // Carriage return
|
||||
"\0", // Null byte
|
||||
"'", // Single quote
|
||||
'"', // Double quote
|
||||
'\\', // Backslash
|
||||
];
|
||||
|
||||
foreach ($dangerousPatterns as $pattern) {
|
||||
if (str_contains($path, $pattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
|
||||
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
|
||||
}
|
||||
|
||||
public bool $unsupported = false;
|
||||
|
||||
public $resource;
|
||||
|
|
@ -46,6 +133,8 @@ class Import extends Component
|
|||
|
||||
public string $customLocation = '';
|
||||
|
||||
public ?int $activityId = null;
|
||||
|
||||
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
|
||||
|
||||
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||
|
|
@ -54,22 +143,35 @@ class Import extends Component
|
|||
|
||||
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
||||
|
||||
// S3 Restore properties
|
||||
public $availableS3Storages = [];
|
||||
|
||||
public ?int $s3StorageId = null;
|
||||
|
||||
public string $s3Path = '';
|
||||
|
||||
public ?int $s3FileSize = null;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
'slideOverClosed' => 'resetActivityId',
|
||||
];
|
||||
}
|
||||
|
||||
public function resetActivityId()
|
||||
{
|
||||
$this->activityId = null;
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (isDev()) {
|
||||
$this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz';
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->getContainers();
|
||||
$this->loadAvailableS3Storages();
|
||||
}
|
||||
|
||||
public function updatedDumpAll($value)
|
||||
|
|
@ -152,8 +254,16 @@ public function getContainers()
|
|||
public function checkFile()
|
||||
{
|
||||
if (filled($this->customLocation)) {
|
||||
// Validate the custom location to prevent command injection
|
||||
if (! $this->validateServerPath($this->customLocation)) {
|
||||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = instant_remote_process(["ls -l {$this->customLocation}"], $this->server, throwError: false);
|
||||
$escapedPath = escapeshellarg($this->customLocation);
|
||||
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
|
||||
if (blank($result)) {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
|
||||
|
|
@ -179,59 +289,35 @@ public function runImport()
|
|||
try {
|
||||
$this->importRunning = true;
|
||||
$this->importCommands = [];
|
||||
if (filled($this->customLocation)) {
|
||||
$backupFileName = '/tmp/restore_'.$this->resource->uuid;
|
||||
$this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$backupFileName}";
|
||||
$tmpPath = $backupFileName;
|
||||
} else {
|
||||
$backupFileName = "upload/{$this->resource->uuid}/restore";
|
||||
$path = Storage::path($backupFileName);
|
||||
if (! Storage::exists($backupFileName)) {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
$backupFileName = "upload/{$this->resource->uuid}/restore";
|
||||
|
||||
return;
|
||||
}
|
||||
// Check if an uploaded file exists first (takes priority over custom location)
|
||||
if (Storage::exists($backupFileName)) {
|
||||
$path = Storage::path($backupFileName);
|
||||
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid;
|
||||
instant_scp($path, $tmpPath, $this->server);
|
||||
Storage::delete($backupFileName);
|
||||
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
||||
} elseif (filled($this->customLocation)) {
|
||||
// Validate the custom location to prevent command injection
|
||||
if (! $this->validateServerPath($this->customLocation)) {
|
||||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
|
||||
|
||||
return;
|
||||
}
|
||||
$tmpPath = '/tmp/restore_'.$this->resource->uuid;
|
||||
$escapedCustomLocation = escapeshellarg($this->customLocation);
|
||||
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
|
||||
} else {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy the restore command to a script file
|
||||
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
|
||||
|
||||
switch ($this->resource->getMorphClass()) {
|
||||
case \App\Models\StandaloneMariadb::class:
|
||||
$restoreCommand = $this->mariadbRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMysql::class:
|
||||
$restoreCommand = $this->mysqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandalonePostgresql::class:
|
||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
|
||||
} else {
|
||||
$restoreCommand .= " {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMongodb::class:
|
||||
$restoreCommand = $this->mongodbRestoreCommand;
|
||||
if ($this->dumpAll === false) {
|
||||
$restoreCommand .= "{$tmpPath}";
|
||||
}
|
||||
break;
|
||||
}
|
||||
$restoreCommand = $this->buildRestoreCommand($tmpPath);
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||
|
|
@ -248,7 +334,13 @@ public function runImport()
|
|||
'container' => $this->container,
|
||||
'serverId' => $this->server->id,
|
||||
]);
|
||||
|
||||
// Track the activity ID
|
||||
$this->activityId = $activity->id;
|
||||
|
||||
// Dispatch activity to the monitor and open slide-over
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -257,4 +349,267 @@ public function runImport()
|
|||
$this->importCommands = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function loadAvailableS3Storages()
|
||||
{
|
||||
try {
|
||||
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
|
||||
->where('is_usable', true)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$this->availableS3Storages = collect();
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedS3Path($value)
|
||||
{
|
||||
// Reset validation state when path changes
|
||||
$this->s3FileSize = null;
|
||||
|
||||
// Ensure path starts with a slash
|
||||
if ($value !== null && $value !== '') {
|
||||
$this->s3Path = str($value)->trim()->start('/')->value();
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedS3StorageId()
|
||||
{
|
||||
// Reset validation state when storage changes
|
||||
$this->s3FileSize = null;
|
||||
}
|
||||
|
||||
public function checkS3File()
|
||||
{
|
||||
if (! $this->s3StorageId) {
|
||||
$this->dispatch('error', 'Please select an S3 storage.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (blank($this->s3Path)) {
|
||||
$this->dispatch('error', 'Please provide an S3 path.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean the path (remove leading slash if present)
|
||||
$cleanPath = ltrim($this->s3Path, '/');
|
||||
|
||||
// Validate the S3 path early to prevent command injection in subsequent operations
|
||||
if (! $this->validateS3Path($cleanPath)) {
|
||||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||
|
||||
// Validate bucket name early
|
||||
if (! $this->validateBucketName($s3Storage->bucket)) {
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Test connection
|
||||
$s3Storage->testConnection();
|
||||
|
||||
// Build S3 disk configuration
|
||||
$disk = Storage::build([
|
||||
'driver' => 's3',
|
||||
'region' => $s3Storage->region,
|
||||
'key' => $s3Storage->key,
|
||||
'secret' => $s3Storage->secret,
|
||||
'bucket' => $s3Storage->bucket,
|
||||
'endpoint' => $s3Storage->endpoint,
|
||||
'use_path_style_endpoint' => true,
|
||||
]);
|
||||
|
||||
// Check if file exists
|
||||
if (! $disk->exists($cleanPath)) {
|
||||
$this->dispatch('error', 'File not found in S3. Please check the path.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get file size
|
||||
$this->s3FileSize = $disk->size($cleanPath);
|
||||
|
||||
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
|
||||
} catch (\Throwable $e) {
|
||||
$this->s3FileSize = null;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function restoreFromS3()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->s3StorageId || blank($this->s3Path)) {
|
||||
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_null($this->s3FileSize)) {
|
||||
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->importRunning = true;
|
||||
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||
|
||||
$key = $s3Storage->key;
|
||||
$secret = $s3Storage->secret;
|
||||
$bucket = $s3Storage->bucket;
|
||||
$endpoint = $s3Storage->endpoint;
|
||||
|
||||
// Validate bucket name to prevent command injection
|
||||
if (! $this->validateBucketName($bucket)) {
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean the S3 path
|
||||
$cleanPath = ltrim($this->s3Path, '/');
|
||||
|
||||
// Validate the S3 path to prevent command injection
|
||||
if (! $this->validateS3Path($cleanPath)) {
|
||||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get helper image
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$latestVersion = getHelperVersion();
|
||||
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||||
|
||||
// Get the database destination network
|
||||
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
|
||||
|
||||
// Generate unique names for this operation
|
||||
$containerName = "s3-restore-{$this->resource->uuid}";
|
||||
$helperTmpPath = '/tmp/'.basename($cleanPath);
|
||||
$serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath);
|
||||
$containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath);
|
||||
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
|
||||
|
||||
// Prepare all commands in sequence
|
||||
$commands = [];
|
||||
|
||||
// 1. Clean up any existing helper container and temp files from previous runs
|
||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
|
||||
$commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
|
||||
|
||||
// 2. Start helper container on the database network
|
||||
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
|
||||
|
||||
// 3. Configure S3 access in helper container
|
||||
$escapedEndpoint = escapeshellarg($endpoint);
|
||||
$escapedKey = escapeshellarg($key);
|
||||
$escapedSecret = escapeshellarg($secret);
|
||||
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||
|
||||
// 4. Check file exists in S3 (bucket and path already validated above)
|
||||
$escapedBucket = escapeshellarg($bucket);
|
||||
$escapedCleanPath = escapeshellarg($cleanPath);
|
||||
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
|
||||
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
|
||||
|
||||
// 5. Download from S3 to helper container (progress shown by default)
|
||||
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
|
||||
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
|
||||
|
||||
// 6. Copy from helper to server, then immediately to database container
|
||||
$commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
|
||||
$commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
|
||||
|
||||
// 7. Cleanup helper container and server temp file immediately (no longer needed)
|
||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
|
||||
|
||||
// 8. Build and execute restore command inside database container
|
||||
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||
$commands[] = "chmod +x {$scriptPath}";
|
||||
$commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
||||
|
||||
// 9. Execute restore and cleanup temp files immediately after completion
|
||||
$commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
|
||||
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||
|
||||
// Execute all commands with cleanup event (as safety net for edge cases)
|
||||
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
|
||||
'containerName' => $containerName,
|
||||
'serverTmpPath' => $serverTmpPath,
|
||||
'scriptPath' => $scriptPath,
|
||||
'containerTmpPath' => $containerTmpPath,
|
||||
'container' => $this->container,
|
||||
'serverId' => $this->server->id,
|
||||
]);
|
||||
|
||||
// Track the activity ID
|
||||
$this->activityId = $activity->id;
|
||||
|
||||
// Dispatch activity to the monitor and open slide-over
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
|
||||
} catch (\Throwable $e) {
|
||||
$this->importRunning = false;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function buildRestoreCommand(string $tmpPath): string
|
||||
{
|
||||
switch ($this->resource->getMorphClass()) {
|
||||
case \App\Models\StandaloneMariadb::class:
|
||||
$restoreCommand = $this->mariadbRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMysql::class:
|
||||
$restoreCommand = $this->mysqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandalonePostgresql::class:
|
||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
|
||||
} else {
|
||||
$restoreCommand .= " {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMongodb::class:
|
||||
$restoreCommand = $this->mongodbRestoreCommand;
|
||||
if ($this->dumpAll === false) {
|
||||
$restoreCommand .= "{$tmpPath}";
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$restoreCommand = '';
|
||||
}
|
||||
|
||||
return $restoreCommand;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -328,12 +328,15 @@ public function save_init_script($script)
|
|||
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||
|
||||
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
|
||||
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
|
||||
$delete_command = "rm -f $old_file_path";
|
||||
try {
|
||||
// Validate and escape filename to prevent command injection
|
||||
validateShellSafePath($oldScript['filename'], 'init script filename');
|
||||
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
|
||||
$escapedOldPath = escapeshellarg($old_file_path);
|
||||
$delete_command = "rm -f {$escapedOldPath}";
|
||||
instant_remote_process([$delete_command], $this->server);
|
||||
} catch (Exception $e) {
|
||||
$this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage());
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -370,13 +373,17 @@ public function delete_init_script($script)
|
|||
if ($found) {
|
||||
$container_name = $this->database->uuid;
|
||||
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
|
||||
|
||||
$command = "rm -f $file_path";
|
||||
try {
|
||||
// Validate and escape filename to prevent command injection
|
||||
validateShellSafePath($script['filename'], 'init script filename');
|
||||
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
|
||||
$escapedPath = escapeshellarg($file_path);
|
||||
|
||||
$command = "rm -f {$escapedPath}";
|
||||
instant_remote_process([$command], $this->server);
|
||||
} catch (Exception $e) {
|
||||
$this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage());
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -405,6 +412,16 @@ public function save_new_init_script()
|
|||
'new_filename' => 'required|string',
|
||||
'new_content' => 'required|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
// Validate filename to prevent command injection
|
||||
validateShellSafePath($this->new_filename, 'init script filename');
|
||||
} catch (Exception $e) {
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
|
||||
if ($found) {
|
||||
$this->dispatch('error', 'Filename already exists.');
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ public function mount()
|
|||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
];
|
||||
if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') {
|
||||
if ($oneClickServiceName === 'pgadmin' || $oneClickServiceName === 'postgresus') {
|
||||
data_set($service_payload, 'connect_to_docker_network', true);
|
||||
}
|
||||
$service = Service::create($service_payload);
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ public function mount()
|
|||
{
|
||||
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
|
||||
$this->authorize('view', $this->application);
|
||||
$this->requiredPort = $this->application->service->getRequiredPort();
|
||||
$this->requiredPort = $this->application->getRequiredPort();
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
|
|
@ -113,8 +113,7 @@ public function submit()
|
|||
|
||||
// Check for required port
|
||||
if (! $this->forceRemovePort) {
|
||||
$service = $this->application->service;
|
||||
$requiredPort = $service->getRequiredPort();
|
||||
$requiredPort = $this->application->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
// Check if all FQDNs have a port
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ public function mount()
|
|||
try {
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->authorize('view', $this->application);
|
||||
$this->requiredPort = $this->application->service->getRequiredPort();
|
||||
$this->requiredPort = $this->application->getRequiredPort();
|
||||
$this->syncData();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -268,8 +268,7 @@ public function submit()
|
|||
|
||||
// Check for required port
|
||||
if (! $this->forceRemovePort) {
|
||||
$service = $this->application->service;
|
||||
$requiredPort = $service->getRequiredPort();
|
||||
$requiredPort = $this->application->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
// Check if all FQDNs have a port
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Models\Service;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class StackForm extends Component
|
||||
|
|
@ -22,7 +23,7 @@ class StackForm extends Component
|
|||
|
||||
public string $dockerComposeRaw;
|
||||
|
||||
public string $dockerCompose;
|
||||
public ?string $dockerCompose = null;
|
||||
|
||||
public ?bool $connectToDockerNetwork = null;
|
||||
|
||||
|
|
@ -30,7 +31,7 @@ protected function rules(): array
|
|||
{
|
||||
$baseRules = [
|
||||
'dockerComposeRaw' => 'required',
|
||||
'dockerCompose' => 'required',
|
||||
'dockerCompose' => 'nullable',
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'connectToDockerNetwork' => 'nullable',
|
||||
|
|
@ -140,18 +141,27 @@ public function submit($notify = true)
|
|||
$this->validate();
|
||||
$this->syncData(true);
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
// Validate for command injection BEFORE any database operations
|
||||
validateDockerComposeForInjection($this->service->docker_compose_raw);
|
||||
|
||||
$this->service->save();
|
||||
$this->service->saveExtraFields($this->fields);
|
||||
$this->service->parse();
|
||||
// Use transaction to ensure atomicity - if parse fails, save is rolled back
|
||||
DB::transaction(function () {
|
||||
$this->service->save();
|
||||
$this->service->saveExtraFields($this->fields);
|
||||
$this->service->parse();
|
||||
});
|
||||
// Refresh and write files after a successful commit
|
||||
$this->service->refresh();
|
||||
$this->service->saveComposeConfigs();
|
||||
|
||||
$this->dispatch('refreshEnvs');
|
||||
$this->dispatch('refreshServices');
|
||||
$notify && $this->dispatch('success', 'Service saved.');
|
||||
} catch (\Throwable $e) {
|
||||
// On error, refresh from database to restore clean state
|
||||
$this->service->refresh();
|
||||
$this->syncData(false);
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
if (is_null($this->service->config_hash)) {
|
||||
|
|
|
|||
|
|
@ -179,6 +179,10 @@ public function submitFileStorageDirectory()
|
|||
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
||||
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
||||
|
||||
// Validate paths to prevent command injection
|
||||
validateShellSafePath($this->file_storage_directory_source, 'storage source path');
|
||||
validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
|
||||
|
||||
\App\Models\LocalFileVolume::create([
|
||||
'fs_path' => $this->file_storage_directory_source,
|
||||
'mount_path' => $this->file_storage_directory_destination,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Add extends Component
|
||||
|
|
@ -56,6 +59,72 @@ public function mount()
|
|||
$this->problematicVariables = self::getProblematicVariablesForFrontend();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function availableSharedVariables(): array
|
||||
{
|
||||
$team = currentTeam();
|
||||
$result = [
|
||||
'team' => [],
|
||||
'project' => [],
|
||||
'environment' => [],
|
||||
];
|
||||
|
||||
// Early return if no team
|
||||
if (! $team) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Check if user can view team variables
|
||||
try {
|
||||
$this->authorize('view', $team);
|
||||
$result['team'] = $team->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view team variables
|
||||
}
|
||||
|
||||
// Get project variables if we have a project_uuid in route
|
||||
$projectUuid = data_get($this->parameters, 'project_uuid');
|
||||
if ($projectUuid) {
|
||||
$project = Project::where('team_id', $team->id)
|
||||
->where('uuid', $projectUuid)
|
||||
->first();
|
||||
|
||||
if ($project) {
|
||||
try {
|
||||
$this->authorize('view', $project);
|
||||
$result['project'] = $project->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
|
||||
// Get environment variables if we have an environment_uuid in route
|
||||
$environmentUuid = data_get($this->parameters, 'environment_uuid');
|
||||
if ($environmentUuid) {
|
||||
$environment = $project->environments()
|
||||
->where('uuid', $environmentUuid)
|
||||
->first();
|
||||
|
||||
if ($environment) {
|
||||
try {
|
||||
$this->authorize('view', $environment);
|
||||
$result['environment'] = $environment->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view environment variables
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view project variables
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->validate();
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@
|
|||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\EnvironmentVariableProtection;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
|
|
@ -184,6 +187,7 @@ public function submit()
|
|||
|
||||
$this->serialize();
|
||||
$this->syncData(true);
|
||||
$this->syncData(false);
|
||||
$this->dispatch('success', 'Environment variable updated.');
|
||||
$this->dispatch('envsUpdated');
|
||||
$this->dispatch('configurationChanged');
|
||||
|
|
@ -192,6 +196,72 @@ public function submit()
|
|||
}
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function availableSharedVariables(): array
|
||||
{
|
||||
$team = currentTeam();
|
||||
$result = [
|
||||
'team' => [],
|
||||
'project' => [],
|
||||
'environment' => [],
|
||||
];
|
||||
|
||||
// Early return if no team
|
||||
if (! $team) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Check if user can view team variables
|
||||
try {
|
||||
$this->authorize('view', $team);
|
||||
$result['team'] = $team->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view team variables
|
||||
}
|
||||
|
||||
// Get project variables if we have a project_uuid in route
|
||||
$projectUuid = data_get($this->parameters, 'project_uuid');
|
||||
if ($projectUuid) {
|
||||
$project = Project::where('team_id', $team->id)
|
||||
->where('uuid', $projectUuid)
|
||||
->first();
|
||||
|
||||
if ($project) {
|
||||
try {
|
||||
$this->authorize('view', $project);
|
||||
$result['project'] = $project->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
|
||||
// Get environment variables if we have an environment_uuid in route
|
||||
$environmentUuid = data_get($this->parameters, 'environment_uuid');
|
||||
if ($environmentUuid) {
|
||||
$environment = $project->environments()
|
||||
->where('uuid', $environmentUuid)
|
||||
->first();
|
||||
|
||||
if ($environment) {
|
||||
try {
|
||||
$this->authorize('view', $environment);
|
||||
$result['environment'] = $environment->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view environment variables
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view project variables
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -34,11 +34,14 @@ class Add extends Component
|
|||
|
||||
public ?string $container = '';
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string',
|
||||
'command' => 'required|string',
|
||||
'frequency' => 'required|string',
|
||||
'container' => 'nullable|string',
|
||||
'timeout' => 'required|integer|min:60|max:3600',
|
||||
];
|
||||
|
||||
protected $validationAttributes = [
|
||||
|
|
@ -46,6 +49,7 @@ class Add extends Component
|
|||
'command' => 'command',
|
||||
'frequency' => 'frequency',
|
||||
'container' => 'container',
|
||||
'timeout' => 'timeout',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
|
|
@ -103,6 +107,7 @@ public function saveScheduledTask()
|
|||
$task->command = $this->command;
|
||||
$task->frequency = $this->frequency;
|
||||
$task->container = $this->container;
|
||||
$task->timeout = $this->timeout;
|
||||
$task->team_id = currentTeam()->id;
|
||||
|
||||
switch ($this->type) {
|
||||
|
|
@ -130,5 +135,6 @@ public function clear()
|
|||
$this->command = '';
|
||||
$this->frequency = '';
|
||||
$this->container = '';
|
||||
$this->timeout = 300;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ class Show extends Component
|
|||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $container = null;
|
||||
|
||||
#[Validate(['integer', 'required', 'min:60', 'max:3600'])]
|
||||
public $timeout = 300;
|
||||
|
||||
#[Locked]
|
||||
public ?string $application_uuid;
|
||||
|
||||
|
|
@ -99,6 +102,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->task->command = str($this->command)->trim()->value();
|
||||
$this->task->frequency = str($this->frequency)->trim()->value();
|
||||
$this->task->container = str($this->container)->trim()->value();
|
||||
$this->task->timeout = (int) $this->timeout;
|
||||
$this->task->save();
|
||||
} else {
|
||||
$this->isEnabled = $this->task->enabled;
|
||||
|
|
@ -106,6 +110,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->command = $this->task->command;
|
||||
$this->frequency = $this->task->frequency;
|
||||
$this->container = $this->task->container;
|
||||
$this->timeout = $this->task->timeout ?? 300;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Actions\Proxy\StopProxy;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Jobs\CheckTraefikVersionForServerJob;
|
||||
use App\Models\Server;
|
||||
use App\Services\ProxyDashboardCacheService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
|
@ -61,7 +62,18 @@ public function restart()
|
|||
{
|
||||
try {
|
||||
$this->authorize('manageProxy', $this->server);
|
||||
RestartProxyJob::dispatch($this->server);
|
||||
StopProxy::run($this->server, restarting: true);
|
||||
|
||||
$this->server->proxy->force_stop = false;
|
||||
$this->server->save();
|
||||
|
||||
$activity = StartProxy::run($this->server, force: true, restarting: true);
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
|
||||
// Check Traefik version after restart to provide immediate feedback
|
||||
if ($this->server->proxyType() === ProxyTypes::TRAEFIK->value) {
|
||||
CheckTraefikVersionForServerJob::dispatch($this->server, get_traefik_versions());
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -118,19 +130,25 @@ public function checkProxyStatus()
|
|||
|
||||
public function showNotification()
|
||||
{
|
||||
$previousStatus = $this->proxyStatus;
|
||||
$this->server->refresh();
|
||||
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
|
||||
|
||||
switch ($this->proxyStatus) {
|
||||
case 'running':
|
||||
$this->loadProxyConfiguration();
|
||||
$this->dispatch('success', 'Proxy is running.');
|
||||
break;
|
||||
case 'restarting':
|
||||
$this->dispatch('info', 'Initiating proxy restart.');
|
||||
// Only show "Proxy is running" notification when transitioning from a stopped/error state
|
||||
// Don't show during normal start/restart flows (starting, restarting, stopping)
|
||||
if (in_array($previousStatus, ['exited', 'stopped', 'unknown', null])) {
|
||||
$this->dispatch('success', 'Proxy is running.');
|
||||
}
|
||||
break;
|
||||
case 'exited':
|
||||
$this->dispatch('info', 'Proxy has exited.');
|
||||
// Only show "Proxy has exited" notification when transitioning from running state
|
||||
// Don't show during normal stop/restart flows (stopping, restarting)
|
||||
if (in_array($previousStatus, ['running'])) {
|
||||
$this->dispatch('info', 'Proxy has exited.');
|
||||
}
|
||||
break;
|
||||
case 'stopping':
|
||||
$this->dispatch('info', 'Proxy is stopping.');
|
||||
|
|
@ -154,6 +172,22 @@ public function refreshServer()
|
|||
$this->server->load('settings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Traefik has any outdated version info (patch or minor upgrade).
|
||||
* This shows a warning indicator in the navbar.
|
||||
*/
|
||||
public function getHasTraefikOutdatedProperty(): bool
|
||||
{
|
||||
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if server has outdated info stored
|
||||
$outdatedInfo = $this->server->traefik_outdated_info;
|
||||
|
||||
return ! empty($outdatedInfo) && isset($outdatedInfo['type']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.navbar');
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\Proxy\GetProxyConfiguration;
|
||||
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
|
@ -24,6 +25,12 @@ class Proxy extends Component
|
|||
|
||||
public bool $generateExactLabels = false;
|
||||
|
||||
/**
|
||||
* Cache the versions.json file data in memory for this component instance.
|
||||
* This avoids multiple file reads during a single request/render cycle.
|
||||
*/
|
||||
protected ?array $cachedVersionsFile = null;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
|
@ -55,9 +62,37 @@ private function syncData(bool $toModel = false): void
|
|||
}
|
||||
}
|
||||
|
||||
public function getConfigurationFilePathProperty()
|
||||
/**
|
||||
* Get Traefik versions from cached data with in-memory optimization.
|
||||
* Returns array like: ['v3.5' => '3.5.6', 'v3.6' => '3.6.2']
|
||||
*
|
||||
* This method adds an in-memory cache layer on top of the global
|
||||
* get_traefik_versions() helper to avoid multiple calls during
|
||||
* a single component lifecycle/render.
|
||||
*/
|
||||
protected function getTraefikVersions(): ?array
|
||||
{
|
||||
return $this->server->proxyPath().'docker-compose.yml';
|
||||
// In-memory cache for this component instance (per-request)
|
||||
if ($this->cachedVersionsFile !== null) {
|
||||
return data_get($this->cachedVersionsFile, 'traefik');
|
||||
}
|
||||
|
||||
// Load from global cached helper (Redis + filesystem)
|
||||
$versionsData = get_versions_data();
|
||||
$this->cachedVersionsFile = $versionsData;
|
||||
|
||||
if (! $versionsData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$traefikVersions = data_get($versionsData, 'traefik');
|
||||
|
||||
return is_array($traefikVersions) ? $traefikVersions : null;
|
||||
}
|
||||
|
||||
public function getConfigurationFilePathProperty(): string
|
||||
{
|
||||
return rtrim($this->server->proxyPath(), '/') . '/docker-compose.yml';
|
||||
}
|
||||
|
||||
public function changeProxy()
|
||||
|
|
@ -144,4 +179,131 @@ public function loadProxyConfiguration()
|
|||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest Traefik version for this server's current branch.
|
||||
*
|
||||
* This compares the server's detected version against available versions
|
||||
* in versions.json to determine the latest patch for the current branch,
|
||||
* or the newest available version if no current version is detected.
|
||||
*/
|
||||
public function getLatestTraefikVersionProperty(): ?string
|
||||
{
|
||||
try {
|
||||
$traefikVersions = $this->getTraefikVersions();
|
||||
|
||||
if (! $traefikVersions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get this server's current version
|
||||
$currentVersion = $this->server->detected_traefik_version;
|
||||
|
||||
// If we have a current version, try to find matching branch
|
||||
if ($currentVersion && $currentVersion !== 'latest') {
|
||||
$current = ltrim($currentVersion, 'v');
|
||||
if (preg_match('/^(\d+\.\d+)/', $current, $matches)) {
|
||||
$branch = "v{$matches[1]}";
|
||||
if (isset($traefikVersions[$branch])) {
|
||||
$version = $traefikVersions[$branch];
|
||||
|
||||
return str_starts_with($version, 'v') ? $version : "v{$version}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the newest available version
|
||||
$newestVersion = collect($traefikVersions)
|
||||
->map(fn ($v) => ltrim($v, 'v'))
|
||||
->sortBy(fn ($v) => $v, SORT_NATURAL)
|
||||
->last();
|
||||
|
||||
return $newestVersion ? "v{$newestVersion}" : null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getIsTraefikOutdatedProperty(): bool
|
||||
{
|
||||
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentVersion = $this->server->detected_traefik_version;
|
||||
if (! $currentVersion || $currentVersion === 'latest') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$latestVersion = $this->latestTraefikVersion;
|
||||
if (! $latestVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare versions (strip 'v' prefix)
|
||||
$current = ltrim($currentVersion, 'v');
|
||||
$latest = ltrim($latestVersion, 'v');
|
||||
|
||||
return version_compare($current, $latest, '<');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a newer Traefik branch (minor version) is available for this server.
|
||||
* Returns the branch identifier (e.g., "v3.6") if a newer branch exists.
|
||||
*/
|
||||
public function getNewerTraefikBranchAvailableProperty(): ?string
|
||||
{
|
||||
try {
|
||||
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get this server's current version
|
||||
$currentVersion = $this->server->detected_traefik_version;
|
||||
if (! $currentVersion || $currentVersion === 'latest') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we have outdated info stored for this server (faster than computing)
|
||||
$outdatedInfo = $this->server->traefik_outdated_info;
|
||||
if ($outdatedInfo && isset($outdatedInfo['type']) && $outdatedInfo['type'] === 'minor_upgrade') {
|
||||
// Use the upgrade_target field if available (e.g., "v3.6")
|
||||
if (isset($outdatedInfo['upgrade_target'])) {
|
||||
return str_starts_with($outdatedInfo['upgrade_target'], 'v')
|
||||
? $outdatedInfo['upgrade_target']
|
||||
: "v{$outdatedInfo['upgrade_target']}";
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: compute from cached versions data
|
||||
$traefikVersions = $this->getTraefikVersions();
|
||||
|
||||
if (! $traefikVersions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract current branch (e.g., "3.5" from "3.5.6")
|
||||
$current = ltrim($currentVersion, 'v');
|
||||
if (! preg_match('/^(\d+\.\d+)/', $current, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currentBranch = $matches[1];
|
||||
|
||||
// Find the newest branch that's greater than current
|
||||
$newestBranch = null;
|
||||
foreach ($traefikVersions as $branch => $version) {
|
||||
$branchNum = ltrim($branch, 'v');
|
||||
if (version_compare($branchNum, $currentBranch, '>')) {
|
||||
if (! $newestBranch || version_compare($branchNum, $newestBranch, '>')) {
|
||||
$newestBranch = $branchNum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $newestBranch ? "v{$newestBranch}" : null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,13 +25,25 @@ public function delete(string $fileName)
|
|||
$this->authorize('update', $this->server);
|
||||
$proxy_path = $this->server->proxyPath();
|
||||
$proxy_type = $this->server->proxyType();
|
||||
|
||||
// Decode filename: pipes are used to encode dots for Livewire property binding
|
||||
// (e.g., 'my|service.yaml' -> 'my.service.yaml')
|
||||
// This must happen BEFORE validation because validateShellSafePath() correctly
|
||||
// rejects pipe characters as dangerous shell metacharacters
|
||||
$file = str_replace('|', '.', $fileName);
|
||||
|
||||
// Validate filename to prevent command injection
|
||||
validateShellSafePath($file, 'proxy configuration filename');
|
||||
|
||||
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
|
||||
$this->dispatch('error', 'Cannot delete Caddyfile.');
|
||||
|
||||
return;
|
||||
}
|
||||
instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $this->server);
|
||||
|
||||
$fullPath = "{$proxy_path}/dynamic/{$file}";
|
||||
$escapedPath = escapeshellarg($fullPath);
|
||||
instant_remote_process(["rm -f {$escapedPath}"], $this->server);
|
||||
if ($proxy_type === 'CADDY') {
|
||||
$this->server->reloadCaddy();
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue