v4.0.0-beta.445 (#7257)
This commit is contained in:
commit
006c4e5ba4
177 changed files with 12217 additions and 3261 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
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
---
|
||||
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
|
||||
|
|
@ -308,3 +303,286 @@ ### External Services
|
|||
- **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` (line 717)
|
||||
- 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` (line 718)
|
||||
- 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,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,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.
|
||||
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,6 +8,8 @@
|
|||
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;
|
||||
|
|
@ -16,6 +18,7 @@
|
|||
class GetContainersStatus
|
||||
{
|
||||
use AsAction;
|
||||
use CalculatesExcludedStatus;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
|
|
@ -31,6 +34,8 @@ class GetContainersStatus
|
|||
|
||||
protected ?Collection $applicationContainerRestartCounts;
|
||||
|
||||
protected ?Collection $serviceContainerStatuses;
|
||||
|
||||
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
||||
{
|
||||
$this->containers = $containers;
|
||||
|
|
@ -98,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');
|
||||
|
|
@ -222,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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -314,7 +334,7 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
|
||||
if ($recentlyRestarted) {
|
||||
// Keep it as degraded if it was recently in a crash loop
|
||||
$application->update(['status' => 'degraded (unhealthy)']);
|
||||
$application->update(['status' => 'degraded:unhealthy']);
|
||||
} else {
|
||||
// Reset restart count when application exits completely
|
||||
$application->update([
|
||||
|
|
@ -418,6 +438,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
}
|
||||
}
|
||||
|
||||
// Aggregate multi-container service statuses
|
||||
$this->aggregateServiceContainerStatuses($services);
|
||||
|
||||
ServiceChecked::dispatch($this->server->team->id);
|
||||
}
|
||||
|
||||
|
|
@ -425,74 +448,88 @@ private function aggregateApplicationStatus($application, Collection $containerS
|
|||
{
|
||||
// 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 container is exited but has restart count > 0, it's in a crash loop
|
||||
if ($hasExited && $maxRestartCount > 0) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning) {
|
||||
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
}
|
||||
|
||||
// All containers are exited with no restart count - truly stopped
|
||||
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,7 +63,16 @@ 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',
|
||||
"echo 'Starting 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 --time=$timeout $containerName",
|
||||
"docker rm -f $containerName",
|
||||
"docker stop --time=$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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
{
|
||||
use Dispatchable, EnvironmentVariableAnalyzer, ExecuteRemoteCommand, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env';
|
||||
|
||||
private const BUILD_SCRIPT_PATH = '/artifacts/build.sh';
|
||||
|
||||
private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json';
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
public $timeout = 3600;
|
||||
|
|
@ -652,11 +658,27 @@ private function deploy_docker_compose_buildpack()
|
|||
$this->save_buildtime_environment_variables();
|
||||
|
||||
if ($this->docker_compose_custom_build_command) {
|
||||
// Auto-inject -f (compose file) and --env-file flags using helper function
|
||||
$build_command = injectDockerComposeFlags(
|
||||
$this->docker_compose_custom_build_command,
|
||||
"{$this->workdir}{$this->docker_compose_location}",
|
||||
self::BUILD_TIME_ENV_PATH
|
||||
);
|
||||
|
||||
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
|
||||
$build_command = $this->docker_compose_custom_build_command;
|
||||
if ($this->dockerBuildkitSupported) {
|
||||
$build_command = "DOCKER_BUILDKIT=1 {$build_command}";
|
||||
}
|
||||
|
||||
// Append build arguments if not using build secrets (matching default behavior)
|
||||
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
|
||||
$build_args_string = $this->build_args->implode(' ');
|
||||
// Escape single quotes for bash -c context used by executeInDocker
|
||||
$build_args_string = str_replace("'", "'\\''", $build_args_string);
|
||||
$build_command .= " {$build_args_string}";
|
||||
$this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.');
|
||||
}
|
||||
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
|
||||
);
|
||||
|
|
@ -667,7 +689,7 @@ private function deploy_docker_compose_buildpack()
|
|||
$command = "DOCKER_BUILDKIT=1 {$command}";
|
||||
}
|
||||
// Use build-time .env file from /artifacts (outside Docker context to prevent it from being in the image)
|
||||
$command .= ' --env-file /artifacts/build-time.env';
|
||||
$command .= ' --env-file '.self::BUILD_TIME_ENV_PATH;
|
||||
if ($this->force_rebuild) {
|
||||
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache";
|
||||
} else {
|
||||
|
|
@ -715,9 +737,16 @@ private function deploy_docker_compose_buildpack()
|
|||
$server_workdir = $this->application->workdir();
|
||||
if ($this->application->settings->is_raw_compose_deployment_enabled) {
|
||||
if ($this->docker_compose_custom_start_command) {
|
||||
// Auto-inject -f (compose file) and --env-file flags using helper function
|
||||
$start_command = injectDockerComposeFlags(
|
||||
$this->docker_compose_custom_start_command,
|
||||
"{$server_workdir}{$this->docker_compose_location}",
|
||||
"{$server_workdir}/.env"
|
||||
);
|
||||
|
||||
$this->write_deployment_configurations();
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$this->docker_compose_custom_start_command}"), 'hidden' => true],
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
|
||||
);
|
||||
} else {
|
||||
$this->write_deployment_configurations();
|
||||
|
|
@ -733,9 +762,18 @@ private function deploy_docker_compose_buildpack()
|
|||
}
|
||||
} else {
|
||||
if ($this->docker_compose_custom_start_command) {
|
||||
// Auto-inject -f (compose file) and --env-file flags using helper function
|
||||
// Use $this->workdir for non-preserve-repository mode
|
||||
$workdir_path = $this->preserveRepository ? $server_workdir : $this->workdir;
|
||||
$start_command = injectDockerComposeFlags(
|
||||
$this->docker_compose_custom_start_command,
|
||||
"{$workdir_path}{$this->docker_compose_location}",
|
||||
"{$workdir_path}/.env"
|
||||
);
|
||||
|
||||
$this->write_deployment_configurations();
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$this->docker_compose_custom_start_command}"), 'hidden' => true],
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true],
|
||||
);
|
||||
} else {
|
||||
$command = "{$this->coolify_variables} docker compose";
|
||||
|
|
@ -976,7 +1014,7 @@ private function push_to_docker_registry()
|
|||
} catch (Exception $e) {
|
||||
$this->application_deployment_queue->addLogEntry('Failed to push image to docker registry. Please check debug logs for more information.');
|
||||
if ($forceFail) {
|
||||
throw new DeploymentException($e->getMessage(), 69420);
|
||||
throw new DeploymentException(get_class($e).': '.$e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1534,10 +1572,10 @@ private function save_buildtime_environment_variables()
|
|||
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee /artifacts/build-time.env > /dev/null"),
|
||||
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'),
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'cat /artifacts/build-time.env'),
|
||||
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH),
|
||||
'hidden' => true,
|
||||
],
|
||||
);
|
||||
|
|
@ -1548,7 +1586,7 @@ private function save_buildtime_environment_variables()
|
|||
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'touch /artifacts/build-time.env'),
|
||||
executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -1610,123 +1648,131 @@ private function laravel_finetunes()
|
|||
|
||||
private function rolling_update()
|
||||
{
|
||||
$this->checkForCancellation();
|
||||
if ($this->server->isSwarm()) {
|
||||
$this->application_deployment_queue->addLogEntry('Rolling update started.');
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "docker stack deploy --detach=true --with-registry-auth -c {$this->workdir}{$this->docker_compose_location} {$this->application->uuid}"),
|
||||
],
|
||||
);
|
||||
$this->application_deployment_queue->addLogEntry('Rolling update completed.');
|
||||
} else {
|
||||
if ($this->use_build_server) {
|
||||
$this->write_deployment_configurations();
|
||||
$this->server = $this->original_server;
|
||||
}
|
||||
if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
if (count($this->application->ports_mappings_array) > 0) {
|
||||
$this->application_deployment_queue->addLogEntry('Application has ports mapped to the host system, rolling update is not supported.');
|
||||
}
|
||||
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
|
||||
$this->application_deployment_queue->addLogEntry('Consistent container name feature enabled, rolling update is not supported.');
|
||||
}
|
||||
if (str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom internal name is set, rolling update is not supported.');
|
||||
}
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$this->application->settings->is_consistent_container_name_enabled = true;
|
||||
$this->application_deployment_queue->addLogEntry('Pull request deployment, rolling update is not supported.');
|
||||
}
|
||||
if (str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom IP address is set, rolling update is not supported.');
|
||||
}
|
||||
$this->stop_running_container(force: true);
|
||||
$this->start_by_compose_file();
|
||||
} else {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
try {
|
||||
$this->checkForCancellation();
|
||||
if ($this->server->isSwarm()) {
|
||||
$this->application_deployment_queue->addLogEntry('Rolling update started.');
|
||||
$this->start_by_compose_file();
|
||||
$this->health_check();
|
||||
$this->stop_running_container();
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "docker stack deploy --detach=true --with-registry-auth -c {$this->workdir}{$this->docker_compose_location} {$this->application->uuid}"),
|
||||
],
|
||||
);
|
||||
$this->application_deployment_queue->addLogEntry('Rolling update completed.');
|
||||
} else {
|
||||
if ($this->use_build_server) {
|
||||
$this->write_deployment_configurations();
|
||||
$this->server = $this->original_server;
|
||||
}
|
||||
if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
if (count($this->application->ports_mappings_array) > 0) {
|
||||
$this->application_deployment_queue->addLogEntry('Application has ports mapped to the host system, rolling update is not supported.');
|
||||
}
|
||||
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
|
||||
$this->application_deployment_queue->addLogEntry('Consistent container name feature enabled, rolling update is not supported.');
|
||||
}
|
||||
if (str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom internal name is set, rolling update is not supported.');
|
||||
}
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$this->application->settings->is_consistent_container_name_enabled = true;
|
||||
$this->application_deployment_queue->addLogEntry('Pull request deployment, rolling update is not supported.');
|
||||
}
|
||||
if (str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom IP address is set, rolling update is not supported.');
|
||||
}
|
||||
$this->stop_running_container(force: true);
|
||||
$this->start_by_compose_file();
|
||||
} else {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
$this->application_deployment_queue->addLogEntry('Rolling update started.');
|
||||
$this->start_by_compose_file();
|
||||
$this->health_check();
|
||||
$this->stop_running_container();
|
||||
$this->application_deployment_queue->addLogEntry('Rolling update completed.');
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
throw new DeploymentException('Rolling update failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function health_check()
|
||||
{
|
||||
if ($this->server->isSwarm()) {
|
||||
// Implement healthcheck for swarm
|
||||
} else {
|
||||
if ($this->application->isHealthcheckDisabled() && $this->application->custom_healthcheck_found === false) {
|
||||
$this->newVersionIsHealthy = true;
|
||||
try {
|
||||
if ($this->server->isSwarm()) {
|
||||
// Implement healthcheck for swarm
|
||||
} else {
|
||||
if ($this->application->isHealthcheckDisabled() && $this->application->custom_healthcheck_found === false) {
|
||||
$this->newVersionIsHealthy = true;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->application->custom_healthcheck_found) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom healthcheck found in Dockerfile.');
|
||||
}
|
||||
if ($this->container_name) {
|
||||
$counter = 1;
|
||||
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
|
||||
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
|
||||
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
|
||||
return;
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
|
||||
$sleeptime = 0;
|
||||
while ($sleeptime < $this->application->health_check_start_period) {
|
||||
Sleep::for(1)->seconds();
|
||||
$sleeptime++;
|
||||
if ($this->application->custom_healthcheck_found) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom healthcheck found in Dockerfile.');
|
||||
}
|
||||
while ($counter <= $this->application->health_check_retries) {
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
"docker inspect --format='{{json .State.Health.Status}}' {$this->container_name}",
|
||||
'hidden' => true,
|
||||
'save' => 'health_check',
|
||||
'append' => false,
|
||||
],
|
||||
[
|
||||
"docker inspect --format='{{json .State.Health.Log}}' {$this->container_name}",
|
||||
'hidden' => true,
|
||||
'save' => 'health_check_logs',
|
||||
'append' => false,
|
||||
],
|
||||
);
|
||||
$this->application_deployment_queue->addLogEntry("Attempt {$counter} of {$this->application->health_check_retries} | Healthcheck status: {$this->saved_outputs->get('health_check')}");
|
||||
$health_check_logs = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'Output', '(no logs)');
|
||||
if (empty($health_check_logs)) {
|
||||
$health_check_logs = '(no logs)';
|
||||
if ($this->container_name) {
|
||||
$counter = 1;
|
||||
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
|
||||
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
|
||||
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
|
||||
}
|
||||
$health_check_return_code = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'ExitCode', '(no return code)');
|
||||
if ($health_check_logs !== '(no logs)' || $health_check_return_code !== '(no return code)') {
|
||||
$this->application_deployment_queue->addLogEntry("Healthcheck logs: {$health_check_logs} | Return code: {$health_check_return_code}");
|
||||
}
|
||||
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'healthy') {
|
||||
$this->newVersionIsHealthy = true;
|
||||
$this->application->update(['status' => 'running']);
|
||||
$this->application_deployment_queue->addLogEntry('New container is healthy.');
|
||||
break;
|
||||
}
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
|
||||
$this->newVersionIsHealthy = false;
|
||||
$this->query_logs();
|
||||
break;
|
||||
}
|
||||
$counter++;
|
||||
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
|
||||
$sleeptime = 0;
|
||||
while ($sleeptime < $this->application->health_check_interval) {
|
||||
while ($sleeptime < $this->application->health_check_start_period) {
|
||||
Sleep::for(1)->seconds();
|
||||
$sleeptime++;
|
||||
}
|
||||
}
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'starting') {
|
||||
$this->query_logs();
|
||||
while ($counter <= $this->application->health_check_retries) {
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
"docker inspect --format='{{json .State.Health.Status}}' {$this->container_name}",
|
||||
'hidden' => true,
|
||||
'save' => 'health_check',
|
||||
'append' => false,
|
||||
],
|
||||
[
|
||||
"docker inspect --format='{{json .State.Health.Log}}' {$this->container_name}",
|
||||
'hidden' => true,
|
||||
'save' => 'health_check_logs',
|
||||
'append' => false,
|
||||
],
|
||||
);
|
||||
$this->application_deployment_queue->addLogEntry("Attempt {$counter} of {$this->application->health_check_retries} | Healthcheck status: {$this->saved_outputs->get('health_check')}");
|
||||
$health_check_logs = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'Output', '(no logs)');
|
||||
if (empty($health_check_logs)) {
|
||||
$health_check_logs = '(no logs)';
|
||||
}
|
||||
$health_check_return_code = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'ExitCode', '(no return code)');
|
||||
if ($health_check_logs !== '(no logs)' || $health_check_return_code !== '(no return code)') {
|
||||
$this->application_deployment_queue->addLogEntry("Healthcheck logs: {$health_check_logs} | Return code: {$health_check_return_code}");
|
||||
}
|
||||
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'healthy') {
|
||||
$this->newVersionIsHealthy = true;
|
||||
$this->application->update(['status' => 'running']);
|
||||
$this->application_deployment_queue->addLogEntry('New container is healthy.');
|
||||
break;
|
||||
}
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
|
||||
$this->newVersionIsHealthy = false;
|
||||
$this->query_logs();
|
||||
break;
|
||||
}
|
||||
$counter++;
|
||||
$sleeptime = 0;
|
||||
while ($sleeptime < $this->application->health_check_interval) {
|
||||
Sleep::for(1)->seconds();
|
||||
$sleeptime++;
|
||||
}
|
||||
}
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'starting') {
|
||||
$this->query_logs();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
throw new DeploymentException('Health check failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2666,15 +2712,15 @@ private function build_static_image()
|
|||
executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"),
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
]
|
||||
);
|
||||
|
|
@ -2682,7 +2728,7 @@ private function build_static_image()
|
|||
}
|
||||
|
||||
/**
|
||||
* Wrap a docker build command with environment export from /artifacts/build-time.env
|
||||
* Wrap a docker build command with environment export from build-time .env file
|
||||
* This enables shell interpolation of variables (e.g., APP_URL=$COOLIFY_URL)
|
||||
*
|
||||
* @param string $build_command The docker build command to wrap
|
||||
|
|
@ -2690,7 +2736,7 @@ private function build_static_image()
|
|||
*/
|
||||
private function wrap_build_command_with_env_export(string $build_command): string
|
||||
{
|
||||
return "cd {$this->workdir} && set -a && source /artifacts/build-time.env && set +a && {$build_command}";
|
||||
return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}";
|
||||
}
|
||||
|
||||
private function build_image()
|
||||
|
|
@ -2729,10 +2775,10 @@ private function build_image()
|
|||
}
|
||||
if ($this->application->build_pack === 'nixpacks') {
|
||||
$this->nixpacks_plan = base64_encode($this->nixpacks_plan);
|
||||
$this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]);
|
||||
$this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true]);
|
||||
if ($this->force_rebuild) {
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
|
||||
executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
|
||||
'hidden' => true,
|
||||
], [
|
||||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
||||
|
|
@ -2752,7 +2798,7 @@ private function build_image()
|
|||
}
|
||||
} else {
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
|
||||
executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
|
||||
'hidden' => true,
|
||||
], [
|
||||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
||||
|
|
@ -2776,19 +2822,19 @@ private function build_image()
|
|||
$base64_build_command = base64_encode($build_command);
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
]
|
||||
);
|
||||
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
|
||||
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
|
||||
} else {
|
||||
// Dockerfile buildpack
|
||||
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
|
||||
|
|
@ -2820,15 +2866,15 @@ private function build_image()
|
|||
$base64_build_command = base64_encode($build_command);
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
]
|
||||
);
|
||||
|
|
@ -2859,15 +2905,15 @@ private function build_image()
|
|||
executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"),
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
]
|
||||
);
|
||||
|
|
@ -2894,25 +2940,25 @@ private function build_image()
|
|||
$base64_build_command = base64_encode($build_command);
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
if ($this->application->build_pack === 'nixpacks') {
|
||||
$this->nixpacks_plan = base64_encode($this->nixpacks_plan);
|
||||
$this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]);
|
||||
$this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true]);
|
||||
if ($this->force_rebuild) {
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
|
||||
executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
|
||||
'hidden' => true,
|
||||
], [
|
||||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
||||
|
|
@ -2933,7 +2979,7 @@ private function build_image()
|
|||
}
|
||||
} else {
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
|
||||
executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
|
||||
'hidden' => true,
|
||||
], [
|
||||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
||||
|
|
@ -2956,19 +3002,19 @@ private function build_image()
|
|||
$base64_build_command = base64_encode($build_command);
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
]
|
||||
);
|
||||
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
|
||||
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
|
||||
} else {
|
||||
// Dockerfile buildpack
|
||||
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
|
||||
|
|
@ -3001,15 +3047,15 @@ private function build_image()
|
|||
$base64_build_command = base64_encode($build_command);
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
|
||||
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
|
||||
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
||||
'hidden' => true,
|
||||
]
|
||||
);
|
||||
|
|
@ -3034,58 +3080,66 @@ private function graceful_shutdown_container(string $containerName)
|
|||
|
||||
private function stop_running_container(bool $force = false)
|
||||
{
|
||||
$this->application_deployment_queue->addLogEntry('Removing old containers.');
|
||||
if ($this->newVersionIsHealthy || $force) {
|
||||
if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
||||
$this->graceful_shutdown_container($this->container_name);
|
||||
} else {
|
||||
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
|
||||
if ($this->pull_request_id === 0) {
|
||||
$containers = $containers->filter(function ($container) {
|
||||
return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id);
|
||||
try {
|
||||
$this->application_deployment_queue->addLogEntry('Removing old containers.');
|
||||
if ($this->newVersionIsHealthy || $force) {
|
||||
if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
||||
$this->graceful_shutdown_container($this->container_name);
|
||||
} else {
|
||||
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
|
||||
if ($this->pull_request_id === 0) {
|
||||
$containers = $containers->filter(function ($container) {
|
||||
return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id);
|
||||
});
|
||||
}
|
||||
$containers->each(function ($container) {
|
||||
$this->graceful_shutdown_container(data_get($container, 'Names'));
|
||||
});
|
||||
}
|
||||
$containers->each(function ($container) {
|
||||
$this->graceful_shutdown_container(data_get($container, 'Names'));
|
||||
});
|
||||
} else {
|
||||
if ($this->application->dockerfile || $this->application->build_pack === 'dockerfile' || $this->application->build_pack === 'dockerimage') {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
$this->application_deployment_queue->addLogEntry("WARNING: Dockerfile or Docker Image based deployment detected. The healthcheck needs a curl or wget command to check the health of the application. Please make sure that it is available in the image or turn off healthcheck on Coolify's UI.");
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry('New container is not healthy, rolling back to the old container.');
|
||||
$this->failDeployment();
|
||||
$this->graceful_shutdown_container($this->container_name);
|
||||
}
|
||||
} else {
|
||||
if ($this->application->dockerfile || $this->application->build_pack === 'dockerfile' || $this->application->build_pack === 'dockerimage') {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
$this->application_deployment_queue->addLogEntry("WARNING: Dockerfile or Docker Image based deployment detected. The healthcheck needs a curl or wget command to check the health of the application. Please make sure that it is available in the image or turn off healthcheck on Coolify's UI.");
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry('New container is not healthy, rolling back to the old container.');
|
||||
$this->failDeployment();
|
||||
$this->graceful_shutdown_container($this->container_name);
|
||||
} catch (Exception $e) {
|
||||
throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function start_by_compose_file()
|
||||
{
|
||||
// Ensure .env file exists before docker compose tries to load it (defensive programming)
|
||||
$this->execute_remote_command(
|
||||
["touch {$this->configuration_dir}/.env", 'hidden' => true],
|
||||
);
|
||||
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
|
||||
try {
|
||||
// Ensure .env file exists before docker compose tries to load it (defensive programming)
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} pull"), 'hidden' => true],
|
||||
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} up --build -d"), 'hidden' => true],
|
||||
["touch {$this->configuration_dir}/.env", 'hidden' => true],
|
||||
);
|
||||
} else {
|
||||
if ($this->use_build_server) {
|
||||
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
|
||||
$this->execute_remote_command(
|
||||
["{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true],
|
||||
[executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} pull"), 'hidden' => true],
|
||||
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} up --build -d"), 'hidden' => true],
|
||||
);
|
||||
} else {
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up --build -d"), 'hidden' => true],
|
||||
);
|
||||
if ($this->use_build_server) {
|
||||
$this->execute_remote_command(
|
||||
["{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true],
|
||||
);
|
||||
} else {
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up --build -d"), 'hidden' => true],
|
||||
);
|
||||
}
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry('New container started.');
|
||||
} catch (Exception $e) {
|
||||
throw new DeploymentException("Failed to start container: {$e->getMessage()}", $e->getCode(), $e);
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry('New container started.');
|
||||
}
|
||||
|
||||
private function analyzeBuildTimeVariables($variables)
|
||||
|
|
@ -3829,7 +3883,7 @@ private function completeDeployment(): void
|
|||
* Fail the deployment.
|
||||
* Sends failure notification and queues next deployment.
|
||||
*/
|
||||
private function failDeployment(): void
|
||||
protected function failDeployment(): void
|
||||
{
|
||||
$this->transitionToStatus(ApplicationDeploymentStatus::FAILED);
|
||||
}
|
||||
|
|
@ -3837,8 +3891,38 @@ private function failDeployment(): void
|
|||
public function failed(Throwable $exception): void
|
||||
{
|
||||
$this->failDeployment();
|
||||
|
||||
// Log comprehensive error information
|
||||
$errorMessage = $exception->getMessage() ?: 'Unknown error occurred';
|
||||
$errorCode = $exception->getCode();
|
||||
$errorClass = get_class($exception);
|
||||
|
||||
$this->application_deployment_queue->addLogEntry('========================================', 'stderr');
|
||||
$this->application_deployment_queue->addLogEntry("Deployment failed: {$errorMessage}", 'stderr');
|
||||
$this->application_deployment_queue->addLogEntry("Error type: {$errorClass}", 'stderr', hidden: true);
|
||||
$this->application_deployment_queue->addLogEntry("Error code: {$errorCode}", 'stderr', hidden: true);
|
||||
|
||||
// Log the exception file and line for debugging
|
||||
$this->application_deployment_queue->addLogEntry("Location: {$exception->getFile()}:{$exception->getLine()}", 'stderr', hidden: true);
|
||||
|
||||
// Log previous exceptions if they exist (for chained exceptions)
|
||||
$previous = $exception->getPrevious();
|
||||
if ($previous) {
|
||||
$this->application_deployment_queue->addLogEntry('Caused by:', 'stderr', hidden: true);
|
||||
$previousMessage = $previous->getMessage() ?: 'No message';
|
||||
$previousClass = get_class($previous);
|
||||
$this->application_deployment_queue->addLogEntry(" {$previousClass}: {$previousMessage}", 'stderr', hidden: true);
|
||||
$this->application_deployment_queue->addLogEntry(" at {$previous->getFile()}:{$previous->getLine()}", 'stderr', hidden: true);
|
||||
}
|
||||
|
||||
// Log first few lines of stack trace for debugging
|
||||
$trace = $exception->getTraceAsString();
|
||||
$traceLines = explode("\n", $trace);
|
||||
$this->application_deployment_queue->addLogEntry('Stack trace (first 5 lines):', 'stderr', hidden: true);
|
||||
foreach (array_slice($traceLines, 0, 5) as $traceLine) {
|
||||
$this->application_deployment_queue->addLogEntry(" {$traceLine}", 'stderr', hidden: true);
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry('========================================', 'stderr');
|
||||
|
||||
if ($this->application->build_pack !== 'dockercompose') {
|
||||
$code = $exception->getCode();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -1019,4 +1005,41 @@ public function getDetectedPortInfoProperty(): ?array
|
|||
'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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +62,34 @@ private function syncData(bool $toModel = false): void
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
// 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()
|
||||
{
|
||||
return $this->server->proxyPath().'docker-compose.yml';
|
||||
|
|
@ -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,6 +25,8 @@ class ValidateAndInstall extends Component
|
|||
|
||||
public $supported_os_type = null;
|
||||
|
||||
public $prerequisites_installed = null;
|
||||
|
||||
public $docker_installed = null;
|
||||
|
||||
public $docker_compose_installed = null;
|
||||
|
|
@ -33,12 +35,15 @@ class ValidateAndInstall extends Component
|
|||
|
||||
public $error = null;
|
||||
|
||||
public string $installationStep = 'Prerequisites';
|
||||
|
||||
public bool $ask = false;
|
||||
|
||||
protected $listeners = [
|
||||
'init',
|
||||
'validateConnection',
|
||||
'validateOS',
|
||||
'validatePrerequisites',
|
||||
'validateDockerEngine',
|
||||
'validateDockerVersion',
|
||||
'refresh' => '$refresh',
|
||||
|
|
@ -48,6 +53,7 @@ public function init(int $data = 0)
|
|||
{
|
||||
$this->uptime = null;
|
||||
$this->supported_os_type = null;
|
||||
$this->prerequisites_installed = null;
|
||||
$this->docker_installed = null;
|
||||
$this->docker_version = null;
|
||||
$this->docker_compose_installed = null;
|
||||
|
|
@ -69,6 +75,7 @@ public function retry()
|
|||
$this->authorize('update', $this->server);
|
||||
$this->uptime = null;
|
||||
$this->supported_os_type = null;
|
||||
$this->prerequisites_installed = null;
|
||||
$this->docker_installed = null;
|
||||
$this->docker_compose_installed = null;
|
||||
$this->docker_version = null;
|
||||
|
|
@ -103,6 +110,43 @@ public function validateOS()
|
|||
|
||||
return;
|
||||
}
|
||||
$this->dispatch('validatePrerequisites');
|
||||
}
|
||||
|
||||
public function validatePrerequisites()
|
||||
{
|
||||
$validationResult = $this->server->validatePrerequisites();
|
||||
$this->prerequisites_installed = $validationResult['success'];
|
||||
if (! $validationResult['success']) {
|
||||
if ($this->install) {
|
||||
if ($this->number_of_tries == $this->max_tries) {
|
||||
$missingCommands = implode(', ', $validationResult['missing']);
|
||||
$this->error = "Prerequisites ({$missingCommands}) could not be installed. Please install them manually before continuing.";
|
||||
$this->server->update([
|
||||
'validation_logs' => $this->error,
|
||||
]);
|
||||
|
||||
return;
|
||||
} else {
|
||||
if ($this->number_of_tries <= $this->max_tries) {
|
||||
$this->installationStep = 'Prerequisites';
|
||||
$activity = $this->server->installPrerequisites();
|
||||
$this->number_of_tries++;
|
||||
$this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries, "{$this->installationStep} Installation Logs");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
$missingCommands = implode(', ', $validationResult['missing']);
|
||||
$this->error = "Prerequisites ({$missingCommands}) are not installed. Please install them before continuing.";
|
||||
$this->server->update([
|
||||
'validation_logs' => $this->error,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
$this->dispatch('validateDockerEngine');
|
||||
}
|
||||
|
||||
|
|
@ -121,9 +165,10 @@ public function validateDockerEngine()
|
|||
return;
|
||||
} else {
|
||||
if ($this->number_of_tries <= $this->max_tries) {
|
||||
$this->installationStep = 'Docker';
|
||||
$activity = $this->server->installDocker();
|
||||
$this->number_of_tries++;
|
||||
$this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries);
|
||||
$this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries, "{$this->installationStep} Installation Logs");
|
||||
}
|
||||
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -176,6 +176,39 @@ protected static function booted()
|
|||
if (count($payload) > 0) {
|
||||
$application->forceFill($payload);
|
||||
}
|
||||
|
||||
// Buildpack switching cleanup logic
|
||||
if ($application->isDirty('build_pack')) {
|
||||
$originalBuildPack = $application->getOriginal('build_pack');
|
||||
|
||||
// Clear Docker Compose specific data when switching away from dockercompose
|
||||
if ($originalBuildPack === 'dockercompose') {
|
||||
$application->docker_compose_domains = null;
|
||||
$application->docker_compose_raw = null;
|
||||
|
||||
// Remove SERVICE_FQDN_* and SERVICE_URL_* environment variables
|
||||
$application->environment_variables()
|
||||
->where(function ($q) {
|
||||
$q->where('key', 'LIKE', 'SERVICE_FQDN_%')
|
||||
->orWhere('key', 'LIKE', 'SERVICE_URL_%');
|
||||
})
|
||||
->delete();
|
||||
$application->environment_variables_preview()
|
||||
->where(function ($q) {
|
||||
$q->where('key', 'LIKE', 'SERVICE_FQDN_%')
|
||||
->orWhere('key', 'LIKE', 'SERVICE_URL_%');
|
||||
})
|
||||
->delete();
|
||||
}
|
||||
|
||||
// Clear Dockerfile specific data when switching away from dockerfile
|
||||
if ($originalBuildPack === 'dockerfile') {
|
||||
$application->dockerfile = null;
|
||||
$application->dockerfile_location = null;
|
||||
$application->dockerfile_target_build = null;
|
||||
$application->custom_healthcheck_found = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
static::created(function ($application) {
|
||||
ApplicationSetting::create([
|
||||
|
|
@ -636,21 +669,23 @@ protected function serverStatus(): Attribute
|
|||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (! $this->relationLoaded('additional_servers') || $this->additional_servers->count() === 0) {
|
||||
return $this->destination?->server?->isFunctional() ?? false;
|
||||
// Check main server infrastructure health
|
||||
$main_server_functional = $this->destination?->server?->isFunctional() ?? false;
|
||||
|
||||
if (! $main_server_functional) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$additional_servers_status = $this->additional_servers->pluck('pivot.status');
|
||||
$main_server_status = $this->destination?->server?->isFunctional() ?? false;
|
||||
|
||||
foreach ($additional_servers_status as $status) {
|
||||
$server_status = str($status)->before(':')->value();
|
||||
if ($server_status !== 'running') {
|
||||
return false;
|
||||
// Check additional servers infrastructure health (not container status!)
|
||||
if ($this->relationLoaded('additional_servers') && $this->additional_servers->count() > 0) {
|
||||
foreach ($this->additional_servers as $server) {
|
||||
if (! $server->isFunctional()) {
|
||||
return false; // Real server infrastructure problem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $main_server_status;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class DiscordNotificationSettings extends Model
|
|||
'server_reachable_discord_notifications',
|
||||
'server_unreachable_discord_notifications',
|
||||
'server_patch_discord_notifications',
|
||||
'traefik_outdated_discord_notifications',
|
||||
'discord_ping_enabled',
|
||||
];
|
||||
|
||||
|
|
@ -48,6 +49,7 @@ class DiscordNotificationSettings extends Model
|
|||
'server_reachable_discord_notifications' => 'boolean',
|
||||
'server_unreachable_discord_notifications' => 'boolean',
|
||||
'server_patch_discord_notifications' => 'boolean',
|
||||
'traefik_outdated_discord_notifications' => 'boolean',
|
||||
'discord_ping_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class EmailNotificationSettings extends Model
|
|||
'scheduled_task_failure_email_notifications',
|
||||
'server_disk_usage_email_notifications',
|
||||
'server_patch_email_notifications',
|
||||
'traefik_outdated_email_notifications',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
@ -63,6 +64,7 @@ class EmailNotificationSettings extends Model
|
|||
'scheduled_task_failure_email_notifications' => 'boolean',
|
||||
'server_disk_usage_email_notifications' => 'boolean',
|
||||
'server_patch_email_notifications' => 'boolean',
|
||||
'traefik_outdated_email_notifications' => 'boolean',
|
||||
];
|
||||
|
||||
public function team()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Jobs\PullHelperImageJob;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Url\Url;
|
||||
|
|
@ -35,14 +34,6 @@ class InstanceSettings extends Model
|
|||
protected static function booted(): void
|
||||
{
|
||||
static::updated(function ($settings) {
|
||||
if ($settings->wasChanged('helper_version')) {
|
||||
Server::chunkById(100, function ($servers) {
|
||||
foreach ($servers as $server) {
|
||||
PullHelperImageJob::dispatch($server);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear trusted hosts cache when FQDN changes
|
||||
if ($settings->wasChanged('fqdn')) {
|
||||
\Cache::forget('instance_settings_fqdn_host');
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class PushoverNotificationSettings extends Model
|
|||
'server_reachable_pushover_notifications',
|
||||
'server_unreachable_pushover_notifications',
|
||||
'server_patch_pushover_notifications',
|
||||
'traefik_outdated_pushover_notifications',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
@ -49,6 +50,7 @@ class PushoverNotificationSettings extends Model
|
|||
'server_reachable_pushover_notifications' => 'boolean',
|
||||
'server_unreachable_pushover_notifications' => 'boolean',
|
||||
'server_patch_pushover_notifications' => 'boolean',
|
||||
'traefik_outdated_pushover_notifications' => 'boolean',
|
||||
];
|
||||
|
||||
public function team()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Actions\Server\InstallDocker;
|
||||
use App\Actions\Server\InstallPrerequisites;
|
||||
use App\Actions\Server\StartSentinel;
|
||||
use App\Actions\Server\ValidatePrerequisites;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Helpers\SslHelper;
|
||||
|
|
@ -31,6 +33,51 @@
|
|||
use Symfony\Component\Yaml\Yaml;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
/**
|
||||
* @property array{
|
||||
* current: string,
|
||||
* latest: string,
|
||||
* type: 'patch_update'|'minor_upgrade',
|
||||
* checked_at: string,
|
||||
* newer_branch_target?: string,
|
||||
* newer_branch_latest?: string,
|
||||
* upgrade_target?: string
|
||||
* }|null $traefik_outdated_info Traefik version tracking information.
|
||||
*
|
||||
* This JSON column stores information about outdated Traefik proxy versions on this server.
|
||||
* The structure varies depending on the type of update available:
|
||||
*
|
||||
* **For patch updates** (e.g., 3.5.0 → 3.5.2):
|
||||
* ```php
|
||||
* [
|
||||
* 'current' => '3.5.0', // Current version (without 'v' prefix)
|
||||
* 'latest' => '3.5.2', // Latest patch version available
|
||||
* 'type' => 'patch_update', // Update type identifier
|
||||
* 'checked_at' => '2025-11-14T10:00:00Z', // ISO8601 timestamp
|
||||
* 'newer_branch_target' => 'v3.6', // (Optional) Available major/minor version
|
||||
* 'newer_branch_latest' => '3.6.2' // (Optional) Latest version in that branch
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* **For minor/major upgrades** (e.g., 3.5.6 → 3.6.2):
|
||||
* ```php
|
||||
* [
|
||||
* 'current' => '3.5.6', // Current version
|
||||
* 'latest' => '3.6.2', // Latest version in target branch
|
||||
* 'type' => 'minor_upgrade', // Update type identifier
|
||||
* 'upgrade_target' => 'v3.6', // Target branch (with 'v' prefix)
|
||||
* 'checked_at' => '2025-11-14T10:00:00Z' // ISO8601 timestamp
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* **Null value**: Set to null when:
|
||||
* - Server is fully up-to-date with the latest version
|
||||
* - Traefik image uses the 'latest' tag (no fixed version tracking)
|
||||
* - No Traefik version detected on the server
|
||||
*
|
||||
* @see \App\Jobs\CheckTraefikVersionForServerJob Where this data is populated
|
||||
* @see \App\Livewire\Server\Proxy Where this data is read and displayed
|
||||
*/
|
||||
#[OA\Schema(
|
||||
description: 'Server model',
|
||||
type: 'object',
|
||||
|
|
@ -142,6 +189,7 @@ protected static function booted()
|
|||
|
||||
protected $casts = [
|
||||
'proxy' => SchemalessAttributes::class,
|
||||
'traefik_outdated_info' => 'array',
|
||||
'logdrain_axiom_api_key' => 'encrypted',
|
||||
'logdrain_newrelic_license_key' => 'encrypted',
|
||||
'delete_unused_volumes' => 'boolean',
|
||||
|
|
@ -167,6 +215,8 @@ protected static function booted()
|
|||
'hetzner_server_id',
|
||||
'hetzner_server_status',
|
||||
'is_validating',
|
||||
'detected_traefik_version',
|
||||
'traefik_outdated_info',
|
||||
];
|
||||
|
||||
protected $guarded = [];
|
||||
|
|
@ -522,6 +572,11 @@ public function scopeWithProxy(): Builder
|
|||
return $this->proxy->modelScope();
|
||||
}
|
||||
|
||||
public function scopeWhereProxyType(Builder $query, string $proxyType): Builder
|
||||
{
|
||||
return $query->where('proxy->type', $proxyType);
|
||||
}
|
||||
|
||||
public function isLocalhost()
|
||||
{
|
||||
return $this->ip === 'host.docker.internal' || $this->id === 0;
|
||||
|
|
@ -1131,6 +1186,21 @@ public function installDocker()
|
|||
return InstallDocker::run($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required commands are available on the server.
|
||||
*
|
||||
* @return array{success: bool, missing: array<string>, found: array<string>}
|
||||
*/
|
||||
public function validatePrerequisites(): array
|
||||
{
|
||||
return ValidatePrerequisites::run($this);
|
||||
}
|
||||
|
||||
public function installPrerequisites()
|
||||
{
|
||||
return InstallPrerequisites::run($this);
|
||||
}
|
||||
|
||||
public function validateDockerEngine($throwError = false)
|
||||
{
|
||||
$dockerBinary = instant_remote_process(['command -v docker'], $this, false, no_sudo: true);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
|
|
@ -173,6 +174,21 @@ public function deleteConnectedNetworks()
|
|||
instant_remote_process(["docker network rm {$this->uuid}"], $server, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the service's aggregate status from its applications and databases.
|
||||
*
|
||||
* This method aggregates status from Eloquent model relationships (not Docker containers).
|
||||
* It differs from the CalculatesExcludedStatus trait which works with Docker container objects
|
||||
* during container inspection. This accessor runs on-demand for UI display and works with
|
||||
* already-stored status strings from the database.
|
||||
*
|
||||
* Status format: "{status}:{health}" or "{status}:{health}:excluded"
|
||||
* - Status values: running, exited, degraded, starting, paused, restarting
|
||||
* - Health values: healthy, unhealthy, unknown
|
||||
* - :excluded suffix: Indicates all containers are excluded from health monitoring
|
||||
*
|
||||
* @return string The aggregate status in format "status:health" or "status:health:excluded"
|
||||
*/
|
||||
public function getStatusAttribute()
|
||||
{
|
||||
if ($this->isStarting()) {
|
||||
|
|
@ -182,71 +198,102 @@ public function getStatusAttribute()
|
|||
$applications = $this->applications;
|
||||
$databases = $this->databases;
|
||||
|
||||
$complexStatus = null;
|
||||
$complexHealth = null;
|
||||
[$complexStatus, $complexHealth, $hasNonExcluded] = $this->aggregateResourceStatuses(
|
||||
$applications,
|
||||
$databases,
|
||||
excludedOnly: false
|
||||
);
|
||||
|
||||
foreach ($applications as $application) {
|
||||
if ($application->exclude_from_status) {
|
||||
continue;
|
||||
// If all services are excluded from status checks, calculate status from excluded containers
|
||||
// but mark it with :excluded to indicate monitoring is disabled
|
||||
if (! $hasNonExcluded && ($complexStatus === null && $complexHealth === null)) {
|
||||
[$excludedStatus, $excludedHealth] = $this->aggregateResourceStatuses(
|
||||
$applications,
|
||||
$databases,
|
||||
excludedOnly: true
|
||||
);
|
||||
|
||||
// Return status with :excluded suffix to indicate monitoring is disabled
|
||||
if ($excludedStatus && $excludedHealth) {
|
||||
return "{$excludedStatus}:{$excludedHealth}:excluded";
|
||||
}
|
||||
$status = str($application->status)->before('(')->trim();
|
||||
$health = str($application->status)->between('(', ')')->trim();
|
||||
if ($complexStatus === 'degraded') {
|
||||
continue;
|
||||
}
|
||||
if ($status->startsWith('running')) {
|
||||
if ($complexStatus === 'exited') {
|
||||
$complexStatus = 'degraded';
|
||||
} else {
|
||||
$complexStatus = 'running';
|
||||
}
|
||||
} elseif ($status->startsWith('restarting')) {
|
||||
$complexStatus = 'degraded';
|
||||
} elseif ($status->startsWith('exited')) {
|
||||
$complexStatus = 'exited';
|
||||
}
|
||||
if ($health->value() === 'healthy') {
|
||||
if ($complexHealth === 'unhealthy') {
|
||||
continue;
|
||||
}
|
||||
$complexHealth = 'healthy';
|
||||
} else {
|
||||
$complexHealth = 'unhealthy';
|
||||
|
||||
// If no status was calculated at all (no containers exist), return unknown
|
||||
if ($excludedStatus === null && $excludedHealth === null) {
|
||||
return 'unknown:unknown:excluded';
|
||||
}
|
||||
|
||||
return 'exited';
|
||||
}
|
||||
foreach ($databases as $database) {
|
||||
if ($database->exclude_from_status) {
|
||||
continue;
|
||||
}
|
||||
$status = str($database->status)->before('(')->trim();
|
||||
$health = str($database->status)->between('(', ')')->trim();
|
||||
if ($complexStatus === 'degraded') {
|
||||
continue;
|
||||
}
|
||||
if ($status->startsWith('running')) {
|
||||
if ($complexStatus === 'exited') {
|
||||
$complexStatus = 'degraded';
|
||||
} else {
|
||||
$complexStatus = 'running';
|
||||
}
|
||||
} elseif ($status->startsWith('restarting')) {
|
||||
$complexStatus = 'degraded';
|
||||
} elseif ($status->startsWith('exited')) {
|
||||
$complexStatus = 'exited';
|
||||
}
|
||||
if ($health->value() === 'healthy') {
|
||||
if ($complexHealth === 'unhealthy') {
|
||||
continue;
|
||||
}
|
||||
$complexHealth = 'healthy';
|
||||
} else {
|
||||
$complexHealth = 'unhealthy';
|
||||
}
|
||||
|
||||
// If health is null/empty, return just the status without trailing colon
|
||||
if ($complexHealth === null || $complexHealth === '') {
|
||||
return $complexStatus;
|
||||
}
|
||||
|
||||
return "{$complexStatus}:{$complexHealth}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate status and health from collections of applications and databases.
|
||||
*
|
||||
* This helper method consolidates status aggregation logic using ContainerStatusAggregator.
|
||||
* It processes container status strings stored in the database (not live Docker data).
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Collection $applications Collection of Application models
|
||||
* @param \Illuminate\Database\Eloquent\Collection $databases Collection of Database models
|
||||
* @param bool $excludedOnly If true, only process excluded containers; if false, only process non-excluded
|
||||
* @return array{0: string|null, 1: string|null, 2?: bool} [status, health, hasNonExcluded (only when excludedOnly=false)]
|
||||
*/
|
||||
private function aggregateResourceStatuses($applications, $databases, bool $excludedOnly = false): array
|
||||
{
|
||||
$hasNonExcluded = false;
|
||||
$statusStrings = collect();
|
||||
|
||||
// Process both applications and databases using the same logic
|
||||
$resources = $applications->concat($databases);
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$isExcluded = $resource->exclude_from_status || str($resource->status)->contains(':excluded');
|
||||
|
||||
// Filter based on excludedOnly flag
|
||||
if ($excludedOnly && ! $isExcluded) {
|
||||
continue;
|
||||
}
|
||||
if (! $excludedOnly && $isExcluded) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $excludedOnly) {
|
||||
$hasNonExcluded = true;
|
||||
}
|
||||
|
||||
// Strip :excluded suffix before aggregation (it's in the 3rd part of "status:health:excluded")
|
||||
$status = str($resource->status)->before(':excluded')->toString();
|
||||
$statusStrings->push($status);
|
||||
}
|
||||
|
||||
// If no status strings collected, return nulls
|
||||
if ($statusStrings->isEmpty()) {
|
||||
return $excludedOnly ? [null, null] : [null, null, $hasNonExcluded];
|
||||
}
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($statusStrings);
|
||||
|
||||
// Parse the aggregated "status:health" string
|
||||
$parts = explode(':', $aggregatedStatus);
|
||||
$status = $parts[0] ?? null;
|
||||
$health = $parts[1] ?? null;
|
||||
|
||||
if ($excludedOnly) {
|
||||
return [$status, $health];
|
||||
}
|
||||
|
||||
return [$status, $health, $hasNonExcluded];
|
||||
}
|
||||
|
||||
public function extraFields()
|
||||
{
|
||||
$fields = collect([]);
|
||||
|
|
|
|||
|
|
@ -189,65 +189,66 @@ public function isBackupSolutionAvailable()
|
|||
public function getRequiredPort(): ?int
|
||||
{
|
||||
try {
|
||||
// Normalize container name same way as variable creation
|
||||
// (uppercase, replace - and . with _)
|
||||
$normalizedName = str($this->name)
|
||||
->upper()
|
||||
->replace('-', '_')
|
||||
->replace('.', '_')
|
||||
->value();
|
||||
// Get all environment variables from the service
|
||||
$serviceEnvVars = $this->service->environment_variables()->get();
|
||||
// Parse the Docker Compose to find SERVICE_URL/SERVICE_FQDN variables DIRECTLY DECLARED
|
||||
// for this specific service container (not just referenced from other containers)
|
||||
$dockerComposeRaw = data_get($this->service, 'docker_compose_raw');
|
||||
if (! $dockerComposeRaw) {
|
||||
// Fall back to service-level port if no compose file
|
||||
return $this->service->getRequiredPort();
|
||||
}
|
||||
|
||||
// Look for SERVICE_FQDN_* or SERVICE_URL_* variables that match this container
|
||||
foreach ($serviceEnvVars as $envVar) {
|
||||
$key = str($envVar->key);
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$serviceConfig = data_get($dockerCompose, "services.{$this->name}");
|
||||
if (! $serviceConfig) {
|
||||
return $this->service->getRequiredPort();
|
||||
}
|
||||
|
||||
// Check if this is a SERVICE_FQDN_* or SERVICE_URL_* variable
|
||||
if (! $key->startsWith('SERVICE_FQDN_') && ! $key->startsWith('SERVICE_URL_')) {
|
||||
continue;
|
||||
}
|
||||
// Extract the part after SERVICE_FQDN_ or SERVICE_URL_
|
||||
if ($key->startsWith('SERVICE_FQDN_')) {
|
||||
$suffix = $key->after('SERVICE_FQDN_');
|
||||
} else {
|
||||
$suffix = $key->after('SERVICE_URL_');
|
||||
}
|
||||
$environment = data_get($serviceConfig, 'environment', []);
|
||||
|
||||
// Check if this variable starts with our normalized container name
|
||||
// Format: {NORMALIZED_NAME}_{PORT} or just {NORMALIZED_NAME}
|
||||
if (! $suffix->startsWith($normalizedName)) {
|
||||
\Log::debug('[ServiceApplication::getRequiredPort] Suffix does not match container', [
|
||||
'expected_start' => $normalizedName,
|
||||
'actual_suffix' => $suffix->value(),
|
||||
]);
|
||||
// Extract SERVICE_URL and SERVICE_FQDN variables DIRECTLY DECLARED in this service's environment
|
||||
// (not variables that are merely referenced with ${VAR} syntax)
|
||||
$portFound = null;
|
||||
foreach ($environment as $key => $value) {
|
||||
if (is_int($key) && is_string($value)) {
|
||||
// List-style: "- SERVICE_URL_APP_3000" or "- SERVICE_URL_APP_3000=value"
|
||||
// Extract variable name (before '=' if present)
|
||||
$envVarName = str($value)->before('=')->trim();
|
||||
|
||||
continue;
|
||||
}
|
||||
// Only process direct declarations
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
// Parse to check if it has a port suffix
|
||||
$parsed = parseServiceEnvironmentVariable($envVarName->value());
|
||||
if ($parsed['has_port'] && $parsed['port']) {
|
||||
// Found a port-specific variable for this service
|
||||
$portFound = (int) $parsed['port'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} elseif (is_string($key)) {
|
||||
// Map-style: "SERVICE_URL_APP_3000: value" or "SERVICE_FQDN_DB: localhost"
|
||||
$envVarName = str($key);
|
||||
|
||||
// Check if there's a port suffix after the container name
|
||||
// The suffix should be exactly NORMALIZED_NAME or NORMALIZED_NAME_PORT
|
||||
$afterName = $suffix->after($normalizedName)->value();
|
||||
|
||||
// If there's content after the name, it should start with underscore
|
||||
if ($afterName !== '' && str($afterName)->startsWith('_')) {
|
||||
// Extract port: _3210 -> 3210
|
||||
$port = str($afterName)->after('_')->value();
|
||||
// Validate that the extracted port is numeric
|
||||
if (is_numeric($port)) {
|
||||
\Log::debug('[ServiceApplication::getRequiredPort] MATCH FOUND - Returning port', [
|
||||
'port' => (int) $port,
|
||||
]);
|
||||
|
||||
return (int) $port;
|
||||
// Only process direct declarations
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
// Parse to check if it has a port suffix
|
||||
$parsed = parseServiceEnvironmentVariable($envVarName->value());
|
||||
if ($parsed['has_port'] && $parsed['port']) {
|
||||
// Found a port-specific variable for this service
|
||||
$portFound = (int) $parsed['port'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to service-level port if no port-specific variable is found
|
||||
$fallbackPort = $this->service->getRequiredPort();
|
||||
// If a port was found in the template, return it
|
||||
if ($portFound !== null) {
|
||||
return $portFound;
|
||||
}
|
||||
|
||||
return $fallbackPort;
|
||||
// No port-specific variables found for this service, return null
|
||||
// (DO NOT fall back to service-level port, as that applies to all services)
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class SlackNotificationSettings extends Model
|
|||
'server_reachable_slack_notifications',
|
||||
'server_unreachable_slack_notifications',
|
||||
'server_patch_slack_notifications',
|
||||
'traefik_outdated_slack_notifications',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
@ -47,6 +48,7 @@ class SlackNotificationSettings extends Model
|
|||
'server_reachable_slack_notifications' => 'boolean',
|
||||
'server_unreachable_slack_notifications' => 'boolean',
|
||||
'server_patch_slack_notifications' => 'boolean',
|
||||
'traefik_outdated_slack_notifications' => 'boolean',
|
||||
];
|
||||
|
||||
public function team()
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
|||
protected static function booted()
|
||||
{
|
||||
static::created(function ($team) {
|
||||
$team->emailNotificationSettings()->create();
|
||||
$team->emailNotificationSettings()->create([
|
||||
'use_instance_email_settings' => isDev(),
|
||||
]);
|
||||
$team->discordNotificationSettings()->create();
|
||||
$team->slackNotificationSettings()->create();
|
||||
$team->telegramNotificationSettings()->create();
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class TelegramNotificationSettings extends Model
|
|||
'server_reachable_telegram_notifications',
|
||||
'server_unreachable_telegram_notifications',
|
||||
'server_patch_telegram_notifications',
|
||||
'traefik_outdated_telegram_notifications',
|
||||
|
||||
'telegram_notifications_deployment_success_thread_id',
|
||||
'telegram_notifications_deployment_failure_thread_id',
|
||||
|
|
@ -43,6 +44,7 @@ class TelegramNotificationSettings extends Model
|
|||
'telegram_notifications_server_reachable_thread_id',
|
||||
'telegram_notifications_server_unreachable_thread_id',
|
||||
'telegram_notifications_server_patch_thread_id',
|
||||
'telegram_notifications_traefik_outdated_thread_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
@ -62,6 +64,7 @@ class TelegramNotificationSettings extends Model
|
|||
'server_reachable_telegram_notifications' => 'boolean',
|
||||
'server_unreachable_telegram_notifications' => 'boolean',
|
||||
'server_patch_telegram_notifications' => 'boolean',
|
||||
'traefik_outdated_telegram_notifications' => 'boolean',
|
||||
|
||||
'telegram_notifications_deployment_success_thread_id' => 'encrypted',
|
||||
'telegram_notifications_deployment_failure_thread_id' => 'encrypted',
|
||||
|
|
@ -75,6 +78,7 @@ class TelegramNotificationSettings extends Model
|
|||
'telegram_notifications_server_reachable_thread_id' => 'encrypted',
|
||||
'telegram_notifications_server_unreachable_thread_id' => 'encrypted',
|
||||
'telegram_notifications_server_patch_thread_id' => 'encrypted',
|
||||
'telegram_notifications_traefik_outdated_thread_id' => 'encrypted',
|
||||
];
|
||||
|
||||
public function team()
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class WebhookNotificationSettings extends Model
|
|||
'server_reachable_webhook_notifications',
|
||||
'server_unreachable_webhook_notifications',
|
||||
'server_patch_webhook_notifications',
|
||||
'traefik_outdated_webhook_notifications',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
|
@ -49,6 +50,7 @@ protected function casts(): array
|
|||
'server_reachable_webhook_notifications' => 'boolean',
|
||||
'server_unreachable_webhook_notifications' => 'boolean',
|
||||
'server_patch_webhook_notifications' => 'boolean',
|
||||
'traefik_outdated_webhook_notifications' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
262
app/Notifications/Server/TraefikVersionOutdated.php
Normal file
262
app/Notifications/Server/TraefikVersionOutdated.php
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications\Server;
|
||||
|
||||
use App\Notifications\CustomEmailNotification;
|
||||
use App\Notifications\Dto\DiscordMessage;
|
||||
use App\Notifications\Dto\PushoverMessage;
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class TraefikVersionOutdated extends CustomEmailNotification
|
||||
{
|
||||
public function __construct(public Collection $servers)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return $notifiable->getEnabledChannels('traefik_outdated');
|
||||
}
|
||||
|
||||
private function formatVersion(string $version): string
|
||||
{
|
||||
// Add 'v' prefix if not present for consistent display
|
||||
return str_starts_with($version, 'v') ? $version : "v{$version}";
|
||||
}
|
||||
|
||||
private function getUpgradeTarget(array $info): string
|
||||
{
|
||||
// For minor upgrades, use the upgrade_target field (e.g., "v3.6")
|
||||
if (($info['type'] ?? 'patch_update') === 'minor_upgrade' && isset($info['upgrade_target'])) {
|
||||
return $this->formatVersion($info['upgrade_target']);
|
||||
}
|
||||
|
||||
// For patch updates, show the full version
|
||||
return $this->formatVersion($info['latest'] ?? 'unknown');
|
||||
}
|
||||
|
||||
public function toMail($notifiable = null): MailMessage
|
||||
{
|
||||
$mail = new MailMessage;
|
||||
$count = $this->servers->count();
|
||||
|
||||
$mail->subject("Coolify: Traefik proxy outdated on {$count} server(s)");
|
||||
$mail->view('emails.traefik-version-outdated', [
|
||||
'servers' => $this->servers,
|
||||
'count' => $count,
|
||||
]);
|
||||
|
||||
return $mail;
|
||||
}
|
||||
|
||||
public function toDiscord(): DiscordMessage
|
||||
{
|
||||
$count = $this->servers->count();
|
||||
$hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade' ||
|
||||
isset($s->outdatedInfo['newer_branch_target'])
|
||||
);
|
||||
|
||||
$description = "**{$count} server(s)** running outdated Traefik proxy. Update recommended for security and features.\n\n";
|
||||
$description .= "**Affected servers:**\n";
|
||||
|
||||
foreach ($this->servers as $server) {
|
||||
$info = $server->outdatedInfo ?? [];
|
||||
$current = $this->formatVersion($info['current'] ?? 'unknown');
|
||||
$latest = $this->formatVersion($info['latest'] ?? 'unknown');
|
||||
$upgradeTarget = $this->getUpgradeTarget($info);
|
||||
$isPatch = ($info['type'] ?? 'patch_update') === 'patch_update';
|
||||
$hasNewerBranch = isset($info['newer_branch_target']);
|
||||
|
||||
if ($isPatch && $hasNewerBranch) {
|
||||
$newerBranchTarget = $info['newer_branch_target'];
|
||||
$newerBranchLatest = $this->formatVersion($info['newer_branch_latest']);
|
||||
$description .= "• {$server->name}: {$current} → {$upgradeTarget} (patch update available)\n";
|
||||
$description .= " ↳ Also available: {$newerBranchTarget} (latest patch: {$newerBranchLatest}) - new minor version\n";
|
||||
} elseif ($isPatch) {
|
||||
$description .= "• {$server->name}: {$current} → {$upgradeTarget} (patch update available)\n";
|
||||
} else {
|
||||
$description .= "• {$server->name}: {$current} (latest patch: {$latest}) → {$upgradeTarget} (new minor version available)\n";
|
||||
}
|
||||
}
|
||||
|
||||
$description .= "\n⚠️ It is recommended to test before switching the production version.";
|
||||
|
||||
if ($hasUpgrades) {
|
||||
$description .= "\n\n📖 **For minor version upgrades**: Read the Traefik changelog before upgrading to understand breaking changes and new features.";
|
||||
}
|
||||
|
||||
return new DiscordMessage(
|
||||
title: ':warning: Coolify: Traefik proxy outdated',
|
||||
description: $description,
|
||||
color: DiscordMessage::warningColor(),
|
||||
);
|
||||
}
|
||||
|
||||
public function toTelegram(): array
|
||||
{
|
||||
$count = $this->servers->count();
|
||||
$hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade' ||
|
||||
isset($s->outdatedInfo['newer_branch_target'])
|
||||
);
|
||||
|
||||
$message = "⚠️ Coolify: Traefik proxy outdated on {$count} server(s)!\n\n";
|
||||
$message .= "Update recommended for security and features.\n";
|
||||
$message .= "📊 Affected servers:\n";
|
||||
|
||||
foreach ($this->servers as $server) {
|
||||
$info = $server->outdatedInfo ?? [];
|
||||
$current = $this->formatVersion($info['current'] ?? 'unknown');
|
||||
$latest = $this->formatVersion($info['latest'] ?? 'unknown');
|
||||
$upgradeTarget = $this->getUpgradeTarget($info);
|
||||
$isPatch = ($info['type'] ?? 'patch_update') === 'patch_update';
|
||||
$hasNewerBranch = isset($info['newer_branch_target']);
|
||||
|
||||
if ($isPatch && $hasNewerBranch) {
|
||||
$newerBranchTarget = $info['newer_branch_target'];
|
||||
$newerBranchLatest = $this->formatVersion($info['newer_branch_latest']);
|
||||
$message .= "• {$server->name}: {$current} → {$upgradeTarget} (patch update available)\n";
|
||||
$message .= " ↳ Also available: {$newerBranchTarget} (latest patch: {$newerBranchLatest}) - new minor version\n";
|
||||
} elseif ($isPatch) {
|
||||
$message .= "• {$server->name}: {$current} → {$upgradeTarget} (patch update available)\n";
|
||||
} else {
|
||||
$message .= "• {$server->name}: {$current} (latest patch: {$latest}) → {$upgradeTarget} (new minor version available)\n";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= "\n⚠️ It is recommended to test before switching the production version.";
|
||||
|
||||
if ($hasUpgrades) {
|
||||
$message .= "\n\n📖 For minor version upgrades: Read the Traefik changelog before upgrading to understand breaking changes and new features.";
|
||||
}
|
||||
|
||||
return [
|
||||
'message' => $message,
|
||||
'buttons' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function toPushover(): PushoverMessage
|
||||
{
|
||||
$count = $this->servers->count();
|
||||
$hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade' ||
|
||||
isset($s->outdatedInfo['newer_branch_target'])
|
||||
);
|
||||
|
||||
$message = "Traefik proxy outdated on {$count} server(s)!\n";
|
||||
$message .= "Affected servers:\n";
|
||||
|
||||
foreach ($this->servers as $server) {
|
||||
$info = $server->outdatedInfo ?? [];
|
||||
$current = $this->formatVersion($info['current'] ?? 'unknown');
|
||||
$latest = $this->formatVersion($info['latest'] ?? 'unknown');
|
||||
$upgradeTarget = $this->getUpgradeTarget($info);
|
||||
$isPatch = ($info['type'] ?? 'patch_update') === 'patch_update';
|
||||
$hasNewerBranch = isset($info['newer_branch_target']);
|
||||
|
||||
if ($isPatch && $hasNewerBranch) {
|
||||
$newerBranchTarget = $info['newer_branch_target'];
|
||||
$newerBranchLatest = $this->formatVersion($info['newer_branch_latest']);
|
||||
$message .= "• {$server->name}: {$current} → {$upgradeTarget} (patch update available)\n";
|
||||
$message .= " Also: {$newerBranchTarget} (latest: {$newerBranchLatest}) - new minor version\n";
|
||||
} elseif ($isPatch) {
|
||||
$message .= "• {$server->name}: {$current} → {$upgradeTarget} (patch update available)\n";
|
||||
} else {
|
||||
$message .= "• {$server->name}: {$current} (latest patch: {$latest}) → {$upgradeTarget} (new minor version available)\n";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= "\nIt is recommended to test before switching the production version.";
|
||||
|
||||
if ($hasUpgrades) {
|
||||
$message .= "\n\nFor minor version upgrades: Read the Traefik changelog before upgrading.";
|
||||
}
|
||||
|
||||
return new PushoverMessage(
|
||||
title: 'Traefik proxy outdated',
|
||||
level: 'warning',
|
||||
message: $message,
|
||||
);
|
||||
}
|
||||
|
||||
public function toSlack(): SlackMessage
|
||||
{
|
||||
$count = $this->servers->count();
|
||||
$hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade' ||
|
||||
isset($s->outdatedInfo['newer_branch_target'])
|
||||
);
|
||||
|
||||
$description = "Traefik proxy outdated on {$count} server(s)!\n";
|
||||
$description .= "*Affected servers:*\n";
|
||||
|
||||
foreach ($this->servers as $server) {
|
||||
$info = $server->outdatedInfo ?? [];
|
||||
$current = $this->formatVersion($info['current'] ?? 'unknown');
|
||||
$latest = $this->formatVersion($info['latest'] ?? 'unknown');
|
||||
$upgradeTarget = $this->getUpgradeTarget($info);
|
||||
$isPatch = ($info['type'] ?? 'patch_update') === 'patch_update';
|
||||
$hasNewerBranch = isset($info['newer_branch_target']);
|
||||
|
||||
if ($isPatch && $hasNewerBranch) {
|
||||
$newerBranchTarget = $info['newer_branch_target'];
|
||||
$newerBranchLatest = $this->formatVersion($info['newer_branch_latest']);
|
||||
$description .= "• `{$server->name}`: {$current} → {$upgradeTarget} (patch update available)\n";
|
||||
$description .= " ↳ Also available: {$newerBranchTarget} (latest patch: {$newerBranchLatest}) - new minor version\n";
|
||||
} elseif ($isPatch) {
|
||||
$description .= "• `{$server->name}`: {$current} → {$upgradeTarget} (patch update available)\n";
|
||||
} else {
|
||||
$description .= "• `{$server->name}`: {$current} (latest patch: {$latest}) → {$upgradeTarget} (new minor version available)\n";
|
||||
}
|
||||
}
|
||||
|
||||
$description .= "\n:warning: It is recommended to test before switching the production version.";
|
||||
|
||||
if ($hasUpgrades) {
|
||||
$description .= "\n\n:book: For minor version upgrades: Read the Traefik changelog before upgrading to understand breaking changes and new features.";
|
||||
}
|
||||
|
||||
return new SlackMessage(
|
||||
title: 'Coolify: Traefik proxy outdated',
|
||||
description: $description,
|
||||
color: SlackMessage::warningColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
$servers = $this->servers->map(function ($server) {
|
||||
$info = $server->outdatedInfo ?? [];
|
||||
|
||||
$webhookData = [
|
||||
'name' => $server->name,
|
||||
'uuid' => $server->uuid,
|
||||
'current_version' => $info['current'] ?? 'unknown',
|
||||
'latest_version' => $info['latest'] ?? 'unknown',
|
||||
'update_type' => $info['type'] ?? 'patch_update',
|
||||
];
|
||||
|
||||
// For minor upgrades, include the upgrade target (e.g., "v3.6")
|
||||
if (($info['type'] ?? 'patch_update') === 'minor_upgrade' && isset($info['upgrade_target'])) {
|
||||
$webhookData['upgrade_target'] = $info['upgrade_target'];
|
||||
}
|
||||
|
||||
// Include newer branch info if available
|
||||
if (isset($info['newer_branch_target'])) {
|
||||
$webhookData['newer_branch_target'] = $info['newer_branch_target'];
|
||||
$webhookData['newer_branch_latest'] = $info['newer_branch_latest'];
|
||||
}
|
||||
|
||||
return $webhookData;
|
||||
})->toArray();
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Traefik proxy outdated',
|
||||
'event' => 'traefik_version_outdated',
|
||||
'affected_servers_count' => $this->servers->count(),
|
||||
'servers' => $servers,
|
||||
];
|
||||
}
|
||||
}
|
||||
251
app/Services/ContainerStatusAggregator.php
Normal file
251
app/Services/ContainerStatusAggregator.php
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Container Status Aggregator Service
|
||||
*
|
||||
* Centralized service for aggregating container statuses into a single status string.
|
||||
* Uses a priority-based state machine to determine the overall status from multiple containers.
|
||||
*
|
||||
* Output Format: Colon-separated (e.g., "running:healthy", "degraded:unhealthy")
|
||||
* This format is used throughout the backend for consistency and machine-readability.
|
||||
* UI components transform this to human-readable format (e.g., "Running (Healthy)").
|
||||
*
|
||||
* State Priority (highest to lowest):
|
||||
* 1. Restarting → degraded:unhealthy
|
||||
* 2. Crash Loop (exited with restarts) → degraded:unhealthy
|
||||
* 3. Mixed (running + exited) → degraded:unhealthy
|
||||
* 4. Running → running:healthy/unhealthy/unknown
|
||||
* 5. Dead/Removing → degraded:unhealthy
|
||||
* 6. Paused → paused:unknown
|
||||
* 7. Starting/Created → starting:unknown
|
||||
* 8. Exited → exited
|
||||
*/
|
||||
class ContainerStatusAggregator
|
||||
{
|
||||
/**
|
||||
* Aggregate container statuses from status strings into a single status.
|
||||
*
|
||||
* @param Collection $containerStatuses Collection of status strings (e.g., "running (healthy)", "running:healthy")
|
||||
* @param int $maxRestartCount Maximum restart count across containers (for crash loop detection)
|
||||
* @return string Aggregated status in colon format (e.g., "running:healthy")
|
||||
*/
|
||||
public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0): string
|
||||
{
|
||||
// Validate maxRestartCount parameter
|
||||
if ($maxRestartCount < 0) {
|
||||
Log::warning('Negative maxRestartCount corrected to 0', [
|
||||
'original_value' => $maxRestartCount,
|
||||
]);
|
||||
$maxRestartCount = 0;
|
||||
}
|
||||
|
||||
if ($maxRestartCount > 1000) {
|
||||
Log::warning('High maxRestartCount detected', [
|
||||
'maxRestartCount' => $maxRestartCount,
|
||||
'containers' => $containerStatuses->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($containerStatuses->isEmpty()) {
|
||||
return 'exited';
|
||||
}
|
||||
|
||||
// Initialize state flags
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasUnknown = false;
|
||||
$hasExited = false;
|
||||
$hasStarting = false;
|
||||
$hasPaused = false;
|
||||
$hasDead = false;
|
||||
|
||||
// Parse each status string and set flags
|
||||
foreach ($containerStatuses as $status) {
|
||||
if (str($status)->contains('restarting')) {
|
||||
$hasRestarting = true;
|
||||
} elseif (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
if (str($status)->contains('unknown')) {
|
||||
$hasUnknown = true;
|
||||
}
|
||||
} elseif (str($status)->contains('exited')) {
|
||||
$hasExited = true;
|
||||
} elseif (str($status)->contains('created') || str($status)->contains('starting')) {
|
||||
$hasStarting = true;
|
||||
} elseif (str($status)->contains('paused')) {
|
||||
$hasPaused = true;
|
||||
} elseif (str($status)->contains('dead') || str($status)->contains('removing')) {
|
||||
$hasDead = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority-based status resolution
|
||||
return $this->resolveStatus(
|
||||
$hasRunning,
|
||||
$hasRestarting,
|
||||
$hasUnhealthy,
|
||||
$hasUnknown,
|
||||
$hasExited,
|
||||
$hasStarting,
|
||||
$hasPaused,
|
||||
$hasDead,
|
||||
$maxRestartCount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate container statuses from Docker container objects.
|
||||
*
|
||||
* @param Collection $containers Collection of Docker container objects with State property
|
||||
* @param int $maxRestartCount Maximum restart count across containers (for crash loop detection)
|
||||
* @return string Aggregated status in colon format (e.g., "running:healthy")
|
||||
*/
|
||||
public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0): string
|
||||
{
|
||||
// Validate maxRestartCount parameter
|
||||
if ($maxRestartCount < 0) {
|
||||
Log::warning('Negative maxRestartCount corrected to 0', [
|
||||
'original_value' => $maxRestartCount,
|
||||
]);
|
||||
$maxRestartCount = 0;
|
||||
}
|
||||
|
||||
if ($maxRestartCount > 1000) {
|
||||
Log::warning('High maxRestartCount detected', [
|
||||
'maxRestartCount' => $maxRestartCount,
|
||||
'containers' => $containers->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($containers->isEmpty()) {
|
||||
return 'exited';
|
||||
}
|
||||
|
||||
// Initialize state flags
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasUnknown = false;
|
||||
$hasExited = false;
|
||||
$hasStarting = false;
|
||||
$hasPaused = false;
|
||||
$hasDead = false;
|
||||
|
||||
// Parse each container object and set flags
|
||||
foreach ($containers as $container) {
|
||||
$state = data_get($container, 'State.Status', 'exited');
|
||||
$health = data_get($container, 'State.Health.Status');
|
||||
|
||||
if ($state === 'restarting') {
|
||||
$hasRestarting = true;
|
||||
} elseif ($state === 'running') {
|
||||
$hasRunning = true;
|
||||
if ($health === 'unhealthy') {
|
||||
$hasUnhealthy = true;
|
||||
} elseif (is_null($health) || $health === 'starting') {
|
||||
$hasUnknown = true;
|
||||
}
|
||||
} elseif ($state === 'exited') {
|
||||
$hasExited = true;
|
||||
} elseif ($state === 'created' || $state === 'starting') {
|
||||
$hasStarting = true;
|
||||
} elseif ($state === 'paused') {
|
||||
$hasPaused = true;
|
||||
} elseif ($state === 'dead' || $state === 'removing') {
|
||||
$hasDead = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority-based status resolution
|
||||
return $this->resolveStatus(
|
||||
$hasRunning,
|
||||
$hasRestarting,
|
||||
$hasUnhealthy,
|
||||
$hasUnknown,
|
||||
$hasExited,
|
||||
$hasStarting,
|
||||
$hasPaused,
|
||||
$hasDead,
|
||||
$maxRestartCount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the aggregated status based on state flags (priority-based state machine).
|
||||
*
|
||||
* @param bool $hasRunning Has at least one running container
|
||||
* @param bool $hasRestarting Has at least one restarting container
|
||||
* @param bool $hasUnhealthy Has at least one unhealthy container
|
||||
* @param bool $hasUnknown Has at least one container with unknown health
|
||||
* @param bool $hasExited Has at least one exited container
|
||||
* @param bool $hasStarting Has at least one starting/created container
|
||||
* @param bool $hasPaused Has at least one paused container
|
||||
* @param bool $hasDead Has at least one dead/removing container
|
||||
* @param int $maxRestartCount Maximum restart count (for crash loop detection)
|
||||
* @return string Status in colon format (e.g., "running:healthy")
|
||||
*/
|
||||
private function resolveStatus(
|
||||
bool $hasRunning,
|
||||
bool $hasRestarting,
|
||||
bool $hasUnhealthy,
|
||||
bool $hasUnknown,
|
||||
bool $hasExited,
|
||||
bool $hasStarting,
|
||||
bool $hasPaused,
|
||||
bool $hasDead,
|
||||
int $maxRestartCount
|
||||
): string {
|
||||
// Priority 1: Restarting containers (degraded state)
|
||||
if ($hasRestarting) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
// Priority 2: Crash loop detection (exited with restart count > 0)
|
||||
if ($hasExited && $maxRestartCount > 0) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
// Priority 3: Mixed state (some running, some exited = degraded)
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
// Priority 4: Running containers (check health status)
|
||||
if ($hasRunning) {
|
||||
if ($hasUnhealthy) {
|
||||
return 'running:unhealthy';
|
||||
} elseif ($hasUnknown) {
|
||||
return 'running:unknown';
|
||||
} else {
|
||||
return 'running:healthy';
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 5: Dead or removing containers
|
||||
if ($hasDead) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
// Priority 6: Paused containers
|
||||
if ($hasPaused) {
|
||||
return 'paused:unknown';
|
||||
}
|
||||
|
||||
// Priority 7: Starting/created containers
|
||||
if ($hasStarting) {
|
||||
return 'starting:unknown';
|
||||
}
|
||||
|
||||
// Priority 8: All containers exited (no restart count = truly stopped)
|
||||
return 'exited';
|
||||
}
|
||||
}
|
||||
166
app/Traits/CalculatesExcludedStatus.php
Normal file
166
app/Traits/CalculatesExcludedStatus.php
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Yaml\Exception\ParseException;
|
||||
|
||||
trait CalculatesExcludedStatus
|
||||
{
|
||||
/**
|
||||
* Calculate status for containers when all containers are excluded from health checks.
|
||||
*
|
||||
* This method processes excluded containers and returns a status with :excluded suffix
|
||||
* to indicate that monitoring is disabled but still show the actual container state.
|
||||
*
|
||||
* @param Collection $containers Collection of container objects from Docker inspect
|
||||
* @param Collection $excludedContainers Collection of container names that are excluded
|
||||
* @return string Status string with :excluded suffix (e.g., 'running:unhealthy:excluded')
|
||||
*/
|
||||
protected function calculateExcludedStatus(Collection $containers, Collection $excludedContainers): string
|
||||
{
|
||||
// Filter to only excluded containers
|
||||
$excludedOnly = $containers->filter(function ($container) use ($excludedContainers) {
|
||||
$labels = data_get($container, 'Config.Labels', []);
|
||||
$serviceName = data_get($labels, 'com.docker.compose.service');
|
||||
|
||||
return $serviceName && $excludedContainers->contains($serviceName);
|
||||
});
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$status = $aggregator->aggregateFromContainers($excludedOnly);
|
||||
|
||||
// Append :excluded suffix
|
||||
return $this->appendExcludedSuffix($status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate status for containers when all containers are excluded (simplified version).
|
||||
*
|
||||
* This version works with status strings (e.g., "running:healthy") instead of full
|
||||
* container objects, suitable for Sentinel updates that don't have full container data.
|
||||
*
|
||||
* @param Collection $containerStatuses Collection of status strings keyed by container name
|
||||
* @return string Status string with :excluded suffix
|
||||
*/
|
||||
protected function calculateExcludedStatusFromStrings(Collection $containerStatuses): string
|
||||
{
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$status = $aggregator->aggregateFromStrings($containerStatuses);
|
||||
|
||||
// Append :excluded suffix
|
||||
$finalStatus = $this->appendExcludedSuffix($status);
|
||||
|
||||
return $finalStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append :excluded suffix to a status string.
|
||||
*
|
||||
* Converts status formats like:
|
||||
* - "running:healthy" → "running:healthy:excluded"
|
||||
* - "degraded:unhealthy" → "degraded:excluded" (simplified)
|
||||
* - "paused:unknown" → "paused:excluded" (simplified)
|
||||
*
|
||||
* @param string $status The base status string
|
||||
* @return string Status with :excluded suffix
|
||||
*/
|
||||
private function appendExcludedSuffix(string $status): string
|
||||
{
|
||||
// For degraded states, simplify to just "degraded:excluded"
|
||||
if (str($status)->startsWith('degraded')) {
|
||||
return 'degraded:excluded';
|
||||
}
|
||||
|
||||
// For paused/starting/exited states, simplify to just "state:excluded"
|
||||
if (str($status)->startsWith('paused')) {
|
||||
return 'paused:excluded';
|
||||
}
|
||||
|
||||
if (str($status)->startsWith('starting')) {
|
||||
return 'starting:excluded';
|
||||
}
|
||||
|
||||
if (str($status)->startsWith('exited')) {
|
||||
return 'exited';
|
||||
}
|
||||
|
||||
// For running states, keep the health status: "running:healthy:excluded"
|
||||
return "$status:excluded";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get excluded containers from docker-compose YAML.
|
||||
*
|
||||
* Containers are excluded if:
|
||||
* - They have exclude_from_hc: true label
|
||||
* - They have restart: no policy
|
||||
*
|
||||
* @param string|null $dockerComposeRaw The raw docker-compose YAML content
|
||||
* @return Collection Collection of excluded container names
|
||||
*/
|
||||
protected function getExcludedContainersFromDockerCompose(?string $dockerComposeRaw): Collection
|
||||
{
|
||||
$excludedContainers = collect();
|
||||
|
||||
if (! $dockerComposeRaw) {
|
||||
return $excludedContainers;
|
||||
}
|
||||
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
|
||||
// Validate structure
|
||||
if (! is_array($dockerCompose)) {
|
||||
Log::warning('Docker Compose YAML did not parse to array', [
|
||||
'yaml_length' => strlen($dockerComposeRaw),
|
||||
'parsed_type' => gettype($dockerCompose),
|
||||
]);
|
||||
|
||||
return $excludedContainers;
|
||||
}
|
||||
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
if (! is_array($services)) {
|
||||
Log::warning('Docker Compose services is not an array', [
|
||||
'services_type' => gettype($services),
|
||||
]);
|
||||
|
||||
return $excludedContainers;
|
||||
}
|
||||
|
||||
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 (ParseException $e) {
|
||||
// Specific YAML parsing errors
|
||||
Log::warning('Failed to parse Docker Compose YAML for health check exclusions', [
|
||||
'error' => $e->getMessage(),
|
||||
'line' => $e->getParsedLine(),
|
||||
'snippet' => $e->getSnippet(),
|
||||
]);
|
||||
|
||||
return $excludedContainers;
|
||||
} catch (\Exception $e) {
|
||||
// Unexpected errors
|
||||
Log::error('Unexpected error parsing Docker Compose YAML', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return $excludedContainers;
|
||||
}
|
||||
|
||||
return $excludedContainers;
|
||||
}
|
||||
}
|
||||
|
|
@ -1083,6 +1083,44 @@ function generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker
|
|||
return $docker_compose;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove Coolify's custom Docker Compose fields from parsed YAML array
|
||||
*
|
||||
* Coolify extends Docker Compose with custom fields that are processed during
|
||||
* parsing and deployment but must be removed before sending to Docker.
|
||||
*
|
||||
* Custom fields:
|
||||
* - exclude_from_hc (service-level): Exclude service from health check monitoring
|
||||
* - content (volume-level): Auto-create file with specified content during init
|
||||
* - isDirectory / is_directory (volume-level): Mark bind mount as directory
|
||||
*
|
||||
* @param array $yamlCompose Parsed Docker Compose array
|
||||
* @return array Cleaned Docker Compose array with custom fields removed
|
||||
*/
|
||||
function stripCoolifyCustomFields(array $yamlCompose): array
|
||||
{
|
||||
foreach ($yamlCompose['services'] ?? [] as $serviceName => $service) {
|
||||
// Remove service-level custom fields
|
||||
unset($yamlCompose['services'][$serviceName]['exclude_from_hc']);
|
||||
|
||||
// Remove volume-level custom fields (only for long syntax - arrays)
|
||||
if (isset($service['volumes'])) {
|
||||
foreach ($service['volumes'] as $volumeName => $volume) {
|
||||
// Skip if volume is string (short syntax like 'db-data:/var/lib/postgresql/data')
|
||||
if (! is_array($volume)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['content']);
|
||||
unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['isDirectory']);
|
||||
unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['is_directory']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $yamlCompose;
|
||||
}
|
||||
|
||||
function validateComposeFile(string $compose, int $server_id): string|Throwable
|
||||
{
|
||||
$uuid = Str::random(18);
|
||||
|
|
@ -1092,16 +1130,10 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
|
|||
throw new \Exception('Server not found');
|
||||
}
|
||||
$yaml_compose = Yaml::parse($compose);
|
||||
foreach ($yaml_compose['services'] as $service_name => $service) {
|
||||
if (! isset($service['volumes'])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($service['volumes'] as $volume_name => $volume) {
|
||||
if (data_get($volume, 'type') === 'bind' && data_get($volume, 'content')) {
|
||||
unset($yaml_compose['services'][$service_name]['volumes'][$volume_name]['content']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove Coolify's custom fields before Docker validation
|
||||
$yaml_compose = stripCoolifyCustomFields($yaml_compose);
|
||||
|
||||
$base64_compose = base64_encode(Yaml::dump($yaml_compose));
|
||||
instant_remote_process([
|
||||
"echo {$base64_compose} | base64 -d | tee /tmp/{$uuid}.yml > /dev/null",
|
||||
|
|
@ -1272,3 +1304,36 @@ function generateDockerEnvFlags($variables): string
|
|||
})
|
||||
->implode(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-inject -f and --env-file flags into a docker compose command if not already present
|
||||
*
|
||||
* @param string $command The docker compose command to modify
|
||||
* @param string $composeFilePath The path to the compose file
|
||||
* @param string $envFilePath The path to the .env file
|
||||
* @return string The modified command with injected flags
|
||||
*
|
||||
* @example
|
||||
* Input: "docker compose build"
|
||||
* Output: "docker compose -f ./docker-compose.yml --env-file .env build"
|
||||
*/
|
||||
function injectDockerComposeFlags(string $command, string $composeFilePath, string $envFilePath): string
|
||||
{
|
||||
$dockerComposeReplacement = 'docker compose';
|
||||
|
||||
// Add -f flag if not present (checks for both -f and --file with various formats)
|
||||
// Detects: -f path, -f=path, -fpath (concatenated with path chars: . / ~), --file path, --file=path
|
||||
// Note: Uses [.~/]|$ instead of \S to prevent false positives with flags like -foo, -from, -feature
|
||||
if (! preg_match('/(?:^|\s)(?:-f(?:[=\s]|[.\/~]|$)|--file(?:=|\s))/', $command)) {
|
||||
$dockerComposeReplacement .= " -f {$composeFilePath}";
|
||||
}
|
||||
|
||||
// Add --env-file flag if not present (checks for --env-file with various formats)
|
||||
// Detects: --env-file path, --env-file=path with any whitespace
|
||||
if (! preg_match('/(?:^|\s)--env-file(?:=|\s)/', $command)) {
|
||||
$dockerComposeReplacement .= " --env-file {$envFilePath}";
|
||||
}
|
||||
|
||||
// Replace only first occurrence to avoid modifying comments/strings/chained commands
|
||||
return preg_replace('/docker\s+compose/', $dockerComposeReplacement, $command, 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -514,84 +514,96 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$key = str($key);
|
||||
$value = replaceVariables($value);
|
||||
$command = parseCommandFromMagicEnvVariable($key);
|
||||
if ($command->value() === 'FQDN') {
|
||||
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
|
||||
$originalFqdnFor = str($fqdnFor)->replace('_', '-');
|
||||
if (str($fqdnFor)->contains('-')) {
|
||||
$fqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_');
|
||||
if ($command->value() === 'FQDN' || $command->value() === 'URL') {
|
||||
// ALWAYS create BOTH SERVICE_URL and SERVICE_FQDN pairs regardless of which one is in template
|
||||
$parsed = parseServiceEnvironmentVariable($key->value());
|
||||
$serviceName = $parsed['service_name'];
|
||||
$port = $parsed['port'];
|
||||
|
||||
// Extract case-preserved service name from template
|
||||
$strKey = str($key->value());
|
||||
if ($parsed['has_port']) {
|
||||
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||
$serviceNamePreserved = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||
} else {
|
||||
$serviceNamePreserved = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
|
||||
}
|
||||
} else {
|
||||
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||
$serviceNamePreserved = $strKey->after('SERVICE_URL_')->value();
|
||||
} else {
|
||||
$serviceNamePreserved = $strKey->after('SERVICE_FQDN_')->value();
|
||||
}
|
||||
}
|
||||
// Generated FQDN & URL
|
||||
$fqdn = generateFqdn(server: $server, random: "$originalFqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||
$url = generateUrl(server: $server, random: "$originalFqdnFor-$uuid");
|
||||
|
||||
$originalServiceName = str($serviceName)->replace('_', '-')->value();
|
||||
// Always normalize service names to match docker_compose_domains lookup
|
||||
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
|
||||
// Generate BOTH FQDN & URL
|
||||
$fqdn = generateFqdn(server: $server, random: "$originalServiceName-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||
$url = generateUrl(server: $server, random: "$originalServiceName-$uuid");
|
||||
|
||||
// IMPORTANT: SERVICE_FQDN env vars should NOT contain scheme (host only)
|
||||
// But $fqdn variable itself may contain scheme (used for database domain field)
|
||||
// Strip scheme for environment variable values
|
||||
$fqdnValueForEnv = str($fqdn)->after('://')->value();
|
||||
|
||||
// Append port if specified
|
||||
$urlWithPort = $url;
|
||||
$fqdnValueForEnvWithPort = $fqdnValueForEnv;
|
||||
if ($port && is_numeric($port)) {
|
||||
$urlWithPort = "$url:$port";
|
||||
$fqdnValueForEnvWithPort = "$fqdnValueForEnv:$port";
|
||||
}
|
||||
|
||||
// ALWAYS create base SERVICE_FQDN variable (host only, no scheme)
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key->value(),
|
||||
'key' => "SERVICE_FQDN_{$serviceNamePreserved}",
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdn,
|
||||
'value' => $fqdnValueForEnv,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
if ($resource->build_pack === 'dockercompose') {
|
||||
// Check if a service with this name actually exists
|
||||
$serviceExists = false;
|
||||
foreach ($services as $serviceName => $service) {
|
||||
$transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
if ($transformedServiceName === $fqdnFor) {
|
||||
$serviceExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only add domain if the service exists
|
||||
if ($serviceExists) {
|
||||
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
|
||||
$domainExists = data_get($domains->get($fqdnFor), 'domain');
|
||||
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
|
||||
if (str($domainExists)->replace('http://', '')->replace('https://', '')->value() !== $envExists->value) {
|
||||
$envExists->update([
|
||||
'value' => $url,
|
||||
]);
|
||||
}
|
||||
if (is_null($domainExists)) {
|
||||
// Put URL in the domains array instead of FQDN
|
||||
$domains->put((string) $fqdnFor, [
|
||||
'domain' => $url,
|
||||
]);
|
||||
$resource->docker_compose_domains = $domains->toJson();
|
||||
$resource->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($command->value() === 'URL') {
|
||||
// SERVICE_URL_APP or SERVICE_URL_APP_3000
|
||||
// Detect if there's a port suffix
|
||||
$parsed = parseServiceEnvironmentVariable($key->value());
|
||||
$urlFor = $parsed['service_name'];
|
||||
$port = $parsed['port'];
|
||||
$originalUrlFor = str($urlFor)->replace('_', '-');
|
||||
if (str($urlFor)->contains('-')) {
|
||||
$urlFor = str($urlFor)->replace('-', '_')->replace('.', '_');
|
||||
}
|
||||
$url = generateUrl(server: $server, random: "$originalUrlFor-$uuid");
|
||||
// Append port if specified
|
||||
$urlWithPort = $url;
|
||||
if ($port && is_numeric($port)) {
|
||||
$urlWithPort = "$url:$port";
|
||||
}
|
||||
// ALWAYS create base SERVICE_URL variable (with scheme)
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key->value(),
|
||||
'key' => "SERVICE_URL_{$serviceNamePreserved}",
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $url,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
// If port-specific, ALSO create port-specific pairs
|
||||
if ($parsed['has_port'] && $port) {
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => "SERVICE_FQDN_{$serviceNamePreserved}_{$port}",
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdnValueForEnvWithPort,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => "SERVICE_URL_{$serviceNamePreserved}_{$port}",
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $urlWithPort,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($resource->build_pack === 'dockercompose') {
|
||||
// Check if a service with this name actually exists
|
||||
$serviceExists = false;
|
||||
foreach ($services as $serviceName => $service) {
|
||||
$transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
if ($transformedServiceName === $urlFor) {
|
||||
foreach ($services as $serviceNameKey => $service) {
|
||||
$transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
|
||||
if ($transformedServiceName === $serviceName) {
|
||||
$serviceExists = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -600,16 +612,14 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
// Only add domain if the service exists
|
||||
if ($serviceExists) {
|
||||
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
|
||||
$domainExists = data_get($domains->get($urlFor), 'domain');
|
||||
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
|
||||
if ($domainExists !== $envExists->value) {
|
||||
$envExists->update([
|
||||
'value' => $urlWithPort,
|
||||
]);
|
||||
}
|
||||
$domainExists = data_get($domains->get($serviceName), 'domain');
|
||||
|
||||
// Update domain using URL with port if applicable
|
||||
$domainValue = $port ? $urlWithPort : $url;
|
||||
|
||||
if (is_null($domainExists)) {
|
||||
$domains->put((string) $urlFor, [
|
||||
'domain' => $urlWithPort,
|
||||
$domains->put($serviceName, [
|
||||
'domain' => $domainValue,
|
||||
]);
|
||||
$resource->docker_compose_domains = $domains->toJson();
|
||||
$resource->save();
|
||||
|
|
@ -1584,92 +1594,115 @@ function serviceParser(Service $resource): Collection
|
|||
}
|
||||
// Get magic environments where we need to preset the FQDN / URL
|
||||
if ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_')) {
|
||||
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
|
||||
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000 or SERVICE_URL_APP or SERVICE_URL_APP_3000
|
||||
// ALWAYS create BOTH SERVICE_URL and SERVICE_FQDN pairs regardless of which one is in template
|
||||
$parsed = parseServiceEnvironmentVariable($key->value());
|
||||
if ($key->startsWith('SERVICE_FQDN_')) {
|
||||
$urlFor = null;
|
||||
$fqdnFor = $parsed['service_name'];
|
||||
}
|
||||
if ($key->startsWith('SERVICE_URL_')) {
|
||||
$fqdnFor = null;
|
||||
$urlFor = $parsed['service_name'];
|
||||
}
|
||||
$port = $parsed['port'];
|
||||
if (blank($savedService->fqdn)) {
|
||||
if ($fqdnFor) {
|
||||
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||
|
||||
// Extract service name preserving original case from template
|
||||
$strKey = str($key->value());
|
||||
if ($parsed['has_port']) {
|
||||
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
|
||||
$serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
|
||||
} else {
|
||||
$fqdn = generateFqdn(server: $server, random: "{$savedService->name}-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||
}
|
||||
if ($urlFor) {
|
||||
$url = generateUrl($server, "$urlFor-$uuid");
|
||||
} else {
|
||||
$url = generateUrl($server, "{$savedService->name}-$uuid");
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
|
||||
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$port = $parsed['port'];
|
||||
$fqdnFor = $parsed['service_name'];
|
||||
|
||||
// Only ServiceApplication has fqdn column, ServiceDatabase does not
|
||||
$isServiceApplication = $savedService instanceof ServiceApplication;
|
||||
|
||||
if ($isServiceApplication && blank($savedService->fqdn)) {
|
||||
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||
$url = generateUrl($server, "$fqdnFor-$uuid");
|
||||
} elseif ($isServiceApplication) {
|
||||
$fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
|
||||
$url = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
|
||||
} else {
|
||||
// For ServiceDatabase, generate fqdn/url without saving to the model
|
||||
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||
$url = generateUrl($server, "$fqdnFor-$uuid");
|
||||
}
|
||||
|
||||
// IMPORTANT: SERVICE_FQDN env vars should NOT contain scheme (host only)
|
||||
// But $fqdn variable itself may contain scheme (used for database domain field)
|
||||
// Strip scheme for environment variable values
|
||||
$fqdnValueForEnv = str($fqdn)->after('://')->value();
|
||||
|
||||
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
||||
$path = $value->value();
|
||||
if ($path !== '/') {
|
||||
$fqdn = "$fqdn$path";
|
||||
$url = "$url$path";
|
||||
$fqdnValueForEnv = "$fqdnValueForEnv$path";
|
||||
}
|
||||
}
|
||||
$fqdnWithPort = $fqdn;
|
||||
|
||||
$urlWithPort = $url;
|
||||
$fqdnValueForEnvWithPort = $fqdnValueForEnv;
|
||||
if ($fqdn && $port) {
|
||||
$fqdnWithPort = "$fqdn:$port";
|
||||
$fqdnValueForEnvWithPort = "$fqdnValueForEnv:$port";
|
||||
}
|
||||
if ($url && $port) {
|
||||
$urlWithPort = "$url:$port";
|
||||
}
|
||||
if (is_null($savedService->fqdn)) {
|
||||
|
||||
// Only save fqdn to ServiceApplication, not ServiceDatabase
|
||||
if ($isServiceApplication && is_null($savedService->fqdn)) {
|
||||
// Save URL (with scheme) to database, not FQDN
|
||||
if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
|
||||
if ($fqdnFor) {
|
||||
$savedService->fqdn = $fqdnWithPort;
|
||||
}
|
||||
if ($urlFor) {
|
||||
$savedService->fqdn = $urlWithPort;
|
||||
}
|
||||
$savedService->fqdn = $urlWithPort;
|
||||
} else {
|
||||
$savedService->fqdn = $fqdnWithPort;
|
||||
$savedService->fqdn = $urlWithPort;
|
||||
}
|
||||
$savedService->save();
|
||||
}
|
||||
if (! $parsed['has_port']) {
|
||||
|
||||
// ALWAYS create BOTH base SERVICE_URL and SERVICE_FQDN pairs (without port)
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => "SERVICE_FQDN_{$serviceName}",
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdnValueForEnv,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => "SERVICE_URL_{$serviceName}",
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $url,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
// For port-specific variables, ALSO create port-specific pairs
|
||||
// If template variable has port, create both URL and FQDN with port suffix
|
||||
if ($parsed['has_port'] && $port) {
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $key->value(),
|
||||
'key' => "SERVICE_FQDN_{$serviceName}_{$port}",
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdn,
|
||||
'value' => $fqdnValueForEnvWithPort,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $key->value(),
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $url,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
if ($parsed['has_port']) {
|
||||
// For port-specific variables (e.g., SERVICE_FQDN_UMAMI_3000),
|
||||
// keep the port suffix in the key and use the URL with port
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $key->value(),
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdnWithPort,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $key->value(),
|
||||
'key' => "SERVICE_URL_{$serviceName}_{$port}",
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
|
|
|
|||
|
|
@ -334,3 +334,93 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
|
|||
|
||||
return $config;
|
||||
}
|
||||
|
||||
function getExactTraefikVersionFromContainer(Server $server): ?string
|
||||
{
|
||||
try {
|
||||
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Checking for exact version");
|
||||
|
||||
// Method A: Execute traefik version command (most reliable)
|
||||
$versionCommand = "docker exec coolify-proxy traefik version 2>/dev/null | grep -oP 'Version:\s+\K\d+\.\d+\.\d+'";
|
||||
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Running: {$versionCommand}");
|
||||
|
||||
$output = instant_remote_process([$versionCommand], $server, false);
|
||||
|
||||
if (! empty(trim($output))) {
|
||||
$version = trim($output);
|
||||
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Detected exact version from command: {$version}");
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
// Method B: Try OCI label as fallback
|
||||
$labelCommand = "docker inspect coolify-proxy --format '{{index .Config.Labels \"org.opencontainers.image.version\"}}' 2>/dev/null";
|
||||
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Trying OCI label");
|
||||
|
||||
$label = instant_remote_process([$labelCommand], $server, false);
|
||||
|
||||
if (! empty(trim($label))) {
|
||||
// Extract version number from label (might have 'v' prefix)
|
||||
if (preg_match('/(\d+\.\d+\.\d+)/', trim($label), $matches)) {
|
||||
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Detected from OCI label: {$matches[1]}");
|
||||
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Could not detect exact version");
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTraefikVersionFromDockerCompose(Server $server): ?string
|
||||
{
|
||||
try {
|
||||
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Starting version detection");
|
||||
|
||||
// Try to get exact version from running container (e.g., "3.6.0")
|
||||
$exactVersion = getExactTraefikVersionFromContainer($server);
|
||||
if ($exactVersion) {
|
||||
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Using exact version: {$exactVersion}");
|
||||
|
||||
return $exactVersion;
|
||||
}
|
||||
|
||||
// Fallback: Check image tag (current method)
|
||||
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Falling back to image tag detection");
|
||||
|
||||
$containerName = 'coolify-proxy';
|
||||
$inspectCommand = "docker inspect {$containerName} --format '{{.Config.Image}}' 2>/dev/null";
|
||||
|
||||
$image = instant_remote_process([$inspectCommand], $server, false);
|
||||
|
||||
if (empty(trim($image))) {
|
||||
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Container '{$containerName}' not found or not running");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$image = trim($image);
|
||||
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Running container image: {$image}");
|
||||
|
||||
// Extract version from image string (e.g., "traefik:v3.6" or "traefik:3.6.0" or "traefik:latest")
|
||||
if (preg_match('/traefik:(v?\d+\.\d+(?:\.\d+)?|latest)/i', $image, $matches)) {
|
||||
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Extracted version from image tag: {$matches[1]}");
|
||||
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Image format doesn't match expected pattern: {$image}");
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,65 +115,170 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
|
|||
$resource->save();
|
||||
}
|
||||
|
||||
$serviceName = str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
|
||||
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")->delete();
|
||||
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")->delete();
|
||||
// Extract SERVICE_URL and SERVICE_FQDN variable names from the compose template
|
||||
// to ensure we use the exact names defined in the template (which may be abbreviated)
|
||||
// IMPORTANT: Only extract variables that are DIRECTLY DECLARED for this service,
|
||||
// not variables that are merely referenced from other services
|
||||
$serviceConfig = data_get($dockerCompose, "services.{$name}");
|
||||
$environment = data_get($serviceConfig, 'environment', []);
|
||||
$templateVariableNames = [];
|
||||
|
||||
foreach ($environment as $key => $value) {
|
||||
if (is_int($key) && is_string($value)) {
|
||||
// List-style: "- SERVICE_URL_APP_3000" or "- SERVICE_URL_APP_3000=value"
|
||||
// Extract variable name (before '=' if present)
|
||||
$envVarName = str($value)->before('=')->trim();
|
||||
// Only include if it's a direct declaration (not a reference like ${VAR})
|
||||
// Direct declarations look like: SERVICE_URL_APP or SERVICE_URL_APP_3000
|
||||
// References look like: NEXT_PUBLIC_URL=${SERVICE_URL_APP}
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
} elseif (is_string($key)) {
|
||||
// Map-style: "SERVICE_URL_APP_3000: value" or "SERVICE_FQDN_DB: localhost"
|
||||
$envVarName = str($key);
|
||||
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||
$templateVariableNames[] = $envVarName->value();
|
||||
}
|
||||
}
|
||||
// DO NOT extract variables that are only referenced with ${VAR_NAME} syntax
|
||||
// Those belong to other services and will be updated when THOSE services are updated
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
$templateVariableNames = array_unique($templateVariableNames);
|
||||
|
||||
// Extract unique service names to process (preserving the original case from template)
|
||||
// This allows us to create both URL and FQDN pairs regardless of which one is in the template
|
||||
$serviceNamesToProcess = [];
|
||||
foreach ($templateVariableNames as $templateVarName) {
|
||||
$parsed = parseServiceEnvironmentVariable($templateVarName);
|
||||
|
||||
// Extract the original service name with case preserved from the template
|
||||
$strKey = str($templateVarName);
|
||||
if ($parsed['has_port']) {
|
||||
// For port-specific variables, get the name between SERVICE_URL_/SERVICE_FQDN_ and the last underscore
|
||||
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
|
||||
$serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// For base variables, get everything after SERVICE_URL_/SERVICE_FQDN_
|
||||
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
|
||||
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Use lowercase key for array indexing (to group case variations together)
|
||||
$serviceKey = str($serviceName)->lower()->value();
|
||||
|
||||
// Track both base service name and port-specific variant
|
||||
if (! isset($serviceNamesToProcess[$serviceKey])) {
|
||||
$serviceNamesToProcess[$serviceKey] = [
|
||||
'base' => $serviceName, // Preserve original case
|
||||
'ports' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// If this variable has a port, track it
|
||||
if ($parsed['has_port'] && $parsed['port']) {
|
||||
$serviceNamesToProcess[$serviceKey]['ports'][] = $parsed['port'];
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all existing SERVICE_URL and SERVICE_FQDN variables for these service names
|
||||
// We need to delete both URL and FQDN variants, with and without ports
|
||||
foreach ($serviceNamesToProcess as $serviceInfo) {
|
||||
$serviceName = $serviceInfo['base'];
|
||||
|
||||
// Delete base variables
|
||||
$resource->service->environment_variables()->where('key', "SERVICE_URL_{$serviceName}")->delete();
|
||||
$resource->service->environment_variables()->where('key', "SERVICE_FQDN_{$serviceName}")->delete();
|
||||
|
||||
// Delete port-specific variables
|
||||
foreach ($serviceInfo['ports'] as $port) {
|
||||
$resource->service->environment_variables()->where('key', "SERVICE_URL_{$serviceName}_{$port}")->delete();
|
||||
$resource->service->environment_variables()->where('key', "SERVICE_FQDN_{$serviceName}_{$port}")->delete();
|
||||
}
|
||||
}
|
||||
|
||||
if ($resource->fqdn) {
|
||||
$resourceFqdns = str($resource->fqdn)->explode(',');
|
||||
$resourceFqdns = $resourceFqdns->first();
|
||||
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
|
||||
$url = Url::fromString($resourceFqdns);
|
||||
$port = $url->getPort();
|
||||
$path = $url->getPath();
|
||||
|
||||
// Prepare URL value (with scheme and host)
|
||||
$urlValue = $url->getScheme().'://'.$url->getHost();
|
||||
$urlValue = ($path === '/') ? $urlValue : $urlValue.$path;
|
||||
$resource->service->environment_variables()->updateOrCreate([
|
||||
'resourceable_type' => Service::class,
|
||||
'resourceable_id' => $resource->service_id,
|
||||
'key' => $variableName,
|
||||
], [
|
||||
'value' => $urlValue,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
if ($port) {
|
||||
$variableName = $variableName."_$port";
|
||||
|
||||
// Prepare FQDN value (host only, no scheme)
|
||||
$fqdnHost = $url->getHost();
|
||||
$fqdnValue = str($fqdnHost)->after('://');
|
||||
if ($path !== '/') {
|
||||
$fqdnValue = $fqdnValue.$path;
|
||||
}
|
||||
|
||||
// For each service name found in template, create BOTH SERVICE_URL and SERVICE_FQDN pairs
|
||||
foreach ($serviceNamesToProcess as $serviceInfo) {
|
||||
$serviceName = $serviceInfo['base'];
|
||||
$ports = array_unique($serviceInfo['ports']);
|
||||
|
||||
// ALWAYS create base pair (without port)
|
||||
$resource->service->environment_variables()->updateOrCreate([
|
||||
'resourceable_type' => Service::class,
|
||||
'resourceable_id' => $resource->service_id,
|
||||
'key' => $variableName,
|
||||
'key' => "SERVICE_URL_{$serviceName}",
|
||||
], [
|
||||
'value' => $urlValue,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
|
||||
$fqdn = Url::fromString($resourceFqdns);
|
||||
$port = $fqdn->getPort();
|
||||
$path = $fqdn->getPath();
|
||||
$fqdn = $fqdn->getHost();
|
||||
$fqdnValue = str($fqdn)->after('://');
|
||||
if ($path !== '/') {
|
||||
$fqdnValue = $fqdnValue.$path;
|
||||
}
|
||||
$resource->service->environment_variables()->updateOrCreate([
|
||||
'resourceable_type' => Service::class,
|
||||
'resourceable_id' => $resource->service_id,
|
||||
'key' => $variableName,
|
||||
], [
|
||||
'value' => $fqdnValue,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
if ($port) {
|
||||
$variableName = $variableName."_$port";
|
||||
|
||||
$resource->service->environment_variables()->updateOrCreate([
|
||||
'resourceable_type' => Service::class,
|
||||
'resourceable_id' => $resource->service_id,
|
||||
'key' => $variableName,
|
||||
'key' => "SERVICE_FQDN_{$serviceName}",
|
||||
], [
|
||||
'value' => $fqdnValue,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
// Create port-specific pairs for each port found in template or FQDN
|
||||
$allPorts = $ports;
|
||||
if ($port && ! in_array($port, $allPorts)) {
|
||||
$allPorts[] = $port;
|
||||
}
|
||||
|
||||
foreach ($allPorts as $portNum) {
|
||||
$urlWithPort = $urlValue.':'.$portNum;
|
||||
$fqdnWithPort = $fqdnValue.':'.$portNum;
|
||||
|
||||
$resource->service->environment_variables()->updateOrCreate([
|
||||
'resourceable_type' => Service::class,
|
||||
'resourceable_id' => $resource->service_id,
|
||||
'key' => "SERVICE_URL_{$serviceName}_{$portNum}",
|
||||
], [
|
||||
'value' => $urlWithPort,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$resource->service->environment_variables()->updateOrCreate([
|
||||
'resourceable_type' => Service::class,
|
||||
'resourceable_id' => $resource->service_id,
|
||||
'key' => "SERVICE_FQDN_{$serviceName}_{$portNum}",
|
||||
], [
|
||||
'value' => $fqdnWithPort,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -241,10 +241,9 @@ function get_latest_sentinel_version(): string
|
|||
function get_latest_version_of_coolify(): string
|
||||
{
|
||||
try {
|
||||
$versions = File::get(base_path('versions.json'));
|
||||
$versions = json_decode($versions, true);
|
||||
$versions = get_versions_data();
|
||||
|
||||
return data_get($versions, 'coolify.v4.version');
|
||||
return data_get($versions, 'coolify.v4.version', '0.0.0');
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
return '0.0.0';
|
||||
|
|
@ -3154,3 +3153,46 @@ function generateDockerComposeServiceName(mixed $services, int $pullRequestId =
|
|||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform colon-delimited status format to human-readable parentheses format.
|
||||
*
|
||||
* Handles Docker container status formats with optional health check status and exclusion modifiers.
|
||||
*
|
||||
* Examples:
|
||||
* - running:healthy → Running (healthy)
|
||||
* - running:unhealthy:excluded → Running (unhealthy, excluded)
|
||||
* - exited:excluded → Exited (excluded)
|
||||
* - Proxy:running → Proxy:running (preserved as-is for headline formatting)
|
||||
* - running → Running
|
||||
*
|
||||
* @param string $status The status string to format
|
||||
* @return string The formatted status string
|
||||
*/
|
||||
function formatContainerStatus(string $status): string
|
||||
{
|
||||
// Preserve Proxy statuses as-is (they follow different format)
|
||||
if (str($status)->startsWith('Proxy')) {
|
||||
return str($status)->headline()->value();
|
||||
}
|
||||
|
||||
// Check for :excluded suffix
|
||||
$isExcluded = str($status)->endsWith(':excluded');
|
||||
$parts = explode(':', $status);
|
||||
|
||||
if ($isExcluded) {
|
||||
if (count($parts) === 3) {
|
||||
// Has health status: running:unhealthy:excluded → Running (unhealthy, excluded)
|
||||
return str($parts[0])->headline().' ('.$parts[1].', excluded)';
|
||||
} else {
|
||||
// No health status: exited:excluded → Exited (excluded)
|
||||
return str($parts[0])->headline().' (excluded)';
|
||||
}
|
||||
} elseif (count($parts) >= 2) {
|
||||
// Regular colon format: running:healthy → Running (healthy)
|
||||
return str($parts[0])->headline().' ('.$parts[1].')';
|
||||
} else {
|
||||
// Simple status: running → Running
|
||||
return str($status)->headline()->value();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
bootstrap/helpers/versions.php
Normal file
53
bootstrap/helpers/versions.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Get cached versions data from versions.json.
|
||||
*
|
||||
* This function provides a centralized, cached access point for all
|
||||
* version data in the application. Data is cached in Redis for 1 hour
|
||||
* and shared across all servers in the cluster.
|
||||
*
|
||||
* @return array|null The versions data array, or null if file doesn't exist
|
||||
*/
|
||||
function get_versions_data(): ?array
|
||||
{
|
||||
return Cache::remember('coolify:versions:all', 3600, function () {
|
||||
$versionsPath = base_path('versions.json');
|
||||
|
||||
if (! File::exists($versionsPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode(File::get($versionsPath), true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Traefik versions from cached data.
|
||||
*
|
||||
* @return array|null Array of Traefik versions (e.g., ['v3.5' => '3.5.6'])
|
||||
*/
|
||||
function get_traefik_versions(): ?array
|
||||
{
|
||||
$versions = get_versions_data();
|
||||
|
||||
if (! $versions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$traefikVersions = data_get($versions, 'traefik');
|
||||
|
||||
return is_array($traefikVersions) ? $traefikVersions : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the versions cache.
|
||||
* Call this after updating versions.json to ensure fresh data is loaded.
|
||||
*/
|
||||
function invalidate_versions_cache(): void
|
||||
{
|
||||
Cache::forget('coolify:versions:all');
|
||||
}
|
||||
72
composer.lock
generated
72
composer.lock
generated
|
|
@ -9514,16 +9514,16 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v7.3.2",
|
||||
"version": "v7.3.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-foundation.git",
|
||||
"reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6"
|
||||
"reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6",
|
||||
"reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6",
|
||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/db488a62f98f7a81d5746f05eea63a74e55bb7c4",
|
||||
"reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -9573,7 +9573,7 @@
|
|||
"description": "Defines an object-oriented layer for the HTTP specification",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-foundation/tree/v7.3.2"
|
||||
"source": "https://github.com/symfony/http-foundation/tree/v7.3.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -9593,7 +9593,7 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-07-10T08:47:49+00:00"
|
||||
"time": "2025-11-08T16:41:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-kernel",
|
||||
|
|
@ -9799,16 +9799,16 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/mime",
|
||||
"version": "v7.3.2",
|
||||
"version": "v7.3.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/mime.git",
|
||||
"reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1"
|
||||
"reference": "b1b828f69cbaf887fa835a091869e55df91d0e35"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1",
|
||||
"reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1",
|
||||
"url": "https://api.github.com/repos/symfony/mime/zipball/b1b828f69cbaf887fa835a091869e55df91d0e35",
|
||||
"reference": "b1b828f69cbaf887fa835a091869e55df91d0e35",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -9863,7 +9863,7 @@
|
|||
"mime-type"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/mime/tree/v7.3.2"
|
||||
"source": "https://github.com/symfony/mime/tree/v7.3.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -9883,7 +9883,7 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-07-15T13:41:35+00:00"
|
||||
"time": "2025-09-16T08:38:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/options-resolver",
|
||||
|
|
@ -10195,7 +10195,7 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-idn",
|
||||
"version": "v1.32.0",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-intl-idn.git",
|
||||
|
|
@ -10258,7 +10258,7 @@
|
|||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0"
|
||||
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -10269,6 +10269,10 @@
|
|||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
|
|
@ -10278,7 +10282,7 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-normalizer",
|
||||
"version": "v1.32.0",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
|
||||
|
|
@ -10339,7 +10343,7 @@
|
|||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0"
|
||||
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -10350,6 +10354,10 @@
|
|||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
|
|
@ -10359,7 +10367,7 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-mbstring",
|
||||
"version": "v1.32.0",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||
|
|
@ -10420,7 +10428,7 @@
|
|||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0"
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -10431,6 +10439,10 @@
|
|||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
|
|
@ -10440,7 +10452,7 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php80",
|
||||
"version": "v1.32.0",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php80.git",
|
||||
|
|
@ -10500,7 +10512,7 @@
|
|||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0"
|
||||
"source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -10511,6 +10523,10 @@
|
|||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
|
|
@ -10520,16 +10536,16 @@
|
|||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php83",
|
||||
"version": "v1.32.0",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php83.git",
|
||||
"reference": "2fb86d65e2d424369ad2905e83b236a8805ba491"
|
||||
"reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491",
|
||||
"reference": "2fb86d65e2d424369ad2905e83b236a8805ba491",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5",
|
||||
"reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -10576,7 +10592,7 @@
|
|||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0"
|
||||
"source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -10587,12 +10603,16 @@
|
|||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-09T11:45:10+00:00"
|
||||
"time": "2025-07-08T02:45:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-uuid",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"scripts": {
|
||||
"setup": "./scripts/conductor-setup.sh",
|
||||
"run": "docker rm -f coolify coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down"
|
||||
"run": "docker rm -f coolify coolify-minio-init coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down"
|
||||
},
|
||||
"runScriptMode": "nonconcurrent"
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.0.0-beta.444',
|
||||
'version' => '4.0.0-beta.445',
|
||||
'helper_version' => '1.0.12',
|
||||
'realtime_version' => '1.0.10',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
|
@ -95,4 +95,27 @@
|
|||
'storage_api_key' => env('BUNNY_STORAGE_API_KEY'),
|
||||
'api_key' => env('BUNNY_API_KEY'),
|
||||
],
|
||||
|
||||
'server_checks' => [
|
||||
// Notification delay configuration for parallel server checks
|
||||
// Used for Traefik version checks and other future server check jobs
|
||||
// These settings control how long to wait before sending notifications
|
||||
// after dispatching parallel check jobs for all servers
|
||||
|
||||
// Minimum delay in seconds (120s = 2 minutes)
|
||||
// Accounts for job processing time, retries, and network latency
|
||||
'notification_delay_min' => 120,
|
||||
|
||||
// Maximum delay in seconds (300s = 5 minutes)
|
||||
// Prevents excessive waiting for very large server counts
|
||||
'notification_delay_max' => 300,
|
||||
|
||||
// Scaling factor: seconds to add per server (0.2)
|
||||
// Formula: delay = min(max, max(min, serverCount * scaling))
|
||||
// Examples:
|
||||
// - 100 servers: 120s (uses minimum)
|
||||
// - 1000 servers: 200s
|
||||
// - 2000 servers: 300s (hits maximum)
|
||||
'notification_delay_scaling' => 0.2,
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ public function up(): void
|
|||
$table->string('name');
|
||||
$table->text('script'); // Encrypted in the model
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('team_id');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->string('detected_traefik_version')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->dropColumn('detected_traefik_version');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('email_notification_settings', function (Blueprint $table) {
|
||||
$table->boolean('traefik_outdated_email_notifications')->default(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('email_notification_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('traefik_outdated_email_notifications');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('telegram_notification_settings', function (Blueprint $table) {
|
||||
$table->text('telegram_notifications_traefik_outdated_thread_id')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('telegram_notification_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('telegram_notifications_traefik_outdated_thread_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->json('traefik_outdated_info')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->dropColumn('traefik_outdated_info');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('discord_notification_settings', function (Blueprint $table) {
|
||||
$table->boolean('traefik_outdated_discord_notifications')->default(true);
|
||||
});
|
||||
|
||||
Schema::table('slack_notification_settings', function (Blueprint $table) {
|
||||
$table->boolean('traefik_outdated_slack_notifications')->default(true);
|
||||
});
|
||||
|
||||
Schema::table('webhook_notification_settings', function (Blueprint $table) {
|
||||
$table->boolean('traefik_outdated_webhook_notifications')->default(true);
|
||||
});
|
||||
|
||||
Schema::table('telegram_notification_settings', function (Blueprint $table) {
|
||||
$table->boolean('traefik_outdated_telegram_notifications')->default(true);
|
||||
});
|
||||
|
||||
Schema::table('pushover_notification_settings', function (Blueprint $table) {
|
||||
$table->boolean('traefik_outdated_pushover_notifications')->default(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('discord_notification_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('traefik_outdated_discord_notifications');
|
||||
});
|
||||
|
||||
Schema::table('slack_notification_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('traefik_outdated_slack_notifications');
|
||||
});
|
||||
|
||||
Schema::table('webhook_notification_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('traefik_outdated_webhook_notifications');
|
||||
});
|
||||
|
||||
Schema::table('telegram_notification_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('traefik_outdated_telegram_notifications');
|
||||
});
|
||||
|
||||
Schema::table('pushover_notification_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('traefik_outdated_pushover_notifications');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Clear dockerfile fields for applications not using dockerfile buildpack
|
||||
DB::table('applications')
|
||||
->where('build_pack', '!=', 'dockerfile')
|
||||
->update([
|
||||
'dockerfile' => null,
|
||||
'dockerfile_location' => null,
|
||||
'dockerfile_target_build' => null,
|
||||
'custom_healthcheck_found' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// No rollback needed - we're cleaning up corrupt data
|
||||
}
|
||||
};
|
||||
|
|
@ -113,6 +113,8 @@ public function run(): void
|
|||
$server_details['proxy'] = ServerMetadata::from([
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
'last_saved_settings' => null,
|
||||
'last_applied_settings' => null,
|
||||
]);
|
||||
$server = Server::create($server_details);
|
||||
$server->settings->is_reachable = true;
|
||||
|
|
@ -177,6 +179,8 @@ public function run(): void
|
|||
$server_details['proxy'] = ServerMetadata::from([
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
'last_saved_settings' => null,
|
||||
'last_applied_settings' => null,
|
||||
]);
|
||||
$server = Server::create($server_details);
|
||||
$server->settings->is_reachable = true;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public function run(): void
|
|||
'bucket' => 'local',
|
||||
'endpoint' => 'http://coolify-minio:9000',
|
||||
'team_id' => 0,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,26 @@ services:
|
|||
- dev_minio_data:/data
|
||||
networks:
|
||||
- coolify
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
pull_policy: always
|
||||
container_name: coolify-minio-init
|
||||
restart: no
|
||||
depends_on:
|
||||
- minio
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
echo 'Waiting for MinIO to be ready...';
|
||||
until mc alias set local http://coolify-minio:9000 minioadmin minioadmin 2>/dev/null; do
|
||||
echo 'MinIO not ready yet, waiting...';
|
||||
sleep 2;
|
||||
done;
|
||||
echo 'MinIO is ready, creating bucket if needed...';
|
||||
mc mb local/local --ignore-existing;
|
||||
echo 'MinIO initialization complete - bucket local is ready';
|
||||
"
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
volumes:
|
||||
dev_backups_data:
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.443"
|
||||
"version": "4.0.0-beta.445"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.444"
|
||||
"version": "4.0.0-beta.446"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.11"
|
||||
"version": "1.0.12"
|
||||
},
|
||||
"realtime": {
|
||||
"version": "1.0.10"
|
||||
|
|
|
|||
1
public/svgs/opnform.svg
Normal file
1
public/svgs/opnform.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" height="120" viewBox="0 0 120 120" width="120" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="60" x2="60" y1="2" y2="118.352"><stop offset="0" stop-color="#2563eb"/><stop offset="1" stop-color="#60a5fa"/></linearGradient><path clip-rule="evenodd" d="m80.6502 118.352c22.9638-8.418 39.3498-30.4712 39.3498-56.352 0-33.1371-26.8629-60-60-60s-60 26.8629-60 60c0 25.8808 16.3862 47.934 39.3498 56.352l16.631-38.9411c-7.4746-1.9924-12.9808-8.8086-12.9808-16.9109 0-9.665 7.835-17.5 17.5-17.5s17.5 7.835 17.5 17.5c0 8.4269-5.9563 15.4627-13.8885 17.1269z" fill="url(#a)" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 698 B |
18
public/svgs/palworld.svg
Normal file
18
public/svgs/palworld.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="0 0 2492.7 661.9" style="enable-background:new 0 0 2492.7 661.9;" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M1250.6,661.9c-9.6-9.4-19.2-18.7-28.7-27.9c10.4-10.4,20-19.9,29.5-29.4c9.1,9.3,18.6,19,28.6,29.3 c-7.3,7.2-15.2,15-23,22.8c-1.6,1.6-3,3.5-4.5,5.2C1252,661.9,1251.3,661.9,1250.6,661.9z M1233.2,632.7c6.1,6.2,12.4,12.7,18.6,19 c6.1-6.1,12.5-12.4,18.7-18.6c-6.4-6.5-12.7-12.9-18.8-19.1C1245.4,620.3,1239.1,626.7,1233.2,632.7z"></path>
|
||||
<path d="M859.3,245.1c4.9,0,9,0,13.1,0c22.8,0,45.7-0.1,68.5,0.1c2.2,0,4.7,1.1,6.6,2.4c4.3,2.9,8.3,2.7,12.2-1 c0.7-0.7,1.8-1.4,2.8-1.4c6.5-0.1,13-0.1,19.7-0.1c-1.7,6.1-3.6,11.7-4.7,17.5c-2.8,13.7-0.3,27.1,2.3,40.5 c2.2,11.6,4.9,23.1,6.3,34.7c0.7,5.3-1.1,11-1.8,16.7c3.9,0.3,5.5,3.5,6.2,7.3c3.1,16.2,6.1,32.3,9,48.5c0.3,1.8,0.5,4.3-0.4,5.5 c-2.5,3.1-2.2,5.9-0.4,8.9c0.5,0.8,1.2,1.4,1.8,2.1c0.7-0.8,1.8-1.4,2.1-2.3c4.8-15.5,9.4-31,14.2-46.5c4-13.1,8-26.1,12.2-39.1 c0.4-1.1,1.9-2.6,2.9-2.6c5.1-0.2,10.4-2.7,15.4,0.6c0.4,0.3,1.3,0,1.9-0.1c0.3-0.1,0.5-0.4,1.6-1.5c-3.8-5.3-7.6-11-11.8-16.2 c-1.9-2.3-2.7-4.1-1.7-7.1c6-18.8,11.7-37.6,17.8-56.4c1-3,3.3-5.6,5.1-8.4c0.5,0.2,1,0.3,1.4,0.5c3.6,11.5,7.2,23.1,10.9,34.6 c8.4,26.5,16.7,53.1,25.3,79.5c1.9,5.7,3.2,10.6-3,14.4c-1.3,0.8-2.2,2.4-3.2,3.6c0.3,0.5,0.5,1,0.6,1c9.4-0.7,13.4,4.5,15.5,13 c2.6,10.4,6.2,20.6,10.2,30.9c0.9-4.9,1.9-9.8,2.7-14.7c3.3-19.1,6.6-38.2,9.8-57.3c3.2-19.7,6.5-39.3,9.3-59.1 c1.9-13.8,2.5-27.8-1.9-41.4c-0.8-2.4,3.6-7.1,6.6-7.1c26.2,0,52.3,0,78.5,0.1c3.9,0,6.8,2,6.3,7.9c3.5-2.4,6-5.1,9.1-6.1 c3.9-1.2,8.2-1.2,12.4-1.2c0.3,0,1.6,3.8,1,4.7c-10.6,15.4-16.3,32.9-20.5,50.9c-1.5,6.4-2.9,12.5-7.5,17.5c-0.5,0.6-1,1.9-0.8,2.2 c5.2,5.2,0.9,10.3-0.3,15.3c-5.5,24-11.2,48-17.4,71.8c-1.1,4.3-5.5,7.5-7.8,11.6c-2.9,5.2-8.2,7-13.2,9.9c3,2.5,5.7,4.2,7.8,6.6 c3.5,4,4.5,8.9,3,14c-3.2,11.1-6.5,22.2-9.6,33.4c-0.5,1.9-0.9,3.9-0.8,5.8c0.2,3.4-0.4,6.2-3.4,8.3c-0.7,0.5-1.5,1.6-1.6,2.4 c-0.1,6.4-0.1,12.6-2.6,18.9c-2.2,5.6-1.9,12.2-3.1,18.2c-1.1,5.7-2.6,11.3-4.3,16.9c-0.7,2.3-1.8,6-3.3,6.3 c-4.5,0.9-9.4,1.2-12.7-3.3c-1-1.4-2.1-2.8-3.8-5c1.4,7.7,0.5,8.7-6.5,8.7c-15,0-30-0.1-45,0.1c-4.1,0-6-1.2-5.2-3.7 c-2.6-2.8-5.5-4.5-6.1-6.9c-3.4-11.8-6.6-23.8-8.9-35.9c-0.6-3.2,2.5-7,4.1-11.2c0.2,0.5,0.1,0,0,0c-10-1.7-9.6-10.4-11.4-17.3 c-3.7-14.1-6.9-28.3-10.4-42.4c0-0.2-0.5-0.3-1.3-0.8c-2.5,10.7-5.1,21.3-7.5,31.9c-6.3,27.2-12.5,54.3-18.6,81.5 c-0.8,3.5-1.9,5.1-5.9,4.8c-4.8-0.4-9.9,0.8-14.4-0.3s-8.3-0.4-12.6-0.1c-15.4,1.1-30.9,0.4-46.9,0.4c-2.2-8.4-4.4-16.7-6.5-24.9 c-4.4-16.8-8.7-33.7-13.2-50.5c-0.8-3.1-1.8-6,2.2-8.2c-9.4-7.4-8.8-18.9-11.7-28.7c-6.8-23.5-12.6-47.2-18.7-70.9 c-5.6-21.6-11-43.3-16.6-64.9c-1.6-6.4-3.7-12.6-5.3-19c-0.9-3.9-3.1-7.3-0.4-12c1.7-3.1-0.1-8.3-0.6-12.5 c-0.1-0.5-1.5-1.1-2.4-1.3c-5.2-1.3-8.6-4.4-11.2-9.1C866.2,254.2,862.8,250.1,859.3,245.1z"></path>
|
||||
<path d="M186.1,0c-13,28.3-23.1,57.4-27.8,89c3.9-4.4,7-8.7,10.9-12.1s8.6-5.9,13-8.9c0.5,0.3,0.9,0.6,1.4,1 c-1.1,3.1-1.8,6.5-3.4,9.2c-7.6,12.6-12.1,26.1-13.5,40.7c-0.3,3.3,0.5,6.6,0.5,9.9c0,2.6-0.3,5.2-0.5,7.7c-0.1,0.8-0.3,1.8,0,2.5 c3.4,5.7,6.4,11.7,10.4,17c3.4,4.4,7.9,8,11.9,11.9c2.5,2.4,5,4.9,7.6,7.3c0.8,0.7,2,0.9,3.1,1.1c6.3,0.8,12.6,1.5,18.8,2.2 c3,0.3,5.9,0.7,8.9,0.8c18.1,0.4,36.3,0,54.4,1.1c30.4,1.8,59.9,7.6,86.2,24.3c3.1,2,5.9,4.3,8.8,6.5c2.7,2.1,4.9,4.2,2.1,7.9 c-0.4,0.5-0.6,1.8-0.2,2.3c1,1.7,2.7,3.1,3.4,5c0.6,1.8,0.3,3.9,0.4,5.6c2.3,0.4,4.5,0.8,7.7,1.3c2.8-1.7,6-1.2,7.7,3.3 c3.8,10.1,8.1,20.1,9.9,30.6c3.7,21.3,1,42.2-7.2,62.4c-9.2,22.6-25.1,39.3-45.8,51.5c-15.9,9.4-33.2,15.4-51.5,17.8 c-2.1,0.3-4.7-1.1-6.6-2.5c-2.5-1.7-4.5-4.1-7.5-7c0.7,2.8,0.9,4.6,1.6,6.2c1.4,3.6-0.4,5.2-3.5,5.3c-10.5,0.3-20.9,0.6-31.4,0.4 c-3.1-0.1-3.4,1.2-3.3,3.4c0.2,5.3,0.6,10.6,0.6,15.8c0.1,29.5-0.2,59,0.3,88.5c0.2,10,2.3,20.1,3.4,30.1c0.3,2.7,0.3,5.5,0.3,8.3 c0,1.5-0.3,3,0.6,4.8c1.1-1.5,2.2-3,3.8-5c3,4.4,5.8,8.6,9.2,13.6c-5.2,0-9.6,0-14,0c-7.7,0-15.3,0.3-23-0.1 c-2.5-0.1-4.9-1.8-7.4-2.6c-1.1-0.3-2.3-0.5-3.4-0.3c-6,0.9-11.9,2.8-17.9,2.9c-25.7,0.3-51.3,0.1-77,0.1c-8.4,0-8.7-0.4-7.4-8.9 c-1.1,0.9-2.2,1.4-2.7,2.2c-3.2,6.4-8.7,7.2-15.1,6.7c-5.6-0.4-11.3-0.1-16.9-0.1c-0.3-0.5-0.5-0.9-0.8-1.4c1.1-0.9,2-2.1,3.3-2.8 c3.1-1.6,7-2.4,9.5-4.7c14.4-13.2,28.1-27.2,35.3-46c4.5-11.7,8.7-23.5,12.2-35.5c1.4-4.8,3.4-6.8,8.2-6.6c1.3,0.1,2.7-0.6,4-0.9 c-0.5-0.3-1-0.7-1.5-1c0.8-2.8,1.3-5.6,2.3-8.3c0.6-1.5,1.8-2.7,2.9-3.9c7.2-7.4,7.2-7.4,0.2-14.5c-6-6-7.4-13.5-6.7-21.6 c1-11.5,2-23.1,2.8-34.6c0.6-8.5,1-16.9,0.9-25.4c-0.1-15.3-0.1-30.6-1.1-45.9c-1.3-19.9-1.6-40-6.9-59.4c-0.2-0.7-0.4-1.7-0.2-2.3 c3.2-7.7,10.6-11.6,18.3-9.3c-2.8-5.8-5.6-10.7-12.5-12.2c-3-0.6-5.6-3.2-8.3-4.9c-3.3-2-6.6-4-9.8-6.1c-1.4-0.9-2.9-1.7-3.8-2.9 c-2.6-3.2-4.9-6.6-7.3-10c-0.4-0.6-1.1-1.7-0.9-2.1c3-5.3-1.9-5.1-4.4-6.6c-2-1.2-4.1-2.4-5.7-4c-8.3-8.6-19.4-12.5-29.5-18.1 c-19.3-10.8-37.5-23-52.9-39c-15.1-15.6-26.3-33.6-31.3-55c-0.5-2.3-2.9-5.1,1.1-7.4c0.7-0.4-0.7-5.2-1.7-7.7 c-2.9-6.9-1.5-25.6,1.9-31c3.4,10.8,5.9,23,11,34c6.2,13.3,14.7,25.5,22.1,38.2c2.9,4.9,5.5,10,8.2,15c1.8-1.9,3.8-1.1,6.4,1.5 c5.2,5.1,10.9,9.7,16.5,14.4c1.2,1,2.7,1.7,3.3,2c3.5-9.4,6.7-18.5,10.5-27.4c1.3-3,4.4-5.2,6.2-8.1c2.1-3.4,3.5-7.3,5.7-10.6 c2.6-3.9,5.8-7.4,9.8-10.5c-1.4,3.9-3,7.7-4,11.6c-2.6,9.3-4.1,18.9-7.5,27.9c-2.8,7.4-2.1,14.5-2.3,21.9c-0.1,4.4,1.3,6.8,5.4,8.8 c16.1,7.9,32.1,16.2,48.1,24.4c1,0.5,2,0.9,2.8,1.3c-2.7-8.8-5.7-17.6-8-26.5c-3.4-13.1-3.2-26.4-1.2-39.8 c5.8-39.4,24.2-73,47.3-104.5C184.4,2,185.3,1,186.1,0z M249.9,277.5c-2-0.6-3.4-1.1-3.9-1.2c1.4,7.5,3.7,15,3.9,22.5 c0.6,19.6,0.5,39.3,0.1,59c-0.1,5.8-2,11.6-3,17.3c-0.2,0.9,0,1.8,0,2.9c3.7-2.4,6.8-5.7,10.5-6.7c17-4.8,29.8-15.4,41.3-28.1 c2.7-2.9,4.9-5.9,8.1-4c0.2-2.4-0.5-5.5,0.7-7.3c13.3-19.3,18.1-40.1,10.9-62.9c-4.2-13.5-12.9-23.3-26-29.4 c-12.3-5.8-25.4-6.3-38.5-7.8c-3.5-0.4-4.6,1.1-4.5,4.6c0.3,12,0.4,24,0.5,35.9C250.1,274,250,275.6,249.9,277.5z"></path>
|
||||
<path d="M2135.9,560.4c0.6-4.2,0.3-8.5,1.9-11.9c5.3-11.2,7.8-22.9,6.8-35.1c-0.8-10.1,2.1-19.2,5.5-28.3c2-5.4,4-10.6,8.2-14.9 c2.3-2.4,2.9-6.3,4.8-10.9c-5.1,1.1-9.2,1.6-13.1,2.9c-4.3,1.5-5.3,0.1-5.3-4c0.1-36.2-0.2-72.3,0.2-108.5 c0.1-6.5,3.1-12.9,4.8-19.3c0.3-1.4,1-3.2,0.4-4.1c-5.3-7.9-5.8-16.7-5.4-25.6c0.7-14.1-0.7-27.8-7.2-40.6 c-2.4-4.8-5.3-9.4-8.2-14.6c1.2-0.2,2.1-0.4,3-0.4c13.8,0,27.7,0.2,41.5,0.1c6.6-0.1,11.8,2.6,16.7,6.6c4.4,3.6,8.7,7.3,13.2,10.8 c0.5,0.4,1.5,0.3,2.8,0.4c0-5.9,0-11.5,0-17.3c1.5-0.2,2.6-0.5,3.7-0.5c15.3,0,30.7-0.6,46,0.2c26.2,1.3,50.3,9.3,72.2,24.1 c13,8.8,24.1,19.3,33.7,31.5c4.7,6,4.7,13.1,0.4,19.5c-0.4,0.6-0.5,1.4-1.1,3c4.4-2.4,6.9-2.2,11.2,0.6 c12.7,8.3,13.3,22.1,17.1,34.2c5.2,16.6,6.2,33.9,5.6,51.3c-0.1,3.9-1.3,6.7-5.6,6.7c0.9,4.3,2.7,8.5,2.6,12.6 c-0.4,9-1.1,18.1-2.8,26.9c-2.9,15.4-6.7,30.7-9.7,46.1c-1.9,10.1,0.5,19,7.5,27.2c19.5,22.6,55.6,22.8,75.6,7.5 c7.5-5.7,11.5-13.8,14.6-22.5c4.9-13.5,2.3-26.6-1.8-40.6c-7.3,7.3-15.1,9.9-23.9,4.4c-6-3.7-9.5-9.2-11.4-16.3c3.3,0.7,6,1.2,10,2 c-4.3-5.5-8.2-9.6-11-14.3c-3-5.2-5-11.1-7.4-16.7c-0.3-0.7-0.7-1.9-0.3-2.4c3.5-4.8-1-8.2-1.7-12.3c-1.1-6.4-0.3-13.2-0.3-20.3 c1,0.5,2.1,0.7,2.5,1.3c7.3,10.4,18.1,14.6,29.8,17.4c8,1.9,14.1,6.7,18.7,13.3c1.2,1.8,1.3,4.3,2.2,7.8c4,2.9,5,13.4,0.5,21.1 c-12-12.5-19.8-16.9-29.6-16.2c3.5,2.2,6.4,3.9,9.1,5.9c24.8,19.2,34.9,49.4,26.7,79.7c-5.9,21.8-20.4,36-40.2,45.7 c-13.7,6.8-28.2,10.8-43.4,10.9c-19.7,0-37.2-6.5-51.7-20.2c-1.2-1.1-1-3.6-1.7-6.2c-10.4-1.3-13.5-10.9-17.5-19.1 c-3.5-7.2-2.9-7.5-4.6-8.5c-0.8,5.7-0.8,5.6-6.4,9.2c-12.1,7.7-24.9,14-39.1,16.1c-6.3,0.9-13.1-1.7-19.6-2.8 c-1.8-0.3-3.7-0.8-5.9-0.3c2.9,1.9,5.8,3.7,9.1,5.9C2261.2,561.3,2162.7,562.6,2135.9,560.4z M2211.9,392.3 c0.1,0.5,0.2,0.9,0.3,1.4c1.9,1.4,3.9,2.7,5.6,4.3c3.1,2.9,5.7,6.4,9.1,8.9c4.4,3.3,6.5,7.1,6.4,12.6c-0.1,26.8,0,53.6,0,80.4 c0,2.9-0.5,5.9-1.4,8.7c-1,3.3-2.7,6.3-4.1,9.4c0.3,0.4,0.7,0.9,1,1.3c2.8-1.5,5.6-3.1,8.4-4.5c2-1,4.1-2.1,6.2-2.5 c2.4-0.5,5,0,7.4-0.5c24.5-4.5,40.2-19.2,48.6-42.2c9-24.5,10.5-50.1,9.6-75.8c-0.3-9.5,1.3-17.5,5.7-24.3c-2.9-2.5-6.4-4.3-8-7.1 c-1.7-3-1.4-7-2.3-10.5c-4.2-17.4-10.3-33.8-24.4-45.8c-13.4-11.4-29.3-14.6-46.7-14.1c0,2.2,0,3.8,0,5.5c0,26.6-0.1,53.3-0.2,79.9 c0,8-6.8,14.5-14.5,14.5C2216.5,391.8,2214.2,392.1,2211.9,392.3z"></path>
|
||||
<path d="M1327.4,524.1c-1.3-1-2.1-2-2.8-2.1c-6.6-0.2-13.2-0.1-19.8-0.2c-1.4,0-3.1-0.5-4.1-1.4c-23.4-22.3-38.4-49.5-44.8-81.1 c-2.5-12.4-4.2-25.1-4.7-37.8c-0.6-16.2,1.6-32.4,6-48.1c2.3-8.2,10.8-14.7,19-15.1c1.4-0.1,2.8,0.7,5.4,1.4 c-0.5-2-0.4-3.9-1.3-4.7c-4.4-4.2-5.1-8.7-2.9-14.3c2.9-7.6,3.8-16.6,8.5-22.8c15-19.8,34.9-33.8,57.6-43.6 c24.9-10.8,51.2-14.9,78.2-14.2c16.5,0.5,32.6,3.6,48.5,7.8c12.2,3.3,24.6,5.3,37.4,3.2c1.9-0.3,4.3,1.8,7.4,3.2 c1.8-2,4.2-4.7,6.7-7.4c0.9-1,1.7-2.1,2.8-2.7c13.1-7.2,23.7-16.4,26.7-33.5c1.6,2.2,2.8,3.2,3.3,4.6c2.6,7.4,6.4,14.7,7.1,22.2 c0.8,9.3,4.9,18.4,1.2,28.1c-2.4,6.4-5.7,11.5-11.2,15.3c-3,2.1-5.6,2.5-8.9,0c-9.5-7.2-10-10.5-1-18.4 c10.4-9.1,14.3-20.3,12.6-34.7c-0.9,1.3-1.6,2-1.8,2.7c-2.5,10.7-8,19.1-17.4,25.1c-13.1,8.4-12.5,18.4-8.2,25.6 c2.5,4.2,6.9,7.2,9.7,11.3c7.2,10.6,14.3,21.4,20.9,32.5c4.1,6.9,2.7,9.8-4.6,12.9c-3.9,1.7-7.8,3.4-11.9,5.9c2,0,4,0,6,0 c11.9,0.1,21,7.7,23.2,19.2c4.2,21.5,5.8,43.3,2.9,65c-1.5,11.3-4.8,22.3-7.3,33.4c-0.7,3.2-2.6,4.8-6,4.3c-1.4-0.2-2.9,0-4.9,0 c7.9,4.8,8,4.9,4,13c-21.8,44.5-57.6,71.6-105.5,82.6c-18,4.1-36.3,5.6-54.8,3.4c-27.2-3.3-52.8-10.9-75.7-26.5 c-2-1.4-5.1-4.4-4.7-5.4c1.2-2.9,2.2-6.8,6.5-7.2C1325.3,525.8,1326,525,1327.4,524.1z M1485.6,483.1c-2.9-8.4-6.3-16.2-4.6-25.1 c0.9-4.7,1.9-9.5,1.6-14.2c-1-15.2-1.6-30.5-4.3-45.4c-4-21.9-11.5-42.8-22.5-62.4c-8.1-14.4-17.7-27.6-31.1-37.5 c-10.3-7.7-21.8-12.2-35-13.1c-13.3-0.9-23.5,3.8-29.8,15c-4.4,7.9-7.4,16.8-10.2,25.5c-1.5,4.7-1.7,9.5-8.9,8.1 c4.7,2.9,6.6,6.2,5.7,11.3c-0.9,5.1-1.2,10.3-1.3,15.4c-0.5,21.5,2.3,42.6,9.1,63.1c5.9,17.9,14.8,34.2,25.4,49.7 c5.8,8.4,9,17.5,0.7,27.4c2.2-0.6,3.5-0.7,4.6-1.3c8-4.1,15-2.4,22,2.9c6.2,4.6,12.7,9,19.5,12.5c6.2,3.2,12.9,5.8,20.4,4.2 c13.4-2.8,19.8-12.7,24.7-24.1c2.6-5.9,4.5-12.2,6.9-18.7C1481,479,1483.3,481.1,1485.6,483.1z"></path>
|
||||
<path d="M1700.7,488.1c8.3,8.4,13.5,16.4,12.9,27.8c-0.7,13.5,2.9,26.3,10,38c0.6,1,1.6,2,1.6,3c0,1.2-0.9,2.5-1.4,3.7 c-1.1-0.5-2.3-0.8-3.1-1.5c-0.9-0.9-1.4-2.1-2.9-4.4c-2,7.4-7.3,6-11.9,6c-9.2,0.1-18.3,0-27.5,0.1c-3.1,0.1-5.2-0.5-6.9-3.5 c-1.8-3.1-4.3-5.7-7-9.3c-1.9,3-3.7,5.4-5,8.1c-1.7,3.7-4.3,4.7-8.3,4.6c-12-0.3-24-0.1-36-0.1c-7.4,0-9.7-3.1-5.9-9.5 c8.5-14.7,9.1-30.7,9.3-46.9c0.1-10.4,0-20.6,2.7-30.8c1.3-5-0.4-10.8-0.9-16.2c-0.4-4.1-1.7-8.3-1.7-12.4 c-0.1-42.3,0-84.6-0.2-127c0-3.9-0.4-7.5,3.6-9.8c3.1-1.8,6.1-3.7,9.6-5.8c-0.4-0.5-1.4-2.6-2.9-3.3c-8.8-4.3-11.7-9.7-11.3-19.4 c0.2-4-1.3-8.2-2.9-12c-2.7-6.5-6.1-12.8-9.3-19.2c3.5-3.8,8.2-3.4,12.9-3.3c7.3,0.1,14.7,0.2,22-0.1c4.4-0.2,8,0.2,7.6,5.7 c0.5,0.1,0.8,0.2,0.8,0.2c5.5-6.8,13.1-5.9,20.5-5.9c22.8,0.1,45.7-0.1,68.5,0.2c7.1,0.1,14.2,1.2,21.3,2.1 c1.8,0.2,3.5,1.6,5.1,2.5c0.5,0.3,1.1,1.1,1.5,1.1c12-2.1,21.7,4.4,31.7,9c15.6,7.2,28.6,17.9,37.9,32.5 c12.3,19.2,14.8,40.1,9.7,61.8c-2.1,8.7-6.5,16.9-9.8,25.3c-1.3,3.3-3.7,4.9-7.4,4.3c-5.2-0.7-10.4-1.3-15.6-1.7 c-1.2-0.1-2.4,0.7-4.2,1.2c8,6,9.7,9.5,7.4,16.1c-0.8,2.3-2.8,4.4-4.7,6c-2.4,1.9-5.3,3.3-8.1,4.7c-2.8,1.4-5.7,2.4-9.9,4.1 c6.2,12.1,12.1,24,18.4,35.7c2.9,5.4,6.5,10.4,9.5,15.7c1.1,2.1,1.4,4.6,2.3,8.1c6.5,2.4,9.3,10.2,14,16.2 c17.8,22.7,35.5,45.5,53.2,68.2c0.4,0.5,0.6,1.1,1.4,2.4c-2.1,0-3.7,0-5.2,0c-25,0-50,0-75,0.2c-3.7,0-6.3-1-8.7-3.7 c-3.1-3.5-6.4-6.8-9.9-9.9c-3-2.7-4.9-5.2-1.9-9c0.5-0.7,0.6-1.8,1.1-3.5c-10.4,5.7-13.5-3.4-17.8-8.5 c-16.5-19.4-30.4-40.6-41.9-63.2c-6.2-12.1-11-24.9-16.4-37.3c-0.6-1.4-1.5-2.8-2.9-5.6c0,4.9,0.9,8.6-0.3,11.5 c-1,2.6-4.2,4.4-6.7,6.9c8.7,5.3,7,14.4,7.6,22.5c0.5,6,0.1,12,0.1,18c0,6.1-2.9,8.9-9.2,8.8C1703.4,488,1702.6,488.1,1700.7,488.1 z M1710,400.3c2.7-0.4,5-0.5,7.2-1.1c21.8-5.6,37.3-19.2,47.3-39c5.9-11.7,7.9-24.5,5-37.5c-2.6-11.7-9.2-20.4-20.1-26 c-12.5-6.3-25.9-7.3-39.4-7.9c0,15.7,0.2,30.6-0.1,45.6c-0.1,3-1.9,5.9-3.4,10c3.9,3.6,3.7,9.9,3.5,16.3c-0.1,3.5,0,7,0,10.5 C1710,380.6,1710,390.1,1710,400.3z"></path>
|
||||
<path d="M581.9,350.4c7.2-4.3,9.3-3.6,13.4,1.9c7.5,10.3,15.2,20.4,22.9,30.6c-8.6-1.7-15.5-7-22.4-12.6 c10.9,19.7,24,37.7,41.8,52.1c-4.1,1.4-10.9-0.8-18.6-6.1c-5.5-3.8-10.7-8-16.3-11.8c14.6,23.9,31.9,45.4,55.3,61.6 c-0.1,0.5-0.3,0.9-0.4,1.4c-3.5-0.8-7.1-1.2-10.4-2.4c-14-5.4-25.8-14-36.6-24.2c-0.5-0.4-1-0.8-1.5-1.1c-0.5-0.3-1.1-0.6-2.1-1.1 c4.2,13.9,7.8,27.5,12.4,40.7c1.9,5.5-1.1,9.9-1.3,14.9c-0.1,3.6-2.1,7.2-3.5,10.7c-1.4,3.3-0.4,4.4,2.9,5.5 c7.9,2.8,15,6.9,19.2,14.8c3.9,7.3,8.4,14.3,12.7,21.4c1,1.7,2.3,3.3,3.2,5c3.2,6.4,1.6,9.2-5.3,9.1c-5,0-10,0.1-15,0 c-5.6,0-11.9,1.7-14.8-6.9c-4,7.6-10.2,7.1-16.4,7c-19-0.1-38-0.1-57-0.1c-1.8,0-3.6,0-5.6,0c-0.6-14-0.1-27.7-2-41 c-1.9-13.4-6.3-26.4-9.4-38.9c-11.6,0-22.5,0-33.4,0c-5.3,0-10.7,0.2-16-0.1c-2-0.1-3.9-1.2-5.8-2.2c-4.3,0.4-6,3.1-6.9,7.5 c-2.9,13.2-7.3,26.1-9.5,39.4c-1.4,8.5,0.1,17.5,0.3,26.2c0,1.6-0.1,3.3,0.1,4.9c0.3,3.7-1.7,4.3-4.9,4.2c-35-0.1-70,0-105,0 c-1.3,0-2.9,0.3-3.9-0.3c-0.8-0.5-1.5-2.6-1.1-3.2c3.4-5.3,6.9-10.6,10.8-15.6c10.3-13.3,17.8-28,23.6-43.7 c5.4-14.8,10.6-29.7,16-44.5c1.1-3,2.4-6.6,7.5-4.2c0-2.8,0.5-5.1-0.1-7.1c-2.2-6.9,1.4-12.6,3.4-18.5 c9.5-26.8,19.3-53.5,29.1-80.3c2.8-7.6,8.9-11.1,16.7-10.5c2.8,0.2,5.7-0.8,9-1.4c-0.6-0.9-0.9-1.8-1.5-2.2 c-10-6.2-9.7-16.3-8.5-25.6c1.3-9.8,5-19.3,7.8-29c0.3-1,1.1-2,1.9-2.6c4.1-3.3,4.4-5.7-0.7-7.8c-7.2-3-11.4-8.6-16-14.2 c-0.9-1.1-1.6-2.3-3.1-4.4c2.7-0.3,4.4-0.6,6.1-0.6c28.7,0,57.3-0.4,86,0.3c7.5,0.2,14.6,3.8,22.4-0.2c3.1-1.6,7.6,3.2,8.1,8.2 c2.7,27.7,13.2,52.9,24,78C585.9,337.1,585.5,343.4,581.9,350.4z M520.8,427.6c-9.7-34.5-13-69.5-21.5-103.8 C491.2,357,483.2,390.2,475,424c11.7,0,22.4-0.1,33,0.1c2.1,0,4.1,0.7,6.2,1.3C516.3,425.9,518.5,426.8,520.8,427.6z"></path>
|
||||
<path d="M886.8,481.9c-2.3,7.6-4.3,14.5-6.6,21.4c-0.9,2.6-2.6,4.8-4.6,6.7c0,2.9,0.6,5.9-0.1,8.5c-3.5,12.5-7.5,25-11.2,37.5 c-1,3.5-2.6,5.2-6.6,4.8c-4.8-0.4-9.7,0.1-14.5-0.2c-1.5-0.1-3.3-1-4.3-2.2c-0.4-0.5,0.9-2.5,1.5-3.9c-1.7-1.4-4.3-2.5-5.5-4.6 c-1.2-1.9-0.9-4.7-2-6.9c-1.2,4.9-2.2,9.8-3.9,14.5c-0.5,1.4-3.1,3-4.8,3c-26.3,0.2-52.7,0.1-79,0.2c-5.9,0-5.5-5.2-7.4-8.4 c-0.5-0.9-0.2-2.2-0.4-3.7c-1.1,0.3-2.2,0.2-2.4,0.7c-1.6,3.2-3,6.6-4.7,9.7c-0.4,0.8-1.9,1.5-2.8,1.5c-15.6,0.1-31.3,0.1-48,0.1 c1.2-4.5,1.7-8.6,3.5-12.1c4.5-8.7,6.7-17.9,6.7-27.6c0.3-55.6,0.5-111.3,1-166.9c0-3.9,2.1-7.9,3.3-11.8c0.5-1.7,1.3-3.2,2.1-5.1 c-2.2,0.1-3.7,0.2-5.4,0.3c0-14,0.5-27.6-0.2-41.2c-0.5-9.7-1.6-19.5-4-28.8c-1.7-6.9-6-13.1-9.6-20.6c1.7-0.5,3.9-1.8,6.2-1.8 c8.2-0.3,16.3-0.4,24.5,0c4.8,0.3,9.5,1.8,13.2,2.5c4.8-1,9-2.4,13.1-2.5c19.7-0.3,39.3-0.1,59-0.2c1.6,0,3.2,0,6.3,0 c-5.1,5.6-7.4,12.6-16,10.9c-1.1-0.2-2.5,1-5,2.1c7.7,1.4,9.3,4.3,8.5,10.9c-0.9,7.6-1.2,15.3-1.3,22.9c-0.2,15-0.1,30-0.1,45 c0,8.8,0,8.8-6,16.5c-1.4-1-2.8-2-4.3-3c-0.4,0.2-0.8,0.5-1.3,0.7c2.3,3.7,3.9,9,7.2,10.6c4.2,2,4.2,4.6,4.2,7.8 c0.1,10.3,0.1,20.7,0.1,31c0,31,0,62-0.1,93c0,2.4-1.2,4.7-1.4,7.1c-0.1,1.7,0,3.9,0.9,4.9c1.4,1.5,3.3,2.1,5.5-0.2 c1.3-1.4,4-2,6.1-2.2c15.1-1.7,30.4-2.5,45.4-4.8c15-2.4,29.1-8,42.4-15.5C884.6,482.4,885.3,482.3,886.8,481.9z"></path>
|
||||
<path d="M2117.6,483c-3.2,10.4-4.6,21.7-14.1,29.7c3.3,3.7,3.3,7.8,1.4,12.4c-1.8,4.4-2.8,9.2-4.4,13.7c-0.8,2.1-1.9,4.2-3.3,5.9 c-2,2.5-3.7-0.3-5.5-0.6c-3.6-0.5-7.5,0.6-11.4,1.1c1.8,2.2,3.6,4.4,4.8,5.8c2.5-0.4,5.5-1.7,6.4-0.8c2.1,2.2,5.3,4.7,3,8.5 c-0.6,0.9-1.9,2-2.9,2c-31.3,0-62.6,0-93.9-0.1c-0.1,0-0.3-0.1-0.9-0.5c0.5-1.3,1-2.7,1.6-4c-0.4,0.1-0.7,0.2-1.1,0.3 c-0.9-2.7-1.8-5.4-2.8-8.5c-1.1,2.2-1.8,4.3-3.2,5.9c-4,4.7-8.5,7.4-15.3,7.3c-22.1-0.5-44.3-0.2-66.5-0.2c-1.1,0-2.2-0.1-3.1-0.1 c3.6-6.1,7.5-11.8,10.5-17.9c4.5-9.3,4.3-19.6,4.3-29.7c0-12,0-24,0-36c0-7.6,6.1-13.8,13.9-14.1c3.1-0.1,6.3,0,10,0 c-0.9-4.7-4.1-7.2-7.4-9c-8.9-4.8-11.6-12.1-11.1-21.9c0.2-4.2-2.5-8.6-3.8-13c-0.6-2.2-1.5-4.4-1.5-6.6 c-0.1-32.6-0.1-65.2-0.1-97.8c0-10.2-0.1-20.3-0.2-30.5c-0.1-10.8-3-20.8-8.8-29.9c-0.8-1.3-0.9-3.3-0.6-4.8 c0.1-0.6,2.3-0.7,4.2-1.3c0.1-0.4,0.3-1.6,0.5-2.8c0.7-0.1,1.2-0.3,1.7-0.3c10,0,20,0.1,30,0c5.2-0.1,10.5-0.5,13.8,6 c5.8-8.2,14-5.8,21.4-5.9c9.8-0.2,19.7-0.4,29.5,0c4.6,0.2,9.1,2,13.6,3c-2.8,7.1-6.5,12.8-7.3,19c-1.6,11.8-1.8,23.8-2,35.8 c-0.1,6.2-1.2,11.6-5.2,16.6c-2.8,3.5-4.6,7.9-6.8,12c0.4,0.3,0.8,0.6,1.1,0.8c1.3-1.2,2.4-2.5,3.9-3.5s3.3-1.7,5-2.5 c0.6,1.7,1.7,3.4,1.7,5.1c0.1,16.7,0.1,33.3,0.1,50c0,35.2,0.2,70.3-0.1,105.5c0,5.1-1.8,10.3-3.4,15.2c-1.4,4.2-1.8,4,1.5,7.9 c2.6-5.4,7-6.9,12.7-7c24.5-0.4,48.7-2.7,71.5-12.3c0.9-0.4,2.2-1,2.9-0.6c6.4,3.2,9.2-1.2,12.2-5.6c0.7-1,1.9-1.5,2.9-2.3 C2116.9,482.4,2117.3,482.7,2117.6,483z"></path>
|
||||
<path d="M1309.2,646.2c-4.8-4.6-9.2-9-13.7-13.3c4.4-4.3,8.8-8.6,13.5-13.2c-0.2,0,0.3-0.1,0.4,0.1c4,5.7,9.6,6.3,16.1,6.3 c111.2,0.4,222.3,1.1,333.5,1.7c125.5,0.7,251,1.4,376.5,2.1c121.7,0.7,243.3,1.5,365,2.2c29,0.2,58,0,87,0c0,0.7,0,1.5,0,2.2 c-62.5,0-124.9-0.2-187.4,0c-83.7,0.3-167.3,0.8-251,1.3c-120.2,0.7-240.3,1.4-360.5,2c-121.8,0.7-243.6,1.4-365.4,2 C1316.7,639.8,1316.7,639.9,1309.2,646.2z"></path>
|
||||
<path d="M1194.3,620.3c4,4,8.6,8.5,13.2,13c-4.4,4.4-8.9,8.8-13.3,13.2c-0.2-0.1-0.4-0.1-0.5-0.2c-5.1-7.2-12.6-6.5-20.1-6.6 c-68-0.2-136-0.5-204-0.8c-46.7-0.2-93.3-0.6-140-0.8c-121.8-0.7-243.6-1.5-365.4-2.2c-81-0.5-162-1.1-242.9-1.3 c-66.5-0.2-133,0-199.4,0c0-0.5,0-0.9,0-1.4c58.6-0.5,117.2-1.2,175.8-1.5c88.6-0.6,177.3-1,265.9-1.5c124.3-0.7,248.6-1.3,372.9-2 c115.5-0.7,230.9-1.3,346.4-2.3C1186.8,625.7,1190.6,622.2,1194.3,620.3z"></path>
|
||||
<path d="M1061.2,218.9c-0.8-10.7-1.7-21.4-2.2-32.2c-0.1-1.6,1.5-3.4,2.5-5.1c0.9-1.4,2-2.7,2.4-4.8c-1.3,0.8-2.6,1.6-4.3,2.6 c1.6-12.2,4.6-23.4,11.9-33.1c-10,6.7-17.3,15.4-21.2,26.8c-3.9,11.3-3.1,22.6-0.5,34.9c-12.5-13.1-15.3-33.6-7.5-49.9 c6.4-13.4,15.9-24.2,28.3-32c7-4.4,14.7-7.5,22.1-11.2c0.4,0.4,0.8,0.8,1.2,1.2c-0.3,1.5-1,3.1-1,4.6c0,4.8,0.3,9.6,0.5,14.4 c0.2,4,2.9,7.4,0.8,12.3c-1.8,4.1,1.8,8.9,4.7,12.6c4.6,5.9,3,12.6,2.7,19.2c0,0.5-0.6,1.5-1,1.5c-5.9,0.8-5.5,5.4-6.4,9.6 c-0.6,2.8-1.9,6-4,7.7c-5.4,4.6-11.3,8.6-17.1,12.7C1069.6,213.4,1065.9,215.7,1061.2,218.9z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
BIN
public/svgs/pangolin-logo.png
Normal file
BIN
public/svgs/pangolin-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
7
public/svgs/tailscale.svg
Normal file
7
public/svgs/tailscale.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" id="Tailscale--Streamline-Simple-Icons" height="24" width="24">
|
||||
<desc>
|
||||
Tailscale Streamline Icon: https://streamlinehq.com
|
||||
</desc>
|
||||
<title>Tailscale</title>
|
||||
<path d="M24 12a3 3 0 1 1 -6 0 3 3 0 0 1 6 0zm-9 9a3 3 0 1 1 -6 0 3 3 0 0 1 6 0zm0 -9a3 3 0 1 1 -6 0 3 3 0 0 1 6 0zm6 -6a3 3 0 1 1 0 -6 3 3 0 0 1 0 6zm0 -0.5a2.5 2.5 0 1 0 0 -5 2.5 2.5 0 0 0 0 5zM3 24a3 3 0 1 1 0 -6 3 3 0 0 1 0 6zm0 -0.5a2.5 2.5 0 1 0 0 -5 2.5 2.5 0 0 0 0 5zm18 0.5a3 3 0 1 1 0 -6 3 3 0 0 1 0 6zm0 -0.5a2.5 2.5 0 1 0 0 -5 2.5 2.5 0 0 0 0 5zM6 12a3 3 0 1 1 -6 0 3 3 0 0 1 6 0zm9 -9a3 3 0 1 1 -6 0 3 3 0 0 1 6 0zm-3 2.5a2.5 2.5 0 1 0 0 -5 2.5 2.5 0 0 0 0 5zM6 3a3 3 0 1 1 -6 0 3 3 0 0 1 6 0zM3 5.5a2.5 2.5 0 1 0 0 -5 2.5 2.5 0 0 0 0 5z" fill="#000000" stroke-width="1"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 837 B |
|
|
@ -32,7 +32,20 @@ @utility apexcharts-tooltip-custom-title {
|
|||
}
|
||||
|
||||
@utility input-sticky {
|
||||
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning;
|
||||
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 dark:bg-coolgray-100 dark:text-white disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 focus-visible:outline-none;
|
||||
box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #e5e5e5;
|
||||
|
||||
&:where(.dark, .dark *) {
|
||||
box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #242424;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 1px #e5e5e5;
|
||||
}
|
||||
|
||||
&:where(.dark, .dark *):focus-visible {
|
||||
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 1px #242424;
|
||||
}
|
||||
}
|
||||
|
||||
@utility input-sticky-active {
|
||||
|
|
@ -46,20 +59,49 @@ @utility input-focus {
|
|||
|
||||
/* input, select before */
|
||||
@utility input-select {
|
||||
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-2 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 dark:disabled:ring-transparent;
|
||||
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 dark:bg-coolgray-100 dark:text-white disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40;
|
||||
box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5;
|
||||
|
||||
&:where(.dark, .dark *) {
|
||||
box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:where(.dark, .dark *):disabled {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Readonly */
|
||||
@utility input {
|
||||
@apply dark:read-only:text-neutral-500 dark:read-only:ring-0 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200;
|
||||
@apply dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200;
|
||||
@apply input-select;
|
||||
@apply focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning;
|
||||
@apply focus-visible:outline-none;
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
|
||||
}
|
||||
|
||||
&:where(.dark, .dark *):focus-visible {
|
||||
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
|
||||
}
|
||||
|
||||
&:read-only {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:where(.dark, .dark *):read-only {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility select {
|
||||
@apply w-full;
|
||||
@apply input-select;
|
||||
@apply focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning;
|
||||
@apply focus-visible:outline-none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23000000'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e");
|
||||
background-position: right 0.5rem center;
|
||||
background-repeat: no-repeat;
|
||||
|
|
@ -69,6 +111,14 @@ @utility select {
|
|||
&:where(.dark, .dark *) {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
|
||||
}
|
||||
|
||||
&:where(.dark, .dark *):focus-visible {
|
||||
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
|
||||
}
|
||||
}
|
||||
|
||||
@utility button {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
@props(['type' => 'warning', 'title' => 'Warning', 'class' => ''])
|
||||
@props(['type' => 'warning', 'title' => 'Warning', 'class' => '', 'dismissible' => false, 'onDismiss' => null])
|
||||
|
||||
@php
|
||||
$icons = [
|
||||
'warning' => '<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>',
|
||||
|
||||
|
||||
'danger' => '<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path></svg>',
|
||||
|
||||
|
||||
'info' => '<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>',
|
||||
|
||||
|
||||
'success' => '<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path></svg>'
|
||||
];
|
||||
|
||||
|
|
@ -42,12 +42,12 @@
|
|||
$icon = $icons[$type] ?? $icons['warning'];
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->merge(['class' => 'p-4 border rounded-lg ' . $colorScheme['bg'] . ' ' . $colorScheme['border'] . ' ' . $class]) }}>
|
||||
<div {{ $attributes->merge(['class' => 'relative p-4 border rounded-lg ' . $colorScheme['bg'] . ' ' . $colorScheme['border'] . ' ' . $class]) }}>
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
{!! $icon !!}
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<div class="ml-3 {{ $dismissible ? 'pr-8' : '' }}">
|
||||
<div class="text-base font-bold {{ $colorScheme['title'] }}">
|
||||
{{ $title }}
|
||||
</div>
|
||||
|
|
@ -55,5 +55,15 @@
|
|||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
@if($dismissible && $onDismiss)
|
||||
<button type="button" @click.stop="{{ $onDismiss }}"
|
||||
class="absolute top-2 right-2 p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
aria-label="Dismiss">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="w-4 h-4 {{ $colorScheme['text'] }}">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue