mirror of
https://github.com/tiennm99/coolify.git
synced 2026-05-03 14:21:11 +00:00
Merge branch 'next' into shadow/fix-docker-time-command
This commit is contained in:
+25
-140
@@ -1,156 +1,41 @@
|
|||||||
# AI Instructions Synchronization Guide
|
# 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)
|
- **Sync Guide**: [.ai/meta/sync-guide.md](.ai/meta/sync-guide.md)
|
||||||
2. **.cursor/rules/** - For Cursor IDE and other AI assistants
|
- **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
|
All AI instructions are now organized in `.ai/` directory:
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
/
|
.ai/
|
||||||
├── CLAUDE.md # Claude Code instructions (condensed)
|
├── README.md # Navigation hub
|
||||||
├── .AI_INSTRUCTIONS_SYNC.md # This file
|
├── core/ # Project information
|
||||||
└── .cursor/
|
├── development/ # Dev workflows
|
||||||
└── rules/
|
├── patterns/ # Code patterns
|
||||||
├── README.mdc # Index and overview
|
└── meta/ # Documentation guides
|
||||||
├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Recent Updates
|
### For AI Assistants
|
||||||
|
|
||||||
### 2025-10-07
|
- **Claude Code**: Use `CLAUDE.md` (references `.ai/` files)
|
||||||
- ✅ Added cross-references between CLAUDE.md and .cursor/rules/
|
- **Cursor IDE**: Use `.cursor/rules/coolify-ai-docs.mdc` (references `.ai/` files)
|
||||||
- ✅ Synchronized Laravel version (12) across all files
|
- **All Tools**: Browse `.ai/` directory for detailed documentation
|
||||||
- ✅ 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
|
|
||||||
|
|
||||||
## Maintenance Commands
|
### Key Principles
|
||||||
|
|
||||||
```bash
|
1. **Single Source of Truth**: Each piece of information exists in ONE file only
|
||||||
# Check for version inconsistencies
|
2. **Cross-Reference**: Other files reference the source, don't duplicate
|
||||||
grep -r "Laravel [0-9]" CLAUDE.md .cursor/rules/*.mdc
|
3. **Organized by Topic**: Core, Development, Patterns, Meta
|
||||||
|
4. **Version Consistency**: All versions in `.ai/core/technology-stack.md`
|
||||||
|
|
||||||
# Check for PHP version consistency
|
## For More Information
|
||||||
grep -r "PHP [0-9]" CLAUDE.md .cursor/rules/*.mdc
|
|
||||||
|
|
||||||
# Format all documentation
|
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.
|
||||||
./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.
|
|
||||||
|
|||||||
+148
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
# Coolify Deployment Architecture
|
||||||
|
|
||||||
|
## Deployment Philosophy
|
||||||
|
|
||||||
|
Coolify orchestrates **Docker-based deployments** across multiple servers with automated configuration generation, zero-downtime deployments, and comprehensive monitoring.
|
||||||
|
|
||||||
|
## Core Deployment Components
|
||||||
|
|
||||||
|
### Deployment Models
|
||||||
|
- **[Application.php](mdc:app/Models/Application.php)** - Main application entity with deployment configurations
|
||||||
|
- **[ApplicationDeploymentQueue.php](mdc:app/Models/ApplicationDeploymentQueue.php)** - Deployment job orchestration
|
||||||
|
- **[Service.php](mdc:app/Models/Service.php)** - Multi-container service definitions
|
||||||
|
- **[Server.php](mdc:app/Models/Server.php)** - Target deployment infrastructure
|
||||||
|
|
||||||
|
### Infrastructure Management
|
||||||
|
- **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - SSH key management for secure server access
|
||||||
|
- **[StandaloneDocker.php](mdc:app/Models/StandaloneDocker.php)** - Single container deployments
|
||||||
|
- **[SwarmDocker.php](mdc:app/Models/SwarmDocker.php)** - Docker Swarm orchestration
|
||||||
|
|
||||||
|
## Deployment Workflow
|
||||||
|
|
||||||
|
### 1. Source Code Integration
|
||||||
|
```
|
||||||
|
Git Repository → Webhook → Coolify → Build & Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Source Control Models
|
||||||
|
- **[GithubApp.php](mdc:app/Models/GithubApp.php)** - GitHub integration and webhooks
|
||||||
|
- **[GitlabApp.php](mdc:app/Models/GitlabApp.php)** - GitLab CI/CD integration
|
||||||
|
|
||||||
|
#### Deployment Triggers
|
||||||
|
- **Git push** to configured branches
|
||||||
|
- **Manual deployment** via UI
|
||||||
|
- **Scheduled deployments** via cron
|
||||||
|
- **API-triggered** deployments
|
||||||
|
|
||||||
|
### 2. Build Process
|
||||||
|
```
|
||||||
|
Source Code → Docker Build → Image Registry → Deployment
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Build Configurations
|
||||||
|
- **Dockerfile detection** and custom Dockerfile support
|
||||||
|
- **Buildpack integration** for framework detection
|
||||||
|
- **Multi-stage builds** for optimization
|
||||||
|
- **Cache layer** management for faster builds
|
||||||
|
|
||||||
|
### 3. Deployment Orchestration
|
||||||
|
```
|
||||||
|
Queue Job → Configuration Generation → Container Deployment → Health Checks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Actions
|
||||||
|
|
||||||
|
### Location: [app/Actions/](mdc:app/Actions)
|
||||||
|
|
||||||
|
#### Application Deployment Actions
|
||||||
|
- **Application/** - Core application deployment logic
|
||||||
|
- **Docker/** - Docker container management
|
||||||
|
- **Service/** - Multi-container service orchestration
|
||||||
|
- **Proxy/** - Reverse proxy configuration
|
||||||
|
|
||||||
|
#### Database Actions
|
||||||
|
- **Database/** - Database deployment and management
|
||||||
|
- Automated backup scheduling
|
||||||
|
- Connection management and health checks
|
||||||
|
|
||||||
|
#### Server Management Actions
|
||||||
|
- **Server/** - Server provisioning and configuration
|
||||||
|
- SSH connection establishment
|
||||||
|
- Docker daemon management
|
||||||
|
|
||||||
|
## Configuration Generation
|
||||||
|
|
||||||
|
### Dynamic Configuration
|
||||||
|
- **[ConfigurationGenerator.php](mdc:app/Services/ConfigurationGenerator.php)** - Generates deployment configurations
|
||||||
|
- **[ConfigurationRepository.php](mdc:app/Services/ConfigurationRepository.php)** - Configuration management
|
||||||
|
|
||||||
|
### Generated Configurations
|
||||||
|
#### Docker Compose Files
|
||||||
|
```yaml
|
||||||
|
# Generated docker-compose.yml structure
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${APP_IMAGE}
|
||||||
|
environment:
|
||||||
|
- ${ENV_VARIABLES}
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.app.rule=Host(`${FQDN}`)
|
||||||
|
volumes:
|
||||||
|
- ${VOLUME_MAPPINGS}
|
||||||
|
networks:
|
||||||
|
- coolify
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Nginx Configurations
|
||||||
|
- **Reverse proxy** setup
|
||||||
|
- **SSL termination** with automatic certificates
|
||||||
|
- **Load balancing** for multiple instances
|
||||||
|
- **Custom headers** and routing rules
|
||||||
|
|
||||||
|
## Container Orchestration
|
||||||
|
|
||||||
|
### Docker Integration
|
||||||
|
- **[DockerImageParser.php](mdc:app/Services/DockerImageParser.php)** - Parse and validate Docker images
|
||||||
|
- **Container lifecycle** management
|
||||||
|
- **Resource allocation** and limits
|
||||||
|
- **Network isolation** and communication
|
||||||
|
|
||||||
|
### Volume Management
|
||||||
|
- **[LocalFileVolume.php](mdc:app/Models/LocalFileVolume.php)** - Persistent file storage
|
||||||
|
- **[LocalPersistentVolume.php](mdc:app/Models/LocalPersistentVolume.php)** - Data persistence
|
||||||
|
- **Backup integration** for volume data
|
||||||
|
|
||||||
|
### Network Configuration
|
||||||
|
- **Custom Docker networks** for isolation
|
||||||
|
- **Service discovery** between containers
|
||||||
|
- **Port mapping** and exposure
|
||||||
|
- **SSL/TLS termination**
|
||||||
|
|
||||||
|
## Environment Management
|
||||||
|
|
||||||
|
### Environment Isolation
|
||||||
|
- **[Environment.php](mdc:app/Models/Environment.php)** - Development, staging, production environments
|
||||||
|
- **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** - Application-specific variables
|
||||||
|
- **[SharedEnvironmentVariable.php](mdc:app/Models/SharedEnvironmentVariable.php)** - Cross-application variables
|
||||||
|
|
||||||
|
### Configuration Hierarchy
|
||||||
|
```
|
||||||
|
Instance Settings → Server Settings → Project Settings → Application Settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preview Environments
|
||||||
|
|
||||||
|
### Git-Based Previews
|
||||||
|
- **[ApplicationPreview.php](mdc:app/Models/ApplicationPreview.php)** - Preview environment management
|
||||||
|
- **Automatic PR/MR previews** for feature branches
|
||||||
|
- **Isolated environments** for testing
|
||||||
|
- **Automatic cleanup** after merge/close
|
||||||
|
|
||||||
|
### Preview Workflow
|
||||||
|
```
|
||||||
|
Feature Branch → Auto-Deploy → Preview URL → Review → Cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
## SSL & Security
|
||||||
|
|
||||||
|
### Certificate Management
|
||||||
|
- **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate automation
|
||||||
|
- **Let's Encrypt** integration for free certificates
|
||||||
|
- **Custom certificate** upload support
|
||||||
|
- **Automatic renewal** and monitoring
|
||||||
|
|
||||||
|
### Security Patterns
|
||||||
|
- **Private Docker networks** for container isolation
|
||||||
|
- **SSH key-based** server authentication
|
||||||
|
- **Environment variable** encryption
|
||||||
|
- **Access control** via team permissions
|
||||||
|
|
||||||
|
## Backup & Recovery
|
||||||
|
|
||||||
|
### Database Backups
|
||||||
|
- **[ScheduledDatabaseBackup.php](mdc:app/Models/ScheduledDatabaseBackup.php)** - Automated database backups
|
||||||
|
- **[ScheduledDatabaseBackupExecution.php](mdc:app/Models/ScheduledDatabaseBackupExecution.php)** - Backup execution tracking
|
||||||
|
- **S3-compatible storage** for backup destinations
|
||||||
|
|
||||||
|
### Application Backups
|
||||||
|
- **Volume snapshots** for persistent data
|
||||||
|
- **Configuration export** for disaster recovery
|
||||||
|
- **Cross-region replication** for high availability
|
||||||
|
|
||||||
|
## Monitoring & Logging
|
||||||
|
|
||||||
|
### Real-Time Monitoring
|
||||||
|
- **[ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php)** - Live deployment monitoring
|
||||||
|
- **WebSocket-based** log streaming
|
||||||
|
- **Container health checks** and alerts
|
||||||
|
- **Resource usage** tracking
|
||||||
|
|
||||||
|
### Deployment Logs
|
||||||
|
- **Build process** logging
|
||||||
|
- **Container startup** logs
|
||||||
|
- **Application runtime** logs
|
||||||
|
- **Error tracking** and alerting
|
||||||
|
|
||||||
|
## Queue System
|
||||||
|
|
||||||
|
### Background Jobs
|
||||||
|
Location: [app/Jobs/](mdc:app/Jobs)
|
||||||
|
- **Deployment jobs** for async processing
|
||||||
|
- **Server monitoring** jobs
|
||||||
|
- **Backup scheduling** jobs
|
||||||
|
- **Notification delivery** jobs
|
||||||
|
|
||||||
|
### Queue Processing
|
||||||
|
- **Redis-backed** job queues
|
||||||
|
- **Laravel Horizon** for queue monitoring
|
||||||
|
- **Failed job** retry mechanisms
|
||||||
|
- **Queue worker** auto-scaling
|
||||||
|
|
||||||
|
## Multi-Server Deployment
|
||||||
|
|
||||||
|
### Server Types
|
||||||
|
- **Standalone servers** - Single Docker host
|
||||||
|
- **Docker Swarm** - Multi-node orchestration
|
||||||
|
- **Remote servers** - SSH-based deployment
|
||||||
|
- **Local development** - Docker Desktop integration
|
||||||
|
|
||||||
|
### Load Balancing
|
||||||
|
- **Traefik integration** for automatic load balancing
|
||||||
|
- **Health check** based routing
|
||||||
|
- **Blue-green deployments** for zero downtime
|
||||||
|
- **Rolling updates** with configurable strategies
|
||||||
|
|
||||||
|
## Deployment Strategies
|
||||||
|
|
||||||
|
### Zero-Downtime Deployment
|
||||||
|
```
|
||||||
|
Old Container → New Container Build → Health Check → Traffic Switch → Old Container Cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blue-Green Deployment
|
||||||
|
- **Parallel environments** for safe deployments
|
||||||
|
- **Instant rollback** capability
|
||||||
|
- **Database migration** handling
|
||||||
|
- **Configuration synchronization**
|
||||||
|
|
||||||
|
### Rolling Updates
|
||||||
|
- **Gradual instance** replacement
|
||||||
|
- **Configurable update** strategy
|
||||||
|
- **Automatic rollback** on failure
|
||||||
|
- **Health check** validation
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
### Deployment API
|
||||||
|
Routes: [routes/api.php](mdc:routes/api.php)
|
||||||
|
- **RESTful endpoints** for deployment management
|
||||||
|
- **Webhook receivers** for CI/CD integration
|
||||||
|
- **Status reporting** endpoints
|
||||||
|
- **Deployment triggering** via API
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- **Laravel Sanctum** API tokens
|
||||||
|
- **Team-based** access control
|
||||||
|
- **Rate limiting** for API calls
|
||||||
|
- **Audit logging** for API usage
|
||||||
|
|
||||||
|
## Error Handling & Recovery
|
||||||
|
|
||||||
|
### Deployment Failure Recovery
|
||||||
|
- **Automatic rollback** on deployment failure
|
||||||
|
- **Health check** failure handling
|
||||||
|
- **Container crash** recovery
|
||||||
|
- **Resource exhaustion** protection
|
||||||
|
|
||||||
|
### Monitoring & Alerting
|
||||||
|
- **Failed deployment** notifications
|
||||||
|
- **Resource threshold** alerts
|
||||||
|
- **SSL certificate** expiry warnings
|
||||||
|
- **Backup failure** notifications
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Build Optimization
|
||||||
|
- **Docker layer** caching
|
||||||
|
- **Multi-stage builds** for smaller images
|
||||||
|
- **Build artifact** reuse
|
||||||
|
- **Parallel build** processing
|
||||||
|
|
||||||
|
### Docker Build Cache Preservation
|
||||||
|
|
||||||
|
Coolify provides settings to preserve Docker build cache across deployments, addressing cache invalidation issues.
|
||||||
|
|
||||||
|
#### The Problem
|
||||||
|
|
||||||
|
By default, Coolify injects `ARG` statements into user Dockerfiles for build-time variables. This breaks Docker's cache mechanism because:
|
||||||
|
1. **ARG declarations invalidate cache** - Any change in ARG values after the `ARG` instruction invalidates all subsequent layers
|
||||||
|
2. **SOURCE_COMMIT changes every commit** - Causes full rebuilds even when code changes are minimal
|
||||||
|
|
||||||
|
#### Application Settings
|
||||||
|
|
||||||
|
Two toggles in **Advanced Settings** control this behavior:
|
||||||
|
|
||||||
|
| Setting | Default | Description |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| `inject_build_args_to_dockerfile` | `true` | Controls whether Coolify adds `ARG` statements to Dockerfile |
|
||||||
|
| `include_source_commit_in_build` | `false` | Controls whether `SOURCE_COMMIT` is included in build context |
|
||||||
|
|
||||||
|
**Database columns:** `application_settings.inject_build_args_to_dockerfile`, `application_settings.include_source_commit_in_build`
|
||||||
|
|
||||||
|
#### Buildpack Coverage
|
||||||
|
|
||||||
|
| Build Pack | ARG Injection | Method |
|
||||||
|
|------------|---------------|--------|
|
||||||
|
| **Dockerfile** | ✅ Yes | `add_build_env_variables_to_dockerfile()` |
|
||||||
|
| **Docker Compose** (with `build:`) | ✅ Yes | `modify_dockerfiles_for_compose()` |
|
||||||
|
| **PR Deployments** (Dockerfile only) | ✅ Yes | `add_build_env_variables_to_dockerfile()` |
|
||||||
|
| **Nixpacks** | ❌ No | Generates its own Dockerfile internally |
|
||||||
|
| **Static** | ❌ No | Uses internal Dockerfile |
|
||||||
|
| **Docker Image** | ❌ No | No build phase |
|
||||||
|
|
||||||
|
#### How It Works
|
||||||
|
|
||||||
|
**When `inject_build_args_to_dockerfile` is enabled (default):**
|
||||||
|
```dockerfile
|
||||||
|
# Coolify modifies your Dockerfile to add:
|
||||||
|
FROM node:20
|
||||||
|
ARG MY_VAR=value
|
||||||
|
ARG COOLIFY_URL=...
|
||||||
|
ARG SOURCE_COMMIT=abc123 # (if include_source_commit_in_build is true)
|
||||||
|
# ... rest of your Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
**When `inject_build_args_to_dockerfile` is disabled:**
|
||||||
|
- Coolify does NOT modify the Dockerfile
|
||||||
|
- `--build-arg` flags are still passed (harmless without matching `ARG` in Dockerfile)
|
||||||
|
- User must manually add `ARG` statements for any build-time variables they need
|
||||||
|
|
||||||
|
**When `include_source_commit_in_build` is disabled (default):**
|
||||||
|
- `SOURCE_COMMIT` is NOT included in build-time variables
|
||||||
|
- `SOURCE_COMMIT` is still available at **runtime** (in container environment)
|
||||||
|
- Docker cache preserved across different commits
|
||||||
|
|
||||||
|
#### Recommended Configuration
|
||||||
|
|
||||||
|
| Use Case | inject_build_args | include_source_commit | Cache Behavior |
|
||||||
|
|----------|-------------------|----------------------|----------------|
|
||||||
|
| Maximum cache preservation | `false` | `false` | Best cache retention |
|
||||||
|
| Need build-time vars, no commit | `true` | `false` | Cache breaks on var changes |
|
||||||
|
| Need commit at build-time | `true` | `true` | Cache breaks every commit |
|
||||||
|
| Manual ARG management | `false` | `true` | Cache preserved (no ARG in Dockerfile) |
|
||||||
|
|
||||||
|
#### Implementation Details
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `app/Jobs/ApplicationDeploymentJob.php`:
|
||||||
|
- `set_coolify_variables()` - Conditionally adds SOURCE_COMMIT to Docker build context based on `include_source_commit_in_build` setting
|
||||||
|
- `generate_coolify_env_variables(bool $forBuildTime)` - Distinguishes build-time vs. runtime variables; excludes cache-busting variables like SOURCE_COMMIT from build context unless explicitly enabled
|
||||||
|
- `generate_env_variables()` - Populates `$this->env_args` with build-time ARG values, respecting `include_source_commit_in_build` toggle
|
||||||
|
- `add_build_env_variables_to_dockerfile()` - Injects ARG statements into Dockerfiles after FROM instructions; skips injection if `inject_build_args_to_dockerfile` is disabled
|
||||||
|
- `modify_dockerfiles_for_compose()` - Applies ARG injection to Docker Compose service Dockerfiles; respects `inject_build_args_to_dockerfile` toggle
|
||||||
|
- `app/Models/ApplicationSetting.php` - Defines `inject_build_args_to_dockerfile` and `include_source_commit_in_build` boolean properties
|
||||||
|
- `app/Livewire/Project/Application/Advanced.php` - Livewire component providing UI bindings for cache preservation toggles
|
||||||
|
- `resources/views/livewire/project/application/advanced.blade.php` - Checkbox UI elements for user-facing toggles
|
||||||
|
|
||||||
|
**Note:** Docker Compose services without a `build:` section (image-only) are automatically skipped.
|
||||||
|
|
||||||
|
### Runtime Optimization
|
||||||
|
- **Container resource** limits
|
||||||
|
- **Auto-scaling** based on metrics
|
||||||
|
- **Connection pooling** for databases
|
||||||
|
- **CDN integration** for static assets
|
||||||
|
|
||||||
|
## Compliance & Governance
|
||||||
|
|
||||||
|
### Audit Trail
|
||||||
|
- **Deployment history** tracking
|
||||||
|
- **Configuration changes** logging
|
||||||
|
- **User action** auditing
|
||||||
|
- **Resource access** monitoring
|
||||||
|
|
||||||
|
### Backup Compliance
|
||||||
|
- **Retention policies** for backups
|
||||||
|
- **Encryption at rest** for sensitive data
|
||||||
|
- **Cross-region** backup replication
|
||||||
|
- **Recovery testing** automation
|
||||||
|
|
||||||
|
## Integration Patterns
|
||||||
|
|
||||||
|
### CI/CD Integration
|
||||||
|
- **GitHub Actions** compatibility
|
||||||
|
- **GitLab CI** pipeline integration
|
||||||
|
- **Custom webhook** endpoints
|
||||||
|
- **Build status** reporting
|
||||||
|
|
||||||
|
### External Services
|
||||||
|
- **S3-compatible** storage integration
|
||||||
|
- **External database** connections
|
||||||
|
- **Third-party monitoring** tools
|
||||||
|
- **Custom notification** channels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coolify Docker Compose Extensions
|
||||||
|
|
||||||
|
Coolify extends standard Docker Compose with custom fields (often called "magic fields") that provide Coolify-specific functionality. These extensions are processed during deployment and stripped before sending the final compose file to Docker, maintaining full compatibility with Docker's compose specification.
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
**Why Custom Fields?**
|
||||||
|
- Enable Coolify-specific features without breaking Docker Compose compatibility
|
||||||
|
- Simplify configuration by embedding content directly in compose files
|
||||||
|
- Allow fine-grained control over health check monitoring
|
||||||
|
- Reduce external file dependencies
|
||||||
|
|
||||||
|
**Processing Flow:**
|
||||||
|
1. User defines compose file with custom fields
|
||||||
|
2. Coolify parses and processes custom fields (creates files, stores settings)
|
||||||
|
3. Custom fields are stripped from final compose sent to Docker
|
||||||
|
4. Docker receives standard, valid compose file
|
||||||
|
|
||||||
|
### Service-Level Extensions
|
||||||
|
|
||||||
|
#### `exclude_from_hc`
|
||||||
|
|
||||||
|
**Type:** Boolean
|
||||||
|
**Default:** `false`
|
||||||
|
**Purpose:** Exclude specific services from health check monitoring while still showing their status
|
||||||
|
|
||||||
|
**Example Usage:**
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
watchtower:
|
||||||
|
image: containrrr/watchtower
|
||||||
|
exclude_from_hc: true # Don't monitor this service's health
|
||||||
|
|
||||||
|
backup:
|
||||||
|
image: postgres:16
|
||||||
|
exclude_from_hc: true # Backup containers don't need monitoring
|
||||||
|
restart: always
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Container status is still calculated from Docker state (running, exited, etc.)
|
||||||
|
- Status displays with `:excluded` suffix (e.g., `running:healthy:excluded`)
|
||||||
|
- UI shows "Monitoring Disabled" indicator
|
||||||
|
- Functionally equivalent to `restart: no` for health check purposes
|
||||||
|
- See [Container Status with All Excluded](application-architecture.md#container-status-when-all-containers-excluded) for detailed status handling
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Sidecar containers (watchtower, log collectors)
|
||||||
|
- Backup/maintenance containers
|
||||||
|
- One-time initialization containers
|
||||||
|
- Containers that intentionally restart frequently
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- Parsed: `bootstrap/helpers/parsers.php`
|
||||||
|
- Status logic: `app/Traits/CalculatesExcludedStatus.php`
|
||||||
|
- Validation: `tests/Unit/ExcludeFromHealthCheckTest.php`
|
||||||
|
|
||||||
|
### Volume-Level Extensions
|
||||||
|
|
||||||
|
Volume extensions only work with **long syntax** (array/object format), not short syntax (string format).
|
||||||
|
|
||||||
|
#### `content`
|
||||||
|
|
||||||
|
**Type:** String (supports multiline with `|` or `>`)
|
||||||
|
**Purpose:** Embed file content directly in compose file for automatic creation during deployment
|
||||||
|
|
||||||
|
**Example Usage:**
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: node:20
|
||||||
|
volumes:
|
||||||
|
# Inline entrypoint script
|
||||||
|
- type: bind
|
||||||
|
source: ./entrypoint.sh
|
||||||
|
target: /app/entrypoint.sh
|
||||||
|
content: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
echo "Starting application..."
|
||||||
|
npm run migrate
|
||||||
|
exec "$@"
|
||||||
|
|
||||||
|
# Configuration file with environment variables
|
||||||
|
- type: bind
|
||||||
|
source: ./config.xml
|
||||||
|
target: /etc/app/config.xml
|
||||||
|
content: |
|
||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<config>
|
||||||
|
<database>
|
||||||
|
<host>${DB_HOST}</host>
|
||||||
|
<port>${DB_PORT}</port>
|
||||||
|
</database>
|
||||||
|
</config>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Content is written to the host at `source` path before container starts
|
||||||
|
- File is created with mode `644` (readable by all, writable by owner)
|
||||||
|
- Environment variables in content are interpolated at deployment time
|
||||||
|
- Content is stored in `LocalFileVolume` model (encrypted at rest)
|
||||||
|
- Original `docker_compose_raw` retains content for editing
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Entrypoint scripts
|
||||||
|
- Configuration files
|
||||||
|
- Environment-specific settings
|
||||||
|
- Small initialization scripts
|
||||||
|
- Templates that require dynamic content
|
||||||
|
|
||||||
|
**Limitations:**
|
||||||
|
- Not suitable for large files (use git repo or external storage instead)
|
||||||
|
- Binary files not supported
|
||||||
|
- Changes require redeployment
|
||||||
|
|
||||||
|
**Real-World Examples:**
|
||||||
|
- `templates/compose/traccar.yaml` - XML configuration file
|
||||||
|
- `templates/compose/supabase.yaml` - Multiple config files
|
||||||
|
- `templates/compose/chaskiq.yaml` - Entrypoint script
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `content` field extraction)
|
||||||
|
- Storage: `app/Models/LocalFileVolume.php`
|
||||||
|
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
|
||||||
|
|
||||||
|
#### `is_directory` / `isDirectory`
|
||||||
|
|
||||||
|
**Type:** Boolean
|
||||||
|
**Default:** `true` (if neither `content` nor explicit flag provided)
|
||||||
|
**Purpose:** Indicate whether bind mount source should be created as directory or file
|
||||||
|
|
||||||
|
**Example Usage:**
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
volumes:
|
||||||
|
# Explicit file
|
||||||
|
- type: bind
|
||||||
|
source: ./config.json
|
||||||
|
target: /app/config.json
|
||||||
|
is_directory: false # Create as file
|
||||||
|
|
||||||
|
# Explicit directory
|
||||||
|
- type: bind
|
||||||
|
source: ./logs
|
||||||
|
target: /var/log/app
|
||||||
|
is_directory: true # Create as directory
|
||||||
|
|
||||||
|
# Auto-detected as file (has content)
|
||||||
|
- type: bind
|
||||||
|
source: ./script.sh
|
||||||
|
target: /entrypoint.sh
|
||||||
|
content: |
|
||||||
|
#!/bin/sh
|
||||||
|
echo "Hello"
|
||||||
|
# is_directory: false implied by content presence
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- If `is_directory: true` → Creates directory with `mkdir -p`
|
||||||
|
- If `is_directory: false` → Creates empty file with `touch`
|
||||||
|
- If `content` provided → Implies `is_directory: false`
|
||||||
|
- If neither specified → Defaults to `true` (directory)
|
||||||
|
|
||||||
|
**Naming Conventions:**
|
||||||
|
- `is_directory` (snake_case) - **Preferred**, consistent with PHP/Laravel conventions
|
||||||
|
- `isDirectory` (camelCase) - **Legacy support**, both work identically
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Disambiguating files vs directories when no content provided
|
||||||
|
- Ensuring correct bind mount type for Docker
|
||||||
|
- Pre-creating mount points before container starts
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `is_directory`/`isDirectory` field extraction)
|
||||||
|
- Storage: `app/Models/LocalFileVolume.php` (`is_directory` column)
|
||||||
|
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
|
||||||
|
|
||||||
|
### Custom Field Stripping
|
||||||
|
|
||||||
|
**Function:** `stripCoolifyCustomFields()` in `bootstrap/helpers/docker.php`
|
||||||
|
|
||||||
|
All custom fields are removed before the compose file is sent to Docker. This happens in two contexts:
|
||||||
|
|
||||||
|
**1. Validation (User-Triggered)**
|
||||||
|
```php
|
||||||
|
// In validateComposeFile() - Edit Docker Compose modal
|
||||||
|
$yaml_compose = Yaml::parse($compose);
|
||||||
|
$yaml_compose = stripCoolifyCustomFields($yaml_compose); // Strip custom fields
|
||||||
|
// Send to docker compose config for validation
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Deployment (Automatic)**
|
||||||
|
```php
|
||||||
|
// In Service::parse() - During deployment
|
||||||
|
$docker_compose = parseCompose($docker_compose_raw);
|
||||||
|
// Custom fields are processed and then stripped
|
||||||
|
// Final compose sent to Docker has no custom fields
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Gets Stripped:**
|
||||||
|
- Service-level: `exclude_from_hc`
|
||||||
|
- Volume-level: `content`, `isDirectory`, `is_directory`
|
||||||
|
|
||||||
|
**What's Preserved:**
|
||||||
|
- All standard Docker Compose fields
|
||||||
|
- Environment variables
|
||||||
|
- Standard volume definitions (after custom fields removed)
|
||||||
|
|
||||||
|
### Important Notes
|
||||||
|
|
||||||
|
#### Long vs Short Volume Syntax
|
||||||
|
|
||||||
|
**✅ Long Syntax (Works with Custom Fields):**
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./data
|
||||||
|
target: /app/data
|
||||||
|
content: "Hello" # ✅ Custom fields work here
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ Short Syntax (Custom Fields Ignored):**
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- "./data:/app/data" # ❌ Cannot add custom fields to strings
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker Compose Compatibility
|
||||||
|
|
||||||
|
Custom fields are **Coolify-specific** and won't work with standalone `docker compose` CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ❌ Won't work - Docker doesn't recognize custom fields
|
||||||
|
docker compose -f compose.yaml up
|
||||||
|
|
||||||
|
# ✅ Works - Use Coolify's deployment (strips custom fields first)
|
||||||
|
# Deploy through Coolify UI or API
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Editing Custom Fields
|
||||||
|
|
||||||
|
When editing in "Edit Docker Compose" modal:
|
||||||
|
- Custom fields are preserved in the editor
|
||||||
|
- "Validate" button strips them temporarily for Docker validation
|
||||||
|
- "Save" button preserves them in `docker_compose_raw`
|
||||||
|
- They're processed again on next deployment
|
||||||
|
|
||||||
|
### Template Examples
|
||||||
|
|
||||||
|
See these templates for real-world usage:
|
||||||
|
|
||||||
|
**Service Exclusions:**
|
||||||
|
- `templates/compose/budibase.yaml` - Excludes watchtower from monitoring
|
||||||
|
- `templates/compose/pgbackweb.yaml` - Excludes backup service
|
||||||
|
- `templates/compose/elasticsearch-with-kibana.yaml` - Excludes elasticsearch
|
||||||
|
|
||||||
|
**Inline Content:**
|
||||||
|
- `templates/compose/traccar.yaml` - XML configuration (multiline)
|
||||||
|
- `templates/compose/supabase.yaml` - Multiple config files
|
||||||
|
- `templates/compose/searxng.yaml` - Settings file
|
||||||
|
- `templates/compose/invoice-ninja.yaml` - Nginx config
|
||||||
|
|
||||||
|
**Directory Flags:**
|
||||||
|
- `templates/compose/paperless.yaml` - Explicit directory creation
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
**Unit Tests:**
|
||||||
|
- `tests/Unit/StripCoolifyCustomFieldsTest.php` - Custom field stripping logic
|
||||||
|
- `tests/Unit/ExcludeFromHealthCheckTest.php` - Health check exclusion behavior
|
||||||
|
- `tests/Unit/ContainerStatusAggregatorTest.php` - Status aggregation with exclusions
|
||||||
|
|
||||||
|
**Test Coverage:**
|
||||||
|
- ✅ All custom fields (exclude_from_hc, content, isDirectory, is_directory)
|
||||||
|
- ✅ Multiline content (YAML `|` syntax)
|
||||||
|
- ✅ Short vs long volume syntax
|
||||||
|
- ✅ Field stripping without data loss
|
||||||
|
- ✅ Standard Docker Compose field preservation
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
description: High-level project mission, core concepts, and architectural overview
|
|
||||||
globs: README.md, CONTRIBUTING.md, CHANGELOG.md, *.md
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Coolify Project Overview
|
# Coolify Project Overview
|
||||||
|
|
||||||
## What is Coolify?
|
## What is Coolify?
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
---
|
|
||||||
description: Complete technology stack, dependencies, and infrastructure components
|
|
||||||
globs: composer.json, package.json, docker-compose*.yml, config/*.php
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Coolify Technology Stack
|
# Coolify Technology Stack
|
||||||
|
|
||||||
|
Complete technology stack, dependencies, and infrastructure components.
|
||||||
|
|
||||||
## Backend Framework
|
## Backend Framework
|
||||||
|
|
||||||
### **Laravel 12.4.1** (PHP Framework)
|
### **Laravel 12.4.1** (PHP Framework)
|
||||||
- **Location**: [composer.json](mdc:composer.json)
|
|
||||||
- **Purpose**: Core application framework
|
- **Purpose**: Core application framework
|
||||||
- **Key Features**:
|
- **Key Features**:
|
||||||
- Eloquent ORM for database interactions
|
- Eloquent ORM for database interactions
|
||||||
@@ -16,8 +12,8 @@ alwaysApply: false
|
|||||||
- Queue system for background jobs
|
- Queue system for background jobs
|
||||||
- Event-driven architecture
|
- Event-driven architecture
|
||||||
|
|
||||||
### **PHP 8.4**
|
### **PHP 8.4.7**
|
||||||
- **Requirement**: `^8.4` in [composer.json](mdc:composer.json)
|
- **Requirement**: `^8.4` in composer.json
|
||||||
- **Features Used**:
|
- **Features Used**:
|
||||||
- Typed properties and return types
|
- Typed properties and return types
|
||||||
- Attributes for validation and configuration
|
- Attributes for validation and configuration
|
||||||
@@ -28,11 +24,11 @@ alwaysApply: false
|
|||||||
|
|
||||||
### **Livewire 3.5.20** (Primary Frontend Framework)
|
### **Livewire 3.5.20** (Primary Frontend Framework)
|
||||||
- **Purpose**: Server-side rendering with reactive components
|
- **Purpose**: Server-side rendering with reactive components
|
||||||
- **Location**: [app/Livewire/](mdc:app/Livewire/)
|
- **Location**: `app/Livewire/`
|
||||||
- **Key Components**:
|
- **Key Components**:
|
||||||
- [Dashboard.php](mdc:app/Livewire/Dashboard.php) - Main interface
|
- Dashboard - Main interface
|
||||||
- [ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php) - Real-time monitoring
|
- ActivityMonitor - Real-time monitoring
|
||||||
- [MonacoEditor.php](mdc:app/Livewire/MonacoEditor.php) - Code editor
|
- MonacoEditor - Code editor
|
||||||
|
|
||||||
### **Alpine.js** (Client-Side Interactivity)
|
### **Alpine.js** (Client-Side Interactivity)
|
||||||
- **Purpose**: Lightweight JavaScript for DOM manipulation
|
- **Purpose**: Lightweight JavaScript for DOM manipulation
|
||||||
@@ -40,8 +36,7 @@ alwaysApply: false
|
|||||||
- **Usage**: Declarative directives in Blade templates
|
- **Usage**: Declarative directives in Blade templates
|
||||||
|
|
||||||
### **Tailwind CSS 4.1.4** (Styling Framework)
|
### **Tailwind CSS 4.1.4** (Styling Framework)
|
||||||
- **Location**: [package.json](mdc:package.json)
|
- **Configuration**: `postcss.config.cjs`
|
||||||
- **Configuration**: [postcss.config.cjs](mdc:postcss.config.cjs)
|
|
||||||
- **Extensions**:
|
- **Extensions**:
|
||||||
- `@tailwindcss/forms` - Form styling
|
- `@tailwindcss/forms` - Form styling
|
||||||
- `@tailwindcss/typography` - Content typography
|
- `@tailwindcss/typography` - Content typography
|
||||||
@@ -57,7 +52,7 @@ alwaysApply: false
|
|||||||
### **PostgreSQL 15** (Primary Database)
|
### **PostgreSQL 15** (Primary Database)
|
||||||
- **Purpose**: Main application data storage
|
- **Purpose**: Main application data storage
|
||||||
- **Features**: JSONB support, advanced indexing
|
- **Features**: JSONB support, advanced indexing
|
||||||
- **Models**: [app/Models/](mdc:app/Models/)
|
- **Models**: `app/Models/`
|
||||||
|
|
||||||
### **Redis 7** (Caching & Real-time)
|
### **Redis 7** (Caching & Real-time)
|
||||||
- **Purpose**:
|
- **Purpose**:
|
||||||
@@ -67,14 +62,14 @@ alwaysApply: false
|
|||||||
- WebSocket session management
|
- WebSocket session management
|
||||||
|
|
||||||
### **Supported Databases** (For User Applications)
|
### **Supported Databases** (For User Applications)
|
||||||
- **PostgreSQL**: [StandalonePostgresql.php](mdc:app/Models/StandalonePostgresql.php)
|
- **PostgreSQL**: StandalonePostgresql
|
||||||
- **MySQL**: [StandaloneMysql.php](mdc:app/Models/StandaloneMysql.php)
|
- **MySQL**: StandaloneMysql
|
||||||
- **MariaDB**: [StandaloneMariadb.php](mdc:app/Models/StandaloneMariadb.php)
|
- **MariaDB**: StandaloneMariadb
|
||||||
- **MongoDB**: [StandaloneMongodb.php](mdc:app/Models/StandaloneMongodb.php)
|
- **MongoDB**: StandaloneMongodb
|
||||||
- **Redis**: [StandaloneRedis.php](mdc:app/Models/StandaloneRedis.php)
|
- **Redis**: StandaloneRedis
|
||||||
- **KeyDB**: [StandaloneKeydb.php](mdc:app/Models/StandaloneKeydb.php)
|
- **KeyDB**: StandaloneKeydb
|
||||||
- **Dragonfly**: [StandaloneDragonfly.php](mdc:app/Models/StandaloneDragonfly.php)
|
- **Dragonfly**: StandaloneDragonfly
|
||||||
- **ClickHouse**: [StandaloneClickhouse.php](mdc:app/Models/StandaloneClickhouse.php)
|
- **ClickHouse**: StandaloneClickhouse
|
||||||
|
|
||||||
## Authentication & Security
|
## Authentication & Security
|
||||||
|
|
||||||
@@ -101,7 +96,7 @@ alwaysApply: false
|
|||||||
|
|
||||||
### **Queue System**
|
### **Queue System**
|
||||||
- **Backend**: Redis-based queues
|
- **Backend**: Redis-based queues
|
||||||
- **Jobs**: [app/Jobs/](mdc:app/Jobs/)
|
- **Jobs**: `app/Jobs/`
|
||||||
- **Processing**: Background deployment and monitoring tasks
|
- **Processing**: Background deployment and monitoring tasks
|
||||||
|
|
||||||
## Development Tools
|
## Development Tools
|
||||||
@@ -130,21 +125,21 @@ alwaysApply: false
|
|||||||
- **Gitea**: Self-hosted Git service
|
- **Gitea**: Self-hosted Git service
|
||||||
|
|
||||||
### **Cloud Storage**
|
### **Cloud Storage**
|
||||||
- **AWS S3**: [league/flysystem-aws-s3-v3](mdc:composer.json)
|
- **AWS S3**: league/flysystem-aws-s3-v3
|
||||||
- **SFTP**: [league/flysystem-sftp-v3](mdc:composer.json)
|
- **SFTP**: league/flysystem-sftp-v3
|
||||||
- **Local Storage**: File system integration
|
- **Local Storage**: File system integration
|
||||||
|
|
||||||
### **Notification Services**
|
### **Notification Services**
|
||||||
- **Email**: [resend/resend-laravel](mdc:composer.json)
|
- **Email**: resend/resend-laravel
|
||||||
- **Discord**: Custom webhook integration
|
- **Discord**: Custom webhook integration
|
||||||
- **Slack**: Webhook notifications
|
- **Slack**: Webhook notifications
|
||||||
- **Telegram**: Bot API integration
|
- **Telegram**: Bot API integration
|
||||||
- **Pushover**: Push notifications
|
- **Pushover**: Push notifications
|
||||||
|
|
||||||
### **Monitoring & Logging**
|
### **Monitoring & Logging**
|
||||||
- **Sentry**: [sentry/sentry-laravel](mdc:composer.json) - Error tracking
|
- **Sentry**: sentry/sentry-laravel - Error tracking
|
||||||
- **Laravel Ray**: [spatie/laravel-ray](mdc:composer.json) - Debug tool
|
- **Laravel Ray**: spatie/laravel-ray - Debug tool
|
||||||
- **Activity Log**: [spatie/laravel-activitylog](mdc:composer.json)
|
- **Activity Log**: spatie/laravel-activitylog
|
||||||
|
|
||||||
## DevOps & Infrastructure
|
## DevOps & Infrastructure
|
||||||
|
|
||||||
@@ -181,9 +176,9 @@ alwaysApply: false
|
|||||||
## API & Documentation
|
## API & Documentation
|
||||||
|
|
||||||
### **OpenAPI/Swagger**
|
### **OpenAPI/Swagger**
|
||||||
- **Documentation**: [openapi.json](mdc:openapi.json) (373KB)
|
- **Documentation**: openapi.json (373KB)
|
||||||
- **Generator**: [zircote/swagger-php](mdc:composer.json)
|
- **Generator**: zircote/swagger-php
|
||||||
- **API Routes**: [routes/api.php](mdc:routes/api.php)
|
- **API Routes**: `routes/api.php`
|
||||||
|
|
||||||
### **WebSocket Communication**
|
### **WebSocket Communication**
|
||||||
- **Laravel Echo**: Real-time event broadcasting
|
- **Laravel Echo**: Real-time event broadcasting
|
||||||
@@ -192,7 +187,7 @@ alwaysApply: false
|
|||||||
|
|
||||||
## Package Management
|
## Package Management
|
||||||
|
|
||||||
### **PHP Dependencies** ([composer.json](mdc:composer.json))
|
### **PHP Dependencies** (composer.json)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"require": {
|
"require": {
|
||||||
@@ -205,7 +200,7 @@ alwaysApply: false
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### **JavaScript Dependencies** ([package.json](mdc:package.json))
|
### **JavaScript Dependencies** (package.json)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -223,15 +218,15 @@ alwaysApply: false
|
|||||||
## Configuration Files
|
## Configuration Files
|
||||||
|
|
||||||
### **Build Configuration**
|
### **Build Configuration**
|
||||||
- **[vite.config.js](mdc:vite.config.js)**: Frontend build setup
|
- **vite.config.js**: Frontend build setup
|
||||||
- **[postcss.config.cjs](mdc:postcss.config.cjs)**: CSS processing
|
- **postcss.config.cjs**: CSS processing
|
||||||
- **[rector.php](mdc:rector.php)**: PHP refactoring rules
|
- **rector.php**: PHP refactoring rules
|
||||||
- **[pint.json](mdc:pint.json)**: Code style configuration
|
- **pint.json**: Code style configuration
|
||||||
|
|
||||||
### **Testing Configuration**
|
### **Testing Configuration**
|
||||||
- **[phpunit.xml](mdc:phpunit.xml)**: Unit test configuration
|
- **phpunit.xml**: Unit test configuration
|
||||||
- **[phpunit.dusk.xml](mdc:phpunit.dusk.xml)**: Browser test configuration
|
- **phpunit.dusk.xml**: Browser test configuration
|
||||||
- **[tests/Pest.php](mdc:tests/Pest.php)**: Pest testing setup
|
- **tests/Pest.php**: Pest testing setup
|
||||||
|
|
||||||
## Version Requirements
|
## 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
|
# Coolify Development Workflow
|
||||||
|
|
||||||
## Development Environment Setup
|
## Development Environment Setup
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
<laravel-boost-guidelines>
|
<laravel-boost-guidelines>
|
||||||
=== foundation rules ===
|
=== 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
|
# 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.
|
> **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.
|
||||||
@@ -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
|
||||||
@@ -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
|
# Coolify API & Routing Architecture
|
||||||
|
|
||||||
## Routing Structure
|
## 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
|
# Coolify Database Architecture & Patterns
|
||||||
|
|
||||||
## Database Strategy
|
## 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
|
# 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
|
# Coolify Frontend Architecture & Patterns
|
||||||
|
|
||||||
## Frontend Philosophy
|
## Frontend Philosophy
|
||||||
@@ -263,7 +258,7 @@ public bool $autoDisable = true; // Automatically disable if no permission
|
|||||||
- **Automatic disabling** for unauthorized users
|
- **Automatic disabling** for unauthorized users
|
||||||
- **Smart behavior** (disables instantSave on checkboxes 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
|
## 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
|
# Coolify Security Architecture & Patterns
|
||||||
|
|
||||||
## Security Philosophy
|
## Security Philosophy
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
reviews:
|
|
||||||
review_status: false
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
---
|
|
||||||
description: Complete guide to Coolify Cursor rules and development patterns
|
|
||||||
globs: .cursor/rules/*.mdc
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Coolify Cursor Rules - Complete Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This comprehensive set of Cursor Rules provides deep insights into **Coolify**, an open-source self-hostable alternative to Heroku/Netlify/Vercel. These rules will help you understand, navigate, and contribute to this complex Laravel-based deployment platform.
|
|
||||||
|
|
||||||
> **Cross-Reference**: This directory is for **detailed, topic-specific rules** used by Cursor IDE and other AI assistants. For Claude Code specifically, also see **[CLAUDE.md](mdc:CLAUDE.md)** which provides a condensed, workflow-focused guide. Both systems share core principles but are optimized for their respective tools.
|
|
||||||
>
|
|
||||||
> **Maintaining Rules**: When updating these rules, see **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for synchronization guidelines to keep CLAUDE.md and .cursor/rules/ consistent.
|
|
||||||
|
|
||||||
## Rule Categories
|
|
||||||
|
|
||||||
### 🏗️ Architecture & Foundation
|
|
||||||
- **[project-overview.mdc](mdc:.cursor/rules/project-overview.mdc)** - What Coolify is and its core mission
|
|
||||||
- **[technology-stack.mdc](mdc:.cursor/rules/technology-stack.mdc)** - Complete technology stack and dependencies
|
|
||||||
- **[application-architecture.mdc](mdc:.cursor/rules/application-architecture.mdc)** - Laravel application structure and patterns
|
|
||||||
|
|
||||||
### 🎨 Frontend Development
|
|
||||||
- **[frontend-patterns.mdc](mdc:.cursor/rules/frontend-patterns.mdc)** - Livewire + Alpine.js + Tailwind architecture
|
|
||||||
- **[form-components.mdc](mdc:.cursor/rules/form-components.mdc)** - Enhanced form components with built-in authorization
|
|
||||||
|
|
||||||
### 🗄️ Data & Backend
|
|
||||||
- **[database-patterns.mdc](mdc:.cursor/rules/database-patterns.mdc)** - Database architecture, models, and data management
|
|
||||||
- **[deployment-architecture.mdc](mdc:.cursor/rules/deployment-architecture.mdc)** - Docker orchestration and deployment workflows
|
|
||||||
|
|
||||||
### 🌐 API & Communication
|
|
||||||
- **[api-and-routing.mdc](mdc:.cursor/rules/api-and-routing.mdc)** - RESTful APIs, webhooks, and routing patterns
|
|
||||||
|
|
||||||
### 🧪 Quality Assurance
|
|
||||||
- **[testing-patterns.mdc](mdc:.cursor/rules/testing-patterns.mdc)** - Testing strategies with Pest PHP and Laravel Dusk
|
|
||||||
|
|
||||||
### 🔧 Development Process
|
|
||||||
- **[development-workflow.mdc](mdc:.cursor/rules/development-workflow.mdc)** - Development setup, coding standards, and contribution guidelines
|
|
||||||
|
|
||||||
### 🔒 Security
|
|
||||||
- **[security-patterns.mdc](mdc:.cursor/rules/security-patterns.mdc)** - Security architecture, authentication, and best practices
|
|
||||||
|
|
||||||
## Quick Navigation
|
|
||||||
|
|
||||||
### Core Application Files
|
|
||||||
- **[app/Models/Application.php](mdc:app/Models/Application.php)** - Main application entity (74KB, highly complex)
|
|
||||||
- **[app/Models/Server.php](mdc:app/Models/Server.php)** - Server management (46KB, complex)
|
|
||||||
- **[app/Models/Service.php](mdc:app/Models/Service.php)** - Service definitions (58KB, complex)
|
|
||||||
- **[app/Models/Team.php](mdc:app/Models/Team.php)** - Multi-tenant structure (8.9KB)
|
|
||||||
|
|
||||||
### Configuration Files
|
|
||||||
- **[composer.json](mdc:composer.json)** - PHP dependencies and Laravel setup
|
|
||||||
- **[package.json](mdc:package.json)** - Frontend dependencies and build scripts
|
|
||||||
- **[vite.config.js](mdc:vite.config.js)** - Frontend build configuration
|
|
||||||
- **[docker-compose.dev.yml](mdc:docker-compose.dev.yml)** - Development environment
|
|
||||||
|
|
||||||
### API Documentation
|
|
||||||
- **[openapi.json](mdc:openapi.json)** - Complete API documentation (373KB)
|
|
||||||
- **[routes/api.php](mdc:routes/api.php)** - API endpoint definitions (13KB)
|
|
||||||
- **[routes/web.php](mdc:routes/web.php)** - Web application routes (21KB)
|
|
||||||
|
|
||||||
## Key Concepts to Understand
|
|
||||||
|
|
||||||
### 1. Multi-Tenant Architecture
|
|
||||||
Coolify uses a **team-based multi-tenancy** model where:
|
|
||||||
- Users belong to multiple teams
|
|
||||||
- Resources are scoped to teams
|
|
||||||
- Access control is team-based
|
|
||||||
- Data isolation is enforced at the database level
|
|
||||||
|
|
||||||
### 2. Deployment Philosophy
|
|
||||||
- **Docker-first** approach for all deployments
|
|
||||||
- **Zero-downtime** deployments with health checks
|
|
||||||
- **Git-based** workflows with webhook integration
|
|
||||||
- **Multi-server** support with SSH connections
|
|
||||||
|
|
||||||
### 3. Technology Stack
|
|
||||||
- **Backend**: Laravel 12 + PHP 8.4
|
|
||||||
- **Frontend**: Livewire 3.5 + Alpine.js + Tailwind CSS 4.1
|
|
||||||
- **Database**: PostgreSQL 15 + Redis 7
|
|
||||||
- **Containerization**: Docker + Docker Compose
|
|
||||||
- **Testing**: Pest PHP 3.8 + Laravel Dusk
|
|
||||||
|
|
||||||
### 4. Security Model
|
|
||||||
- **Defense-in-depth** security architecture
|
|
||||||
- **OAuth integration** with multiple providers
|
|
||||||
- **API token** authentication with Sanctum
|
|
||||||
- **Encrypted storage** for sensitive data
|
|
||||||
- **SSH key** management for server access
|
|
||||||
|
|
||||||
## Development Quick Start
|
|
||||||
|
|
||||||
### Local Setup
|
|
||||||
```bash
|
|
||||||
# Clone and setup
|
|
||||||
git clone https://github.com/coollabsio/coolify.git
|
|
||||||
cd coolify
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
# Docker development (recommended)
|
|
||||||
docker-compose -f docker-compose.dev.yml up -d
|
|
||||||
docker-compose exec app composer install
|
|
||||||
docker-compose exec app npm install
|
|
||||||
docker-compose exec app php artisan migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
```bash
|
|
||||||
# PHP code style
|
|
||||||
./vendor/bin/pint
|
|
||||||
|
|
||||||
# Static analysis
|
|
||||||
./vendor/bin/phpstan analyse
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
./vendor/bin/pest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Livewire Components
|
|
||||||
```php
|
|
||||||
class ApplicationShow extends Component
|
|
||||||
{
|
|
||||||
public Application $application;
|
|
||||||
|
|
||||||
protected $listeners = [
|
|
||||||
'deployment.started' => 'refresh',
|
|
||||||
'deployment.completed' => 'refresh',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function deploy(): void
|
|
||||||
{
|
|
||||||
$this->authorize('deploy', $this->application);
|
|
||||||
app(ApplicationDeploymentService::class)->deploy($this->application);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Controllers
|
|
||||||
```php
|
|
||||||
class ApplicationController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->middleware('auth:sanctum');
|
|
||||||
$this->middleware('team.access');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deploy(Application $application): JsonResponse
|
|
||||||
{
|
|
||||||
$this->authorize('deploy', $application);
|
|
||||||
$deployment = app(ApplicationDeploymentService::class)->deploy($application);
|
|
||||||
return response()->json(['deployment_id' => $deployment->id]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Queue Jobs
|
|
||||||
```php
|
|
||||||
class DeployApplicationJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
public function handle(DockerService $dockerService): void
|
|
||||||
{
|
|
||||||
$this->deployment->update(['status' => 'running']);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$dockerService->deployContainer($this->deployment->application);
|
|
||||||
$this->deployment->update(['status' => 'success']);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$this->deployment->update(['status' => 'failed']);
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Patterns
|
|
||||||
|
|
||||||
### Feature Tests
|
|
||||||
```php
|
|
||||||
test('user can deploy application via API', function () {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$application = Application::factory()->create(['team_id' => $user->currentTeam->id]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
|
||||||
->postJson("/api/v1/applications/{$application->id}/deploy");
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
expect($application->deployments()->count())->toBe(1);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Browser Tests
|
|
||||||
```php
|
|
||||||
test('user can create application through UI', function () {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->browse(function (Browser $browser) use ($user) {
|
|
||||||
$browser->loginAs($user)
|
|
||||||
->visit('/applications/create')
|
|
||||||
->type('name', 'Test App')
|
|
||||||
->press('Create Application')
|
|
||||||
->assertSee('Application created successfully');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- Multi-provider OAuth support
|
|
||||||
- API token authentication
|
|
||||||
- Team-based access control
|
|
||||||
- Session management
|
|
||||||
|
|
||||||
### Data Protection
|
|
||||||
- Encrypted environment variables
|
|
||||||
- Secure SSH key storage
|
|
||||||
- Input validation and sanitization
|
|
||||||
- SQL injection prevention
|
|
||||||
|
|
||||||
### Container Security
|
|
||||||
- Non-root container users
|
|
||||||
- Minimal capabilities
|
|
||||||
- Read-only filesystems
|
|
||||||
- Network isolation
|
|
||||||
|
|
||||||
## Performance Optimization
|
|
||||||
|
|
||||||
### Database
|
|
||||||
- Eager loading relationships
|
|
||||||
- Query optimization
|
|
||||||
- Connection pooling
|
|
||||||
- Caching strategies
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- Lazy loading components
|
|
||||||
- Asset optimization
|
|
||||||
- CDN integration
|
|
||||||
- Real-time updates via WebSockets
|
|
||||||
|
|
||||||
## Contributing Guidelines
|
|
||||||
|
|
||||||
### Code Standards
|
|
||||||
- PSR-12 PHP coding standards
|
|
||||||
- Laravel best practices
|
|
||||||
- Comprehensive test coverage
|
|
||||||
- Security-first approach
|
|
||||||
|
|
||||||
### Pull Request Process
|
|
||||||
1. Fork repository
|
|
||||||
2. Create feature branch
|
|
||||||
3. Implement with tests
|
|
||||||
4. Run quality checks
|
|
||||||
5. Submit PR with clear description
|
|
||||||
|
|
||||||
## Useful Commands
|
|
||||||
|
|
||||||
### Development
|
|
||||||
```bash
|
|
||||||
# Start development environment
|
|
||||||
docker-compose -f docker-compose.dev.yml up -d
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
./vendor/bin/pest
|
|
||||||
|
|
||||||
# Code formatting
|
|
||||||
./vendor/bin/pint
|
|
||||||
|
|
||||||
# Frontend development
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
```bash
|
|
||||||
# Install Coolify
|
|
||||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
|
||||||
|
|
||||||
# Update Coolify
|
|
||||||
./scripts/upgrade.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- **[README.md](mdc:README.md)** - Project overview and installation
|
|
||||||
- **[CONTRIBUTING.md](mdc:CONTRIBUTING.md)** - Contribution guidelines
|
|
||||||
- **[CHANGELOG.md](mdc:CHANGELOG.md)** - Release history
|
|
||||||
- **[TECH_STACK.md](mdc:TECH_STACK.md)** - Technology overview
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
- **[config/](mdc:config)** - Laravel configuration files
|
|
||||||
- **[database/migrations/](mdc:database/migrations)** - Database schema
|
|
||||||
- **[tests/](mdc:tests)** - Test suite
|
|
||||||
|
|
||||||
This comprehensive rule set provides everything needed to understand, develop, and contribute to the Coolify project effectively. Each rule focuses on specific aspects while maintaining connections to the broader architecture.
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
---
|
|
||||||
description: Laravel application structure, patterns, and architectural decisions
|
|
||||||
globs: app/**/*.php, config/*.php, bootstrap/**/*.php
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Coolify Application Architecture
|
|
||||||
|
|
||||||
## Laravel Project Structure
|
|
||||||
|
|
||||||
### **Core Application Directory** ([app/](mdc:app))
|
|
||||||
|
|
||||||
```
|
|
||||||
app/
|
|
||||||
├── Actions/ # Business logic actions (Action pattern)
|
|
||||||
├── Console/ # Artisan commands
|
|
||||||
├── Contracts/ # Interface definitions
|
|
||||||
├── Data/ # Data Transfer Objects (Spatie Laravel Data)
|
|
||||||
├── Enums/ # Enumeration classes
|
|
||||||
├── Events/ # Event classes
|
|
||||||
├── Exceptions/ # Custom exception classes
|
|
||||||
├── Helpers/ # Utility helper classes
|
|
||||||
├── Http/ # HTTP layer (Controllers, Middleware, Requests)
|
|
||||||
├── Jobs/ # Background job classes
|
|
||||||
├── Listeners/ # Event listeners
|
|
||||||
├── Livewire/ # Livewire components (Frontend)
|
|
||||||
├── Models/ # Eloquent models (Domain entities)
|
|
||||||
├── Notifications/ # Notification classes
|
|
||||||
├── Policies/ # Authorization policies
|
|
||||||
├── Providers/ # Service providers
|
|
||||||
├── Repositories/ # Repository pattern implementations
|
|
||||||
├── Services/ # Service layer classes
|
|
||||||
├── Traits/ # Reusable trait classes
|
|
||||||
└── View/ # View composers and creators
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Domain Models
|
|
||||||
|
|
||||||
### **Infrastructure Management**
|
|
||||||
|
|
||||||
#### **[Server.php](mdc:app/Models/Server.php)** (46KB, 1343 lines)
|
|
||||||
- **Purpose**: Physical/virtual server management
|
|
||||||
- **Key Relationships**:
|
|
||||||
- `hasMany(Application::class)` - Deployed applications
|
|
||||||
- `hasMany(StandalonePostgresql::class)` - Database instances
|
|
||||||
- `belongsTo(Team::class)` - Team ownership
|
|
||||||
- **Key Features**:
|
|
||||||
- SSH connection management
|
|
||||||
- Resource monitoring
|
|
||||||
- Proxy configuration (Traefik/Caddy)
|
|
||||||
- Docker daemon interaction
|
|
||||||
|
|
||||||
#### **[Application.php](mdc:app/Models/Application.php)** (74KB, 1734 lines)
|
|
||||||
- **Purpose**: Application deployment and management
|
|
||||||
- **Key Relationships**:
|
|
||||||
- `belongsTo(Server::class)` - Deployment target
|
|
||||||
- `belongsTo(Environment::class)` - Environment context
|
|
||||||
- `hasMany(ApplicationDeploymentQueue::class)` - Deployment history
|
|
||||||
- **Key Features**:
|
|
||||||
- Git repository integration
|
|
||||||
- Docker build and deployment
|
|
||||||
- Environment variable management
|
|
||||||
- SSL certificate handling
|
|
||||||
|
|
||||||
#### **[Service.php](mdc:app/Models/Service.php)** (58KB, 1325 lines)
|
|
||||||
- **Purpose**: Multi-container service orchestration
|
|
||||||
- **Key Relationships**:
|
|
||||||
- `hasMany(ServiceApplication::class)` - Service components
|
|
||||||
- `hasMany(ServiceDatabase::class)` - Service databases
|
|
||||||
- `belongsTo(Environment::class)` - Environment context
|
|
||||||
- **Key Features**:
|
|
||||||
- Docker Compose generation
|
|
||||||
- Service dependency management
|
|
||||||
- Health check configuration
|
|
||||||
|
|
||||||
### **Team & Project Organization**
|
|
||||||
|
|
||||||
#### **[Team.php](mdc:app/Models/Team.php)** (8.9KB, 308 lines)
|
|
||||||
- **Purpose**: Multi-tenant team management
|
|
||||||
- **Key Relationships**:
|
|
||||||
- `hasMany(User::class)` - Team members
|
|
||||||
- `hasMany(Project::class)` - Team projects
|
|
||||||
- `hasMany(Server::class)` - Team servers
|
|
||||||
- **Key Features**:
|
|
||||||
- Resource limits and quotas
|
|
||||||
- Team-based access control
|
|
||||||
- Subscription management
|
|
||||||
|
|
||||||
#### **[Project.php](mdc:app/Models/Project.php)** (4.3KB, 156 lines)
|
|
||||||
- **Purpose**: Project organization and grouping
|
|
||||||
- **Key Relationships**:
|
|
||||||
- `hasMany(Environment::class)` - Project environments
|
|
||||||
- `belongsTo(Team::class)` - Team ownership
|
|
||||||
- **Key Features**:
|
|
||||||
- Environment isolation
|
|
||||||
- Resource organization
|
|
||||||
|
|
||||||
#### **[Environment.php](mdc:app/Models/Environment.php)**
|
|
||||||
- **Purpose**: Environment-specific configuration
|
|
||||||
- **Key Relationships**:
|
|
||||||
- `hasMany(Application::class)` - Environment applications
|
|
||||||
- `hasMany(Service::class)` - Environment services
|
|
||||||
- `belongsTo(Project::class)` - Project context
|
|
||||||
|
|
||||||
### **Database Management Models**
|
|
||||||
|
|
||||||
#### **Standalone Database Models**
|
|
||||||
- **[StandalonePostgresql.php](mdc:app/Models/StandalonePostgresql.php)** (11KB, 351 lines)
|
|
||||||
- **[StandaloneMysql.php](mdc:app/Models/StandaloneMysql.php)** (11KB, 351 lines)
|
|
||||||
- **[StandaloneMariadb.php](mdc:app/Models/StandaloneMariadb.php)** (10KB, 337 lines)
|
|
||||||
- **[StandaloneMongodb.php](mdc:app/Models/StandaloneMongodb.php)** (12KB, 370 lines)
|
|
||||||
- **[StandaloneRedis.php](mdc:app/Models/StandaloneRedis.php)** (12KB, 394 lines)
|
|
||||||
- **[StandaloneKeydb.php](mdc:app/Models/StandaloneKeydb.php)** (11KB, 347 lines)
|
|
||||||
- **[StandaloneDragonfly.php](mdc:app/Models/StandaloneDragonfly.php)** (11KB, 347 lines)
|
|
||||||
- **[StandaloneClickhouse.php](mdc:app/Models/StandaloneClickhouse.php)** (10KB, 336 lines)
|
|
||||||
|
|
||||||
**Common Features**:
|
|
||||||
- Database configuration management
|
|
||||||
- Backup scheduling and execution
|
|
||||||
- Connection string generation
|
|
||||||
- Health monitoring
|
|
||||||
|
|
||||||
### **Configuration & Settings**
|
|
||||||
|
|
||||||
#### **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** (7.6KB, 219 lines)
|
|
||||||
- **Purpose**: Application environment variable management
|
|
||||||
- **Key Features**:
|
|
||||||
- Encrypted value storage
|
|
||||||
- Build-time vs runtime variables
|
|
||||||
- Shared variable inheritance
|
|
||||||
|
|
||||||
#### **[InstanceSettings.php](mdc:app/Models/InstanceSettings.php)** (3.2KB, 124 lines)
|
|
||||||
- **Purpose**: Global Coolify instance configuration
|
|
||||||
- **Key Features**:
|
|
||||||
- FQDN and port configuration
|
|
||||||
- Auto-update settings
|
|
||||||
- Security configurations
|
|
||||||
|
|
||||||
## Architectural Patterns
|
|
||||||
|
|
||||||
### **Action Pattern** ([app/Actions/](mdc:app/Actions))
|
|
||||||
|
|
||||||
Using [lorisleiva/laravel-actions](mdc:composer.json) for business logic encapsulation:
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Example Action structure
|
|
||||||
class DeployApplication extends Action
|
|
||||||
{
|
|
||||||
public function handle(Application $application): void
|
|
||||||
{
|
|
||||||
// Business logic for deployment
|
|
||||||
}
|
|
||||||
|
|
||||||
public function asJob(Application $application): void
|
|
||||||
{
|
|
||||||
// Queue job implementation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Action Categories**:
|
|
||||||
- **Application/**: Deployment and management actions
|
|
||||||
- **Database/**: Database operations
|
|
||||||
- **Server/**: Server management actions
|
|
||||||
- **Service/**: Service orchestration actions
|
|
||||||
|
|
||||||
### **Repository Pattern** ([app/Repositories/](mdc:app/Repositories))
|
|
||||||
|
|
||||||
Data access abstraction layer:
|
|
||||||
- Encapsulates database queries
|
|
||||||
- Provides testable data layer
|
|
||||||
- Abstracts complex query logic
|
|
||||||
|
|
||||||
### **Service Layer** ([app/Services/](mdc:app/Services))
|
|
||||||
|
|
||||||
Business logic services:
|
|
||||||
- External API integrations
|
|
||||||
- Complex business operations
|
|
||||||
- Cross-cutting concerns
|
|
||||||
|
|
||||||
## Data Flow Architecture
|
|
||||||
|
|
||||||
### **Request Lifecycle**
|
|
||||||
|
|
||||||
1. **HTTP Request** → [routes/web.php](mdc:routes/web.php)
|
|
||||||
2. **Middleware** → Authentication, authorization
|
|
||||||
3. **Livewire Component** → [app/Livewire/](mdc:app/Livewire)
|
|
||||||
4. **Action/Service** → Business logic execution
|
|
||||||
5. **Model/Repository** → Data persistence
|
|
||||||
6. **Response** → Livewire reactive update
|
|
||||||
|
|
||||||
### **Background Processing**
|
|
||||||
|
|
||||||
1. **Job Dispatch** → Queue system (Redis)
|
|
||||||
2. **Job Processing** → [app/Jobs/](mdc:app/Jobs)
|
|
||||||
3. **Action Execution** → Business logic
|
|
||||||
4. **Event Broadcasting** → Real-time updates
|
|
||||||
5. **Notification** → User feedback
|
|
||||||
|
|
||||||
## Security Architecture
|
|
||||||
|
|
||||||
### **Multi-Tenant Isolation**
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Team-based query scoping
|
|
||||||
class Application extends Model
|
|
||||||
{
|
|
||||||
public function scopeOwnedByCurrentTeam($query)
|
|
||||||
{
|
|
||||||
return $query->whereHas('environment.project.team', function ($q) {
|
|
||||||
$q->where('id', currentTeam()->id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Authorization Layers**
|
|
||||||
|
|
||||||
1. **Team Membership** → User belongs to team
|
|
||||||
2. **Resource Ownership** → Resource belongs to team
|
|
||||||
3. **Policy Authorization** → [app/Policies/](mdc:app/Policies)
|
|
||||||
4. **Environment Isolation** → Project/environment boundaries
|
|
||||||
|
|
||||||
### **Data Protection**
|
|
||||||
|
|
||||||
- **Environment Variables**: Encrypted at rest
|
|
||||||
- **SSH Keys**: Secure storage and transmission
|
|
||||||
- **API Tokens**: Sanctum-based authentication
|
|
||||||
- **Audit Logging**: [spatie/laravel-activitylog](mdc:composer.json)
|
|
||||||
|
|
||||||
## Configuration Hierarchy
|
|
||||||
|
|
||||||
### **Global Configuration**
|
|
||||||
- **[InstanceSettings](mdc:app/Models/InstanceSettings.php)**: System-wide settings
|
|
||||||
- **[config/](mdc:config)**: Laravel configuration files
|
|
||||||
|
|
||||||
### **Team Configuration**
|
|
||||||
- **[Team](mdc:app/Models/Team.php)**: Team-specific settings
|
|
||||||
- **[ServerSetting](mdc:app/Models/ServerSetting.php)**: Server configurations
|
|
||||||
|
|
||||||
### **Project Configuration**
|
|
||||||
- **[ProjectSetting](mdc:app/Models/ProjectSetting.php)**: Project settings
|
|
||||||
- **[Environment](mdc:app/Models/Environment.php)**: Environment variables
|
|
||||||
|
|
||||||
### **Application Configuration**
|
|
||||||
- **[ApplicationSetting](mdc:app/Models/ApplicationSetting.php)**: App-specific settings
|
|
||||||
- **[EnvironmentVariable](mdc:app/Models/EnvironmentVariable.php)**: Runtime configuration
|
|
||||||
|
|
||||||
## Event-Driven Architecture
|
|
||||||
|
|
||||||
### **Event Broadcasting** ([app/Events/](mdc:app/Events))
|
|
||||||
|
|
||||||
Real-time updates using Laravel Echo and WebSockets:
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Example event structure
|
|
||||||
class ApplicationDeploymentStarted implements ShouldBroadcast
|
|
||||||
{
|
|
||||||
public function broadcastOn(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
new PrivateChannel("team.{$this->application->team->id}"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Event Listeners** ([app/Listeners/](mdc:app/Listeners))
|
|
||||||
|
|
||||||
- Deployment status updates
|
|
||||||
- Resource monitoring alerts
|
|
||||||
- Notification dispatching
|
|
||||||
- Audit log creation
|
|
||||||
|
|
||||||
## Database Design Patterns
|
|
||||||
|
|
||||||
### **Polymorphic Relationships**
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Environment variables can belong to multiple resource types
|
|
||||||
class EnvironmentVariable extends Model
|
|
||||||
{
|
|
||||||
public function resource(): MorphTo
|
|
||||||
{
|
|
||||||
return $this->morphTo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Team-Based Soft Scoping**
|
|
||||||
|
|
||||||
All major resources include team-based query scoping:
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Automatic team filtering
|
|
||||||
$applications = Application::ownedByCurrentTeam()->get();
|
|
||||||
$servers = Server::ownedByCurrentTeam()->get();
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Configuration Inheritance**
|
|
||||||
|
|
||||||
Environment variables cascade from:
|
|
||||||
1. **Shared Variables** → Team-wide defaults
|
|
||||||
2. **Project Variables** → Project-specific overrides
|
|
||||||
3. **Application Variables** → Application-specific values
|
|
||||||
|
|
||||||
## Integration Patterns
|
|
||||||
|
|
||||||
### **Git Provider Integration**
|
|
||||||
|
|
||||||
Abstracted git operations supporting:
|
|
||||||
- **GitHub**: [app/Models/GithubApp.php](mdc:app/Models/GithubApp.php)
|
|
||||||
- **GitLab**: [app/Models/GitlabApp.php](mdc:app/Models/GitlabApp.php)
|
|
||||||
- **Bitbucket**: Webhook integration
|
|
||||||
- **Gitea**: Self-hosted Git support
|
|
||||||
|
|
||||||
### **Docker Integration**
|
|
||||||
|
|
||||||
- **Container Management**: Direct Docker API communication
|
|
||||||
- **Image Building**: Dockerfile and Buildpack support
|
|
||||||
- **Network Management**: Custom Docker networks
|
|
||||||
- **Volume Management**: Persistent storage handling
|
|
||||||
|
|
||||||
### **SSH Communication**
|
|
||||||
|
|
||||||
- **[phpseclib/phpseclib](mdc:composer.json)**: Secure SSH connections
|
|
||||||
- **Multiplexing**: Connection pooling for efficiency
|
|
||||||
- **Key Management**: [PrivateKey](mdc:app/Models/PrivateKey.php) model
|
|
||||||
|
|
||||||
## Testing Architecture
|
|
||||||
|
|
||||||
### **Test Structure** ([tests/](mdc:tests))
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── Feature/ # Integration tests
|
|
||||||
├── Unit/ # Unit tests
|
|
||||||
├── Browser/ # Dusk browser tests
|
|
||||||
├── Traits/ # Test helper traits
|
|
||||||
├── Pest.php # Pest configuration
|
|
||||||
└── TestCase.php # Base test case
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Testing Patterns**
|
|
||||||
|
|
||||||
- **Feature Tests**: Full request lifecycle testing
|
|
||||||
- **Unit Tests**: Individual class/method testing
|
|
||||||
- **Browser Tests**: End-to-end user workflows
|
|
||||||
- **Database Testing**: Factories and seeders
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### **Query Optimization**
|
|
||||||
|
|
||||||
- **Eager Loading**: Prevent N+1 queries
|
|
||||||
- **Query Scoping**: Team-based filtering
|
|
||||||
- **Database Indexing**: Optimized for common queries
|
|
||||||
|
|
||||||
### **Caching Strategy**
|
|
||||||
|
|
||||||
- **Redis**: Session and cache storage
|
|
||||||
- **Model Caching**: Frequently accessed data
|
|
||||||
- **Query Caching**: Expensive query results
|
|
||||||
|
|
||||||
### **Background Processing**
|
|
||||||
|
|
||||||
- **Queue Workers**: Horizon-managed job processing
|
|
||||||
- **Job Batching**: Related job grouping
|
|
||||||
- **Failed Job Handling**: Automatic retry logic
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
title: Coolify AI Documentation
|
||||||
|
description: Master reference to all Coolify AI documentation in .ai/ directory
|
||||||
|
globs: **/*
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Coolify AI Documentation
|
||||||
|
|
||||||
|
All Coolify AI documentation has been consolidated in the **`.ai/`** directory for better organization and single source of truth.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
- **For Claude Code**: Start with `CLAUDE.md` in the root directory
|
||||||
|
- **For Cursor IDE**: Start with `.ai/README.md` for navigation
|
||||||
|
- **For All AI Tools**: Browse `.ai/` directory by topic
|
||||||
|
|
||||||
|
## Documentation Structure
|
||||||
|
|
||||||
|
All detailed documentation lives in `.ai/` with the following organization:
|
||||||
|
|
||||||
|
### 📚 Core Documentation
|
||||||
|
- **[Technology Stack](.ai/core/technology-stack.md)** - All versions, packages, dependencies (SINGLE SOURCE OF TRUTH for versions)
|
||||||
|
- **[Project Overview](.ai/core/project-overview.md)** - What Coolify is, high-level architecture
|
||||||
|
- **[Application Architecture](.ai/core/application-architecture.md)** - System design, components, relationships
|
||||||
|
- **[Deployment Architecture](.ai/core/deployment-architecture.md)** - Deployment flows, Docker, proxies
|
||||||
|
|
||||||
|
### 💻 Development
|
||||||
|
- **[Development Workflow](.ai/development/development-workflow.md)** - Dev setup, commands, daily workflows
|
||||||
|
- **[Testing Patterns](.ai/development/testing-patterns.md)** - How to write/run tests, Docker requirements
|
||||||
|
- **[Laravel Boost](.ai/development/laravel-boost.md)** - Laravel-specific guidelines (SINGLE SOURCE for Laravel Boost)
|
||||||
|
|
||||||
|
### 🎨 Code Patterns
|
||||||
|
- **[Database Patterns](.ai/patterns/database-patterns.md)** - Eloquent, migrations, relationships
|
||||||
|
- **[Frontend Patterns](.ai/patterns/frontend-patterns.md)** - Livewire, Alpine.js, Tailwind CSS
|
||||||
|
- **[Security Patterns](.ai/patterns/security-patterns.md)** - Auth, authorization, security
|
||||||
|
- **[Form Components](.ai/patterns/form-components.md)** - Enhanced forms with authorization
|
||||||
|
- **[API & Routing](.ai/patterns/api-and-routing.md)** - API design, routing conventions
|
||||||
|
|
||||||
|
### 📖 Meta
|
||||||
|
- **[Maintaining Docs](.ai/meta/maintaining-docs.md)** - How to update/improve documentation
|
||||||
|
- **[Sync Guide](.ai/meta/sync-guide.md)** - Keeping docs synchronized
|
||||||
|
|
||||||
|
## Quick Decision Tree
|
||||||
|
|
||||||
|
**What are you working on?**
|
||||||
|
|
||||||
|
### Running Commands
|
||||||
|
→ `.ai/development/development-workflow.md`
|
||||||
|
- `npm run dev` / `npm run build` - Frontend
|
||||||
|
- `php artisan serve` / `php artisan migrate` - Backend
|
||||||
|
- `docker exec coolify php artisan test` - Feature tests (requires Docker)
|
||||||
|
- `./vendor/bin/pest tests/Unit` - Unit tests (no Docker needed)
|
||||||
|
- `./vendor/bin/pint` - Code formatting
|
||||||
|
|
||||||
|
### Writing Tests
|
||||||
|
→ `.ai/development/testing-patterns.md`
|
||||||
|
- **Unit tests**: No database, use mocking, run outside Docker
|
||||||
|
- **Feature tests**: Can use database, MUST run inside Docker
|
||||||
|
- Critical: Docker execution requirements prevent database connection errors
|
||||||
|
|
||||||
|
### Building UI
|
||||||
|
→ `.ai/patterns/frontend-patterns.md` + `.ai/patterns/form-components.md`
|
||||||
|
- Livewire 3.5.20 with server-side state
|
||||||
|
- Alpine.js for client interactions
|
||||||
|
- Tailwind CSS 4.1.4 styling
|
||||||
|
- Form components with `canGate` authorization
|
||||||
|
|
||||||
|
### Database Work
|
||||||
|
→ `.ai/patterns/database-patterns.md`
|
||||||
|
- Eloquent ORM patterns
|
||||||
|
- Migration best practices
|
||||||
|
- Relationship definitions
|
||||||
|
- Query optimization
|
||||||
|
|
||||||
|
### Security & Authorization
|
||||||
|
→ `.ai/patterns/security-patterns.md` + `.ai/patterns/form-components.md`
|
||||||
|
- Team-based access control
|
||||||
|
- Policy and gate patterns
|
||||||
|
- Form authorization (`canGate`, `canResource`)
|
||||||
|
- API security with Sanctum
|
||||||
|
|
||||||
|
### Laravel-Specific
|
||||||
|
→ `.ai/development/laravel-boost.md`
|
||||||
|
- Laravel 12.4.1 patterns
|
||||||
|
- Livewire 3 best practices
|
||||||
|
- Pest testing patterns
|
||||||
|
- Laravel conventions
|
||||||
|
|
||||||
|
### Version Numbers
|
||||||
|
→ `.ai/core/technology-stack.md`
|
||||||
|
- **SINGLE SOURCE OF TRUTH** for all version numbers
|
||||||
|
- Laravel 12.4.1, PHP 8.4.7, Tailwind 4.1.4, etc.
|
||||||
|
- Never duplicate versions - always reference this file
|
||||||
|
|
||||||
|
## Critical Patterns (Always Follow)
|
||||||
|
|
||||||
|
### Testing Commands
|
||||||
|
```bash
|
||||||
|
# Unit tests (no database, outside Docker)
|
||||||
|
./vendor/bin/pest tests/Unit
|
||||||
|
|
||||||
|
# Feature tests (requires database, inside Docker)
|
||||||
|
docker exec coolify php artisan test
|
||||||
|
```
|
||||||
|
|
||||||
|
**NEVER** run Feature tests outside Docker - they will fail with database connection errors.
|
||||||
|
|
||||||
|
### Form Authorization
|
||||||
|
ALWAYS include authorization on form components:
|
||||||
|
```blade
|
||||||
|
<x-forms.input canGate="update" :canResource="$resource" id="name" label="Name" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Livewire Components
|
||||||
|
MUST have exactly ONE root element. No exceptions.
|
||||||
|
|
||||||
|
### Version Numbers
|
||||||
|
Use exact versions from `technology-stack.md`:
|
||||||
|
- ✅ Laravel 12.4.1
|
||||||
|
- ❌ Laravel 12 or "v12"
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
```bash
|
||||||
|
# Always run before committing
|
||||||
|
./vendor/bin/pint
|
||||||
|
```
|
||||||
|
|
||||||
|
## For AI Assistants
|
||||||
|
|
||||||
|
### Important Notes
|
||||||
|
1. **Single Source of Truth**: Each piece of information exists in ONE location only
|
||||||
|
2. **Cross-Reference, Don't Duplicate**: Link to other files instead of copying content
|
||||||
|
3. **Version Precision**: Always use exact versions from `technology-stack.md`
|
||||||
|
4. **Docker for Feature Tests**: This is non-negotiable for database-dependent tests
|
||||||
|
5. **Form Authorization**: Security requirement, not optional
|
||||||
|
|
||||||
|
### When to Use Which File
|
||||||
|
- **Quick commands**: `CLAUDE.md` or `development-workflow.md`
|
||||||
|
- **Detailed patterns**: Topic-specific files in `.ai/patterns/`
|
||||||
|
- **Testing**: `.ai/development/testing-patterns.md`
|
||||||
|
- **Laravel specifics**: `.ai/development/laravel-boost.md`
|
||||||
|
- **Versions**: `.ai/core/technology-stack.md`
|
||||||
|
|
||||||
|
## Maintaining Documentation
|
||||||
|
|
||||||
|
When updating documentation:
|
||||||
|
1. Read `.ai/meta/maintaining-docs.md` first
|
||||||
|
2. Follow single source of truth principle
|
||||||
|
3. Update cross-references when moving content
|
||||||
|
4. Test all links work
|
||||||
|
5. See `.ai/meta/sync-guide.md` for sync guidelines
|
||||||
|
|
||||||
|
## Migration Note
|
||||||
|
|
||||||
|
This file replaces all previous `.cursor/rules/*.mdc` files. All content has been migrated to `.ai/` directory for better organization and to serve as single source of truth for all AI tools (Claude Code, Cursor IDE, etc.).
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
---
|
|
||||||
description: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness.
|
|
||||||
globs: .cursor/rules/*.mdc
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# Cursor Rules Maintenance Guide
|
|
||||||
|
|
||||||
> **Important**: These rules in `.cursor/rules/` are shared between Cursor IDE and other AI assistants. Changes here should be reflected in **[CLAUDE.md](mdc:CLAUDE.md)** when they affect core workflows or patterns.
|
|
||||||
>
|
|
||||||
> **Synchronization Guide**: See **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for detailed guidelines on maintaining consistency between CLAUDE.md and .cursor/rules/.
|
|
||||||
|
|
||||||
- **Required Rule Structure:**
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
description: Clear, one-line description of what the rule enforces
|
|
||||||
globs: path/to/files/*.ext, other/path/**/*
|
|
||||||
alwaysApply: boolean
|
|
||||||
---
|
|
||||||
|
|
||||||
- **Main Points in Bold**
|
|
||||||
- Sub-points with details
|
|
||||||
- Examples and explanations
|
|
||||||
```
|
|
||||||
|
|
||||||
- **File References:**
|
|
||||||
- Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files
|
|
||||||
- Example: [prisma.mdc](mdc:.cursor/rules/prisma.mdc) for rule references
|
|
||||||
- Example: [schema.prisma](mdc:prisma/schema.prisma) for code references
|
|
||||||
|
|
||||||
- **Code Examples:**
|
|
||||||
- Use language-specific code blocks
|
|
||||||
```typescript
|
|
||||||
// ✅ DO: Show good examples
|
|
||||||
const goodExample = true;
|
|
||||||
|
|
||||||
// ❌ DON'T: Show anti-patterns
|
|
||||||
const badExample = false;
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Rule Content Guidelines:**
|
|
||||||
- Start with high-level overview
|
|
||||||
- Include specific, actionable requirements
|
|
||||||
- Show examples of correct implementation
|
|
||||||
- Reference existing code when possible
|
|
||||||
- Keep rules DRY by referencing other rules
|
|
||||||
|
|
||||||
- **Rule Maintenance:**
|
|
||||||
- Update rules when new patterns emerge
|
|
||||||
- Add examples from actual codebase
|
|
||||||
- Remove outdated patterns
|
|
||||||
- Cross-reference related rules
|
|
||||||
|
|
||||||
- **Best Practices:**
|
|
||||||
- Use bullet points for clarity
|
|
||||||
- Keep descriptions concise
|
|
||||||
- Include both DO and DON'T examples
|
|
||||||
- Reference actual code over theoretical examples
|
|
||||||
- Use consistent formatting across rules
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
---
|
|
||||||
description: Docker orchestration, deployment workflows, and containerization patterns
|
|
||||||
globs: app/Jobs/*.php, app/Actions/Application/*.php, app/Actions/Server/*.php, docker/*.*, *.yml, *.yaml
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Coolify Deployment Architecture
|
|
||||||
|
|
||||||
## Deployment Philosophy
|
|
||||||
|
|
||||||
Coolify orchestrates **Docker-based deployments** across multiple servers with automated configuration generation, zero-downtime deployments, and comprehensive monitoring.
|
|
||||||
|
|
||||||
## Core Deployment Components
|
|
||||||
|
|
||||||
### Deployment Models
|
|
||||||
- **[Application.php](mdc:app/Models/Application.php)** - Main application entity with deployment configurations
|
|
||||||
- **[ApplicationDeploymentQueue.php](mdc:app/Models/ApplicationDeploymentQueue.php)** - Deployment job orchestration
|
|
||||||
- **[Service.php](mdc:app/Models/Service.php)** - Multi-container service definitions
|
|
||||||
- **[Server.php](mdc:app/Models/Server.php)** - Target deployment infrastructure
|
|
||||||
|
|
||||||
### Infrastructure Management
|
|
||||||
- **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - SSH key management for secure server access
|
|
||||||
- **[StandaloneDocker.php](mdc:app/Models/StandaloneDocker.php)** - Single container deployments
|
|
||||||
- **[SwarmDocker.php](mdc:app/Models/SwarmDocker.php)** - Docker Swarm orchestration
|
|
||||||
|
|
||||||
## Deployment Workflow
|
|
||||||
|
|
||||||
### 1. Source Code Integration
|
|
||||||
```
|
|
||||||
Git Repository → Webhook → Coolify → Build & Deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Source Control Models
|
|
||||||
- **[GithubApp.php](mdc:app/Models/GithubApp.php)** - GitHub integration and webhooks
|
|
||||||
- **[GitlabApp.php](mdc:app/Models/GitlabApp.php)** - GitLab CI/CD integration
|
|
||||||
|
|
||||||
#### Deployment Triggers
|
|
||||||
- **Git push** to configured branches
|
|
||||||
- **Manual deployment** via UI
|
|
||||||
- **Scheduled deployments** via cron
|
|
||||||
- **API-triggered** deployments
|
|
||||||
|
|
||||||
### 2. Build Process
|
|
||||||
```
|
|
||||||
Source Code → Docker Build → Image Registry → Deployment
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Build Configurations
|
|
||||||
- **Dockerfile detection** and custom Dockerfile support
|
|
||||||
- **Buildpack integration** for framework detection
|
|
||||||
- **Multi-stage builds** for optimization
|
|
||||||
- **Cache layer** management for faster builds
|
|
||||||
|
|
||||||
### 3. Deployment Orchestration
|
|
||||||
```
|
|
||||||
Queue Job → Configuration Generation → Container Deployment → Health Checks
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment Actions
|
|
||||||
|
|
||||||
### Location: [app/Actions/](mdc:app/Actions)
|
|
||||||
|
|
||||||
#### Application Deployment Actions
|
|
||||||
- **Application/** - Core application deployment logic
|
|
||||||
- **Docker/** - Docker container management
|
|
||||||
- **Service/** - Multi-container service orchestration
|
|
||||||
- **Proxy/** - Reverse proxy configuration
|
|
||||||
|
|
||||||
#### Database Actions
|
|
||||||
- **Database/** - Database deployment and management
|
|
||||||
- Automated backup scheduling
|
|
||||||
- Connection management and health checks
|
|
||||||
|
|
||||||
#### Server Management Actions
|
|
||||||
- **Server/** - Server provisioning and configuration
|
|
||||||
- SSH connection establishment
|
|
||||||
- Docker daemon management
|
|
||||||
|
|
||||||
## Configuration Generation
|
|
||||||
|
|
||||||
### Dynamic Configuration
|
|
||||||
- **[ConfigurationGenerator.php](mdc:app/Services/ConfigurationGenerator.php)** - Generates deployment configurations
|
|
||||||
- **[ConfigurationRepository.php](mdc:app/Services/ConfigurationRepository.php)** - Configuration management
|
|
||||||
|
|
||||||
### Generated Configurations
|
|
||||||
#### Docker Compose Files
|
|
||||||
```yaml
|
|
||||||
# Generated docker-compose.yml structure
|
|
||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
image: ${APP_IMAGE}
|
|
||||||
environment:
|
|
||||||
- ${ENV_VARIABLES}
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.app.rule=Host(`${FQDN}`)
|
|
||||||
volumes:
|
|
||||||
- ${VOLUME_MAPPINGS}
|
|
||||||
networks:
|
|
||||||
- coolify
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Nginx Configurations
|
|
||||||
- **Reverse proxy** setup
|
|
||||||
- **SSL termination** with automatic certificates
|
|
||||||
- **Load balancing** for multiple instances
|
|
||||||
- **Custom headers** and routing rules
|
|
||||||
|
|
||||||
## Container Orchestration
|
|
||||||
|
|
||||||
### Docker Integration
|
|
||||||
- **[DockerImageParser.php](mdc:app/Services/DockerImageParser.php)** - Parse and validate Docker images
|
|
||||||
- **Container lifecycle** management
|
|
||||||
- **Resource allocation** and limits
|
|
||||||
- **Network isolation** and communication
|
|
||||||
|
|
||||||
### Volume Management
|
|
||||||
- **[LocalFileVolume.php](mdc:app/Models/LocalFileVolume.php)** - Persistent file storage
|
|
||||||
- **[LocalPersistentVolume.php](mdc:app/Models/LocalPersistentVolume.php)** - Data persistence
|
|
||||||
- **Backup integration** for volume data
|
|
||||||
|
|
||||||
### Network Configuration
|
|
||||||
- **Custom Docker networks** for isolation
|
|
||||||
- **Service discovery** between containers
|
|
||||||
- **Port mapping** and exposure
|
|
||||||
- **SSL/TLS termination**
|
|
||||||
|
|
||||||
## Environment Management
|
|
||||||
|
|
||||||
### Environment Isolation
|
|
||||||
- **[Environment.php](mdc:app/Models/Environment.php)** - Development, staging, production environments
|
|
||||||
- **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** - Application-specific variables
|
|
||||||
- **[SharedEnvironmentVariable.php](mdc:app/Models/SharedEnvironmentVariable.php)** - Cross-application variables
|
|
||||||
|
|
||||||
### Configuration Hierarchy
|
|
||||||
```
|
|
||||||
Instance Settings → Server Settings → Project Settings → Application Settings
|
|
||||||
```
|
|
||||||
|
|
||||||
## Preview Environments
|
|
||||||
|
|
||||||
### Git-Based Previews
|
|
||||||
- **[ApplicationPreview.php](mdc:app/Models/ApplicationPreview.php)** - Preview environment management
|
|
||||||
- **Automatic PR/MR previews** for feature branches
|
|
||||||
- **Isolated environments** for testing
|
|
||||||
- **Automatic cleanup** after merge/close
|
|
||||||
|
|
||||||
### Preview Workflow
|
|
||||||
```
|
|
||||||
Feature Branch → Auto-Deploy → Preview URL → Review → Cleanup
|
|
||||||
```
|
|
||||||
|
|
||||||
## SSL & Security
|
|
||||||
|
|
||||||
### Certificate Management
|
|
||||||
- **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate automation
|
|
||||||
- **Let's Encrypt** integration for free certificates
|
|
||||||
- **Custom certificate** upload support
|
|
||||||
- **Automatic renewal** and monitoring
|
|
||||||
|
|
||||||
### Security Patterns
|
|
||||||
- **Private Docker networks** for container isolation
|
|
||||||
- **SSH key-based** server authentication
|
|
||||||
- **Environment variable** encryption
|
|
||||||
- **Access control** via team permissions
|
|
||||||
|
|
||||||
## Backup & Recovery
|
|
||||||
|
|
||||||
### Database Backups
|
|
||||||
- **[ScheduledDatabaseBackup.php](mdc:app/Models/ScheduledDatabaseBackup.php)** - Automated database backups
|
|
||||||
- **[ScheduledDatabaseBackupExecution.php](mdc:app/Models/ScheduledDatabaseBackupExecution.php)** - Backup execution tracking
|
|
||||||
- **S3-compatible storage** for backup destinations
|
|
||||||
|
|
||||||
### Application Backups
|
|
||||||
- **Volume snapshots** for persistent data
|
|
||||||
- **Configuration export** for disaster recovery
|
|
||||||
- **Cross-region replication** for high availability
|
|
||||||
|
|
||||||
## Monitoring & Logging
|
|
||||||
|
|
||||||
### Real-Time Monitoring
|
|
||||||
- **[ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php)** - Live deployment monitoring
|
|
||||||
- **WebSocket-based** log streaming
|
|
||||||
- **Container health checks** and alerts
|
|
||||||
- **Resource usage** tracking
|
|
||||||
|
|
||||||
### Deployment Logs
|
|
||||||
- **Build process** logging
|
|
||||||
- **Container startup** logs
|
|
||||||
- **Application runtime** logs
|
|
||||||
- **Error tracking** and alerting
|
|
||||||
|
|
||||||
## Queue System
|
|
||||||
|
|
||||||
### Background Jobs
|
|
||||||
Location: [app/Jobs/](mdc:app/Jobs)
|
|
||||||
- **Deployment jobs** for async processing
|
|
||||||
- **Server monitoring** jobs
|
|
||||||
- **Backup scheduling** jobs
|
|
||||||
- **Notification delivery** jobs
|
|
||||||
|
|
||||||
### Queue Processing
|
|
||||||
- **Redis-backed** job queues
|
|
||||||
- **Laravel Horizon** for queue monitoring
|
|
||||||
- **Failed job** retry mechanisms
|
|
||||||
- **Queue worker** auto-scaling
|
|
||||||
|
|
||||||
## Multi-Server Deployment
|
|
||||||
|
|
||||||
### Server Types
|
|
||||||
- **Standalone servers** - Single Docker host
|
|
||||||
- **Docker Swarm** - Multi-node orchestration
|
|
||||||
- **Remote servers** - SSH-based deployment
|
|
||||||
- **Local development** - Docker Desktop integration
|
|
||||||
|
|
||||||
### Load Balancing
|
|
||||||
- **Traefik integration** for automatic load balancing
|
|
||||||
- **Health check** based routing
|
|
||||||
- **Blue-green deployments** for zero downtime
|
|
||||||
- **Rolling updates** with configurable strategies
|
|
||||||
|
|
||||||
## Deployment Strategies
|
|
||||||
|
|
||||||
### Zero-Downtime Deployment
|
|
||||||
```
|
|
||||||
Old Container → New Container Build → Health Check → Traffic Switch → Old Container Cleanup
|
|
||||||
```
|
|
||||||
|
|
||||||
### Blue-Green Deployment
|
|
||||||
- **Parallel environments** for safe deployments
|
|
||||||
- **Instant rollback** capability
|
|
||||||
- **Database migration** handling
|
|
||||||
- **Configuration synchronization**
|
|
||||||
|
|
||||||
### Rolling Updates
|
|
||||||
- **Gradual instance** replacement
|
|
||||||
- **Configurable update** strategy
|
|
||||||
- **Automatic rollback** on failure
|
|
||||||
- **Health check** validation
|
|
||||||
|
|
||||||
## API Integration
|
|
||||||
|
|
||||||
### Deployment API
|
|
||||||
Routes: [routes/api.php](mdc:routes/api.php)
|
|
||||||
- **RESTful endpoints** for deployment management
|
|
||||||
- **Webhook receivers** for CI/CD integration
|
|
||||||
- **Status reporting** endpoints
|
|
||||||
- **Deployment triggering** via API
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- **Laravel Sanctum** API tokens
|
|
||||||
- **Team-based** access control
|
|
||||||
- **Rate limiting** for API calls
|
|
||||||
- **Audit logging** for API usage
|
|
||||||
|
|
||||||
## Error Handling & Recovery
|
|
||||||
|
|
||||||
### Deployment Failure Recovery
|
|
||||||
- **Automatic rollback** on deployment failure
|
|
||||||
- **Health check** failure handling
|
|
||||||
- **Container crash** recovery
|
|
||||||
- **Resource exhaustion** protection
|
|
||||||
|
|
||||||
### Monitoring & Alerting
|
|
||||||
- **Failed deployment** notifications
|
|
||||||
- **Resource threshold** alerts
|
|
||||||
- **SSL certificate** expiry warnings
|
|
||||||
- **Backup failure** notifications
|
|
||||||
|
|
||||||
## Performance Optimization
|
|
||||||
|
|
||||||
### Build Optimization
|
|
||||||
- **Docker layer** caching
|
|
||||||
- **Multi-stage builds** for smaller images
|
|
||||||
- **Build artifact** reuse
|
|
||||||
- **Parallel build** processing
|
|
||||||
|
|
||||||
### Runtime Optimization
|
|
||||||
- **Container resource** limits
|
|
||||||
- **Auto-scaling** based on metrics
|
|
||||||
- **Connection pooling** for databases
|
|
||||||
- **CDN integration** for static assets
|
|
||||||
|
|
||||||
## Compliance & Governance
|
|
||||||
|
|
||||||
### Audit Trail
|
|
||||||
- **Deployment history** tracking
|
|
||||||
- **Configuration changes** logging
|
|
||||||
- **User action** auditing
|
|
||||||
- **Resource access** monitoring
|
|
||||||
|
|
||||||
### Backup Compliance
|
|
||||||
- **Retention policies** for backups
|
|
||||||
- **Encryption at rest** for sensitive data
|
|
||||||
- **Cross-region** backup replication
|
|
||||||
- **Recovery testing** automation
|
|
||||||
|
|
||||||
## Integration Patterns
|
|
||||||
|
|
||||||
### CI/CD Integration
|
|
||||||
- **GitHub Actions** compatibility
|
|
||||||
- **GitLab CI** pipeline integration
|
|
||||||
- **Custom webhook** endpoints
|
|
||||||
- **Build status** reporting
|
|
||||||
|
|
||||||
### External Services
|
|
||||||
- **S3-compatible** storage integration
|
|
||||||
- **External database** connections
|
|
||||||
- **Third-party monitoring** tools
|
|
||||||
- **Custom notification** channels
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
---
|
|
||||||
description: Guide for using Task Master to manage task-driven development workflows
|
|
||||||
globs: **/*
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
# Task Master Development Workflow
|
|
||||||
|
|
||||||
This guide outlines the typical process for using Task Master to manage software development projects.
|
|
||||||
|
|
||||||
## Primary Interaction: MCP Server vs. CLI
|
|
||||||
|
|
||||||
Task Master offers two primary ways to interact:
|
|
||||||
|
|
||||||
1. **MCP Server (Recommended for Integrated Tools)**:
|
|
||||||
- For AI agents and integrated development environments (like Cursor), interacting via the **MCP server is the preferred method**.
|
|
||||||
- The MCP server exposes Task Master functionality through a set of tools (e.g., `get_tasks`, `add_subtask`).
|
|
||||||
- This method offers better performance, structured data exchange, and richer error handling compared to CLI parsing.
|
|
||||||
- Refer to [`mcp.mdc`](mdc:.cursor/rules/mcp.mdc) for details on the MCP architecture and available tools.
|
|
||||||
- A comprehensive list and description of MCP tools and their corresponding CLI commands can be found in [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc).
|
|
||||||
- **Restart the MCP server** if core logic in `scripts/modules` or MCP tool/direct function definitions change.
|
|
||||||
|
|
||||||
2. **`task-master` CLI (For Users & Fallback)**:
|
|
||||||
- The global `task-master` command provides a user-friendly interface for direct terminal interaction.
|
|
||||||
- It can also serve as a fallback if the MCP server is inaccessible or a specific function isn't exposed via MCP.
|
|
||||||
- Install globally with `npm install -g task-master-ai` or use locally via `npx task-master-ai ...`.
|
|
||||||
- The CLI commands often mirror the MCP tools (e.g., `task-master list` corresponds to `get_tasks`).
|
|
||||||
- Refer to [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc) for a detailed command reference.
|
|
||||||
|
|
||||||
## Standard Development Workflow Process
|
|
||||||
|
|
||||||
- Start new projects by running `initialize_project` tool / `task-master init` or `parse_prd` / `task-master parse-prd --input='<prd-file.txt>'` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to generate initial tasks.json
|
|
||||||
- Begin coding sessions with `get_tasks` / `task-master list` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to see current tasks, status, and IDs
|
|
||||||
- Determine the next task to work on using `next_task` / `task-master next` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
|
||||||
- Analyze task complexity with `analyze_project_complexity` / `task-master analyze-complexity --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) before breaking down tasks
|
|
||||||
- Review complexity report using `complexity_report` / `task-master complexity-report` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
|
||||||
- Select tasks based on dependencies (all marked 'done'), priority level, and ID order
|
|
||||||
- Clarify tasks by checking task files in tasks/ directory or asking for user input
|
|
||||||
- View specific task details using `get_task` / `task-master show <id>` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to understand implementation requirements
|
|
||||||
- Break down complex tasks using `expand_task` / `task-master expand --id=<id> --force --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) with appropriate flags like `--force` (to replace existing subtasks) and `--research`.
|
|
||||||
- Clear existing subtasks if needed using `clear_subtasks` / `task-master clear-subtasks --id=<id>` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) before regenerating
|
|
||||||
- Implement code following task details, dependencies, and project standards
|
|
||||||
- Verify tasks according to test strategies before marking as complete (See [`tests.mdc`](mdc:.cursor/rules/tests.mdc))
|
|
||||||
- Mark completed tasks with `set_task_status` / `task-master set-status --id=<id> --status=done` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc))
|
|
||||||
- Update dependent tasks when implementation differs from original plan using `update` / `task-master update --from=<id> --prompt="..."` or `update_task` / `task-master update-task --id=<id> --prompt="..."` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc))
|
|
||||||
- Add new tasks discovered during implementation using `add_task` / `task-master add-task --prompt="..." --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
|
||||||
- Add new subtasks as needed using `add_subtask` / `task-master add-subtask --parent=<id> --title="..."` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
|
||||||
- Append notes or details to subtasks using `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='Add implementation notes here...\nMore details...'` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)).
|
|
||||||
- Generate task files with `generate` / `task-master generate` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) after updating tasks.json
|
|
||||||
- Maintain valid dependency structure with `add_dependency`/`remove_dependency` tools or `task-master add-dependency`/`remove-dependency` commands, `validate_dependencies` / `task-master validate-dependencies`, and `fix_dependencies` / `task-master fix-dependencies` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) when needed
|
|
||||||
- Respect dependency chains and task priorities when selecting work
|
|
||||||
- Report progress regularly using `get_tasks` / `task-master list`
|
|
||||||
|
|
||||||
## Task Complexity Analysis
|
|
||||||
|
|
||||||
- Run `analyze_project_complexity` / `task-master analyze-complexity --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) for comprehensive analysis
|
|
||||||
- Review complexity report via `complexity_report` / `task-master complexity-report` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) for a formatted, readable version.
|
|
||||||
- Focus on tasks with highest complexity scores (8-10) for detailed breakdown
|
|
||||||
- Use analysis results to determine appropriate subtask allocation
|
|
||||||
- Note that reports are automatically used by the `expand_task` tool/command
|
|
||||||
|
|
||||||
## Task Breakdown Process
|
|
||||||
|
|
||||||
- Use `expand_task` / `task-master expand --id=<id>`. It automatically uses the complexity report if found, otherwise generates default number of subtasks.
|
|
||||||
- Use `--num=<number>` to specify an explicit number of subtasks, overriding defaults or complexity report recommendations.
|
|
||||||
- Add `--research` flag to leverage Perplexity AI for research-backed expansion.
|
|
||||||
- Add `--force` flag to clear existing subtasks before generating new ones (default is to append).
|
|
||||||
- Use `--prompt="<context>"` to provide additional context when needed.
|
|
||||||
- Review and adjust generated subtasks as necessary.
|
|
||||||
- Use `expand_all` tool or `task-master expand --all` to expand multiple pending tasks at once, respecting flags like `--force` and `--research`.
|
|
||||||
- If subtasks need complete replacement (regardless of the `--force` flag on `expand`), clear them first with `clear_subtasks` / `task-master clear-subtasks --id=<id>`.
|
|
||||||
|
|
||||||
## Implementation Drift Handling
|
|
||||||
|
|
||||||
- When implementation differs significantly from planned approach
|
|
||||||
- When future tasks need modification due to current implementation choices
|
|
||||||
- When new dependencies or requirements emerge
|
|
||||||
- Use `update` / `task-master update --from=<futureTaskId> --prompt='<explanation>\nUpdate context...' --research` to update multiple future tasks.
|
|
||||||
- Use `update_task` / `task-master update-task --id=<taskId> --prompt='<explanation>\nUpdate context...' --research` to update a single specific task.
|
|
||||||
|
|
||||||
## Task Status Management
|
|
||||||
|
|
||||||
- Use 'pending' for tasks ready to be worked on
|
|
||||||
- Use 'done' for completed and verified tasks
|
|
||||||
- Use 'deferred' for postponed tasks
|
|
||||||
- Add custom status values as needed for project-specific workflows
|
|
||||||
|
|
||||||
## Task Structure Fields
|
|
||||||
|
|
||||||
- **id**: Unique identifier for the task (Example: `1`, `1.1`)
|
|
||||||
- **title**: Brief, descriptive title (Example: `"Initialize Repo"`)
|
|
||||||
- **description**: Concise summary of what the task involves (Example: `"Create a new repository, set up initial structure."`)
|
|
||||||
- **status**: Current state of the task (Example: `"pending"`, `"done"`, `"deferred"`)
|
|
||||||
- **dependencies**: IDs of prerequisite tasks (Example: `[1, 2.1]`)
|
|
||||||
- Dependencies are displayed with status indicators (✅ for completed, ⏱️ for pending)
|
|
||||||
- This helps quickly identify which prerequisite tasks are blocking work
|
|
||||||
- **priority**: Importance level (Example: `"high"`, `"medium"`, `"low"`)
|
|
||||||
- **details**: In-depth implementation instructions (Example: `"Use GitHub client ID/secret, handle callback, set session token."`)
|
|
||||||
- **testStrategy**: Verification approach (Example: `"Deploy and call endpoint to confirm 'Hello World' response."`)
|
|
||||||
- **subtasks**: List of smaller, more specific tasks (Example: `[{"id": 1, "title": "Configure OAuth", ...}]`)
|
|
||||||
- Refer to task structure details (previously linked to `tasks.mdc`).
|
|
||||||
|
|
||||||
## Configuration Management (Updated)
|
|
||||||
|
|
||||||
Taskmaster configuration is managed through two main mechanisms:
|
|
||||||
|
|
||||||
1. **`.taskmasterconfig` File (Primary):**
|
|
||||||
* Located in the project root directory.
|
|
||||||
* Stores most configuration settings: AI model selections (main, research, fallback), parameters (max tokens, temperature), logging level, default subtasks/priority, project name, etc.
|
|
||||||
* **Managed via `task-master models --setup` command.** Do not edit manually unless you know what you are doing.
|
|
||||||
* **View/Set specific models via `task-master models` command or `models` MCP tool.**
|
|
||||||
* Created automatically when you run `task-master models --setup` for the first time.
|
|
||||||
|
|
||||||
2. **Environment Variables (`.env` / `mcp.json`):**
|
|
||||||
* Used **only** for sensitive API keys and specific endpoint URLs.
|
|
||||||
* Place API keys (one per provider) in a `.env` file in the project root for CLI usage.
|
|
||||||
* For MCP/Cursor integration, configure these keys in the `env` section of `.cursor/mcp.json`.
|
|
||||||
* Available keys/variables: See `assets/env.example` or the Configuration section in the command reference (previously linked to `taskmaster.mdc`).
|
|
||||||
|
|
||||||
**Important:** Non-API key settings (like model selections, `MAX_TOKENS`, `TASKMASTER_LOG_LEVEL`) are **no longer configured via environment variables**. Use the `task-master models` command (or `--setup` for interactive configuration) or the `models` MCP tool.
|
|
||||||
**If AI commands FAIL in MCP** verify that the API key for the selected provider is present in the `env` section of `.cursor/mcp.json`.
|
|
||||||
**If AI commands FAIL in CLI** verify that the API key for the selected provider is present in the `.env` file in the root of the project.
|
|
||||||
|
|
||||||
## Determining the Next Task
|
|
||||||
|
|
||||||
- Run `next_task` / `task-master next` to show the next task to work on.
|
|
||||||
- The command identifies tasks with all dependencies satisfied
|
|
||||||
- Tasks are prioritized by priority level, dependency count, and ID
|
|
||||||
- The command shows comprehensive task information including:
|
|
||||||
- Basic task details and description
|
|
||||||
- Implementation details
|
|
||||||
- Subtasks (if they exist)
|
|
||||||
- Contextual suggested actions
|
|
||||||
- Recommended before starting any new development work
|
|
||||||
- Respects your project's dependency structure
|
|
||||||
- Ensures tasks are completed in the appropriate sequence
|
|
||||||
- Provides ready-to-use commands for common task actions
|
|
||||||
|
|
||||||
## Viewing Specific Task Details
|
|
||||||
|
|
||||||
- Run `get_task` / `task-master show <id>` to view a specific task.
|
|
||||||
- Use dot notation for subtasks: `task-master show 1.2` (shows subtask 2 of task 1)
|
|
||||||
- Displays comprehensive information similar to the next command, but for a specific task
|
|
||||||
- For parent tasks, shows all subtasks and their current status
|
|
||||||
- For subtasks, shows parent task information and relationship
|
|
||||||
- Provides contextual suggested actions appropriate for the specific task
|
|
||||||
- Useful for examining task details before implementation or checking status
|
|
||||||
|
|
||||||
## Managing Task Dependencies
|
|
||||||
|
|
||||||
- Use `add_dependency` / `task-master add-dependency --id=<id> --depends-on=<id>` to add a dependency.
|
|
||||||
- Use `remove_dependency` / `task-master remove-dependency --id=<id> --depends-on=<id>` to remove a dependency.
|
|
||||||
- The system prevents circular dependencies and duplicate dependency entries
|
|
||||||
- Dependencies are checked for existence before being added or removed
|
|
||||||
- Task files are automatically regenerated after dependency changes
|
|
||||||
- Dependencies are visualized with status indicators in task listings and files
|
|
||||||
|
|
||||||
## Iterative Subtask Implementation
|
|
||||||
|
|
||||||
Once a task has been broken down into subtasks using `expand_task` or similar methods, follow this iterative process for implementation:
|
|
||||||
|
|
||||||
1. **Understand the Goal (Preparation):**
|
|
||||||
* Use `get_task` / `task-master show <subtaskId>` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to thoroughly understand the specific goals and requirements of the subtask.
|
|
||||||
|
|
||||||
2. **Initial Exploration & Planning (Iteration 1):**
|
|
||||||
* This is the first attempt at creating a concrete implementation plan.
|
|
||||||
* Explore the codebase to identify the precise files, functions, and even specific lines of code that will need modification.
|
|
||||||
* Determine the intended code changes (diffs) and their locations.
|
|
||||||
* Gather *all* relevant details from this exploration phase.
|
|
||||||
|
|
||||||
3. **Log the Plan:**
|
|
||||||
* Run `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='<detailed plan>'`.
|
|
||||||
* Provide the *complete and detailed* findings from the exploration phase in the prompt. Include file paths, line numbers, proposed diffs, reasoning, and any potential challenges identified. Do not omit details. The goal is to create a rich, timestamped log within the subtask's `details`.
|
|
||||||
|
|
||||||
4. **Verify the Plan:**
|
|
||||||
* Run `get_task` / `task-master show <subtaskId>` again to confirm that the detailed implementation plan has been successfully appended to the subtask's details.
|
|
||||||
|
|
||||||
5. **Begin Implementation:**
|
|
||||||
* Set the subtask status using `set_task_status` / `task-master set-status --id=<subtaskId> --status=in-progress`.
|
|
||||||
* Start coding based on the logged plan.
|
|
||||||
|
|
||||||
6. **Refine and Log Progress (Iteration 2+):**
|
|
||||||
* As implementation progresses, you will encounter challenges, discover nuances, or confirm successful approaches.
|
|
||||||
* **Before appending new information**: Briefly review the *existing* details logged in the subtask (using `get_task` or recalling from context) to ensure the update adds fresh insights and avoids redundancy.
|
|
||||||
* **Regularly** use `update_subtask` / `task-master update-subtask --id=<subtaskId> --prompt='<update details>\n- What worked...\n- What didn't work...'` to append new findings.
|
|
||||||
* **Crucially, log:**
|
|
||||||
* What worked ("fundamental truths" discovered).
|
|
||||||
* What didn't work and why (to avoid repeating mistakes).
|
|
||||||
* Specific code snippets or configurations that were successful.
|
|
||||||
* Decisions made, especially if confirmed with user input.
|
|
||||||
* Any deviations from the initial plan and the reasoning.
|
|
||||||
* The objective is to continuously enrich the subtask's details, creating a log of the implementation journey that helps the AI (and human developers) learn, adapt, and avoid repeating errors.
|
|
||||||
|
|
||||||
7. **Review & Update Rules (Post-Implementation):**
|
|
||||||
* Once the implementation for the subtask is functionally complete, review all code changes and the relevant chat history.
|
|
||||||
* Identify any new or modified code patterns, conventions, or best practices established during the implementation.
|
|
||||||
* Create new or update existing rules following internal guidelines (previously linked to `cursor_rules.mdc` and `self_improve.mdc`).
|
|
||||||
|
|
||||||
8. **Mark Task Complete:**
|
|
||||||
* After verifying the implementation and updating any necessary rules, mark the subtask as completed: `set_task_status` / `task-master set-status --id=<subtaskId> --status=done`.
|
|
||||||
|
|
||||||
9. **Commit Changes (If using Git):**
|
|
||||||
* Stage the relevant code changes and any updated/new rule files (`git add .`).
|
|
||||||
* Craft a comprehensive Git commit message summarizing the work done for the subtask, including both code implementation and any rule adjustments.
|
|
||||||
* Execute the commit command directly in the terminal (e.g., `git commit -m 'feat(module): Implement feature X for subtask <subtaskId>\n\n- Details about changes...\n- Updated rule Y for pattern Z'`).
|
|
||||||
* Consider if a Changeset is needed according to internal versioning guidelines (previously linked to `changeset.mdc`). If so, run `npm run changeset`, stage the generated file, and amend the commit or create a new one.
|
|
||||||
|
|
||||||
10. **Proceed to Next Subtask:**
|
|
||||||
* Identify the next subtask (e.g., using `next_task` / `task-master next`).
|
|
||||||
|
|
||||||
## Code Analysis & Refactoring Techniques
|
|
||||||
|
|
||||||
- **Top-Level Function Search**:
|
|
||||||
- Useful for understanding module structure or planning refactors.
|
|
||||||
- Use grep/ripgrep to find exported functions/constants:
|
|
||||||
`rg "export (async function|function|const) \w+"` or similar patterns.
|
|
||||||
- Can help compare functions between files during migrations or identify potential naming conflicts.
|
|
||||||
|
|
||||||
---
|
|
||||||
*This workflow provides a general guideline. Adapt it based on your specific project needs and team practices.*
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
---
|
|
||||||
description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices.
|
|
||||||
globs: **/*
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
- **Rule Improvement Triggers:**
|
|
||||||
- New code patterns not covered by existing rules
|
|
||||||
- Repeated similar implementations across files
|
|
||||||
- Common error patterns that could be prevented
|
|
||||||
- New libraries or tools being used consistently
|
|
||||||
- Emerging best practices in the codebase
|
|
||||||
|
|
||||||
- **Analysis Process:**
|
|
||||||
- Compare new code with existing rules
|
|
||||||
- Identify patterns that should be standardized
|
|
||||||
- Look for references to external documentation
|
|
||||||
- Check for consistent error handling patterns
|
|
||||||
- Monitor test patterns and coverage
|
|
||||||
|
|
||||||
- **Rule Updates:**
|
|
||||||
- **Add New Rules When:**
|
|
||||||
- A new technology/pattern is used in 3+ files
|
|
||||||
- Common bugs could be prevented by a rule
|
|
||||||
- Code reviews repeatedly mention the same feedback
|
|
||||||
- New security or performance patterns emerge
|
|
||||||
|
|
||||||
- **Modify Existing Rules When:**
|
|
||||||
- Better examples exist in the codebase
|
|
||||||
- Additional edge cases are discovered
|
|
||||||
- Related rules have been updated
|
|
||||||
- Implementation details have changed
|
|
||||||
|
|
||||||
|
|
||||||
- **Rule Quality Checks:**
|
|
||||||
- Rules should be actionable and specific
|
|
||||||
- Examples should come from actual code
|
|
||||||
- References should be up to date
|
|
||||||
- Patterns should be consistently enforced
|
|
||||||
|
|
||||||
- **Continuous Improvement:**
|
|
||||||
- Monitor code review comments
|
|
||||||
- Track common development questions
|
|
||||||
- Update rules after major refactors
|
|
||||||
- Add links to relevant documentation
|
|
||||||
- Cross-reference related rules
|
|
||||||
|
|
||||||
- **Rule Deprecation:**
|
|
||||||
- Mark outdated patterns as deprecated
|
|
||||||
- Remove rules that no longer apply
|
|
||||||
- Update references to deprecated rules
|
|
||||||
- Document migration paths for old patterns
|
|
||||||
|
|
||||||
- **Documentation Updates:**
|
|
||||||
- Keep examples synchronized with code
|
|
||||||
- Update references to external docs
|
|
||||||
- Maintain links between related rules
|
|
||||||
- Document breaking changes
|
|
||||||
Follow [cursor_rules.mdc](mdc:.cursor/rules/cursor_rules.mdc) for proper rule formatting and structure.
|
|
||||||
@@ -17,8 +17,17 @@ env:
|
|||||||
IMAGE_NAME: "coollabsio/coolify-helper"
|
IMAGE_NAME: "coollabsio/coolify-helper"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
amd64:
|
build-push:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
platform: linux/amd64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- arch: aarch64
|
||||||
|
platform: linux/aarch64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -43,60 +52,22 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and Push Image
|
- name: Build and Push Image (${{ matrix.arch }})
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/coolify-helper/Dockerfile
|
file: docker/coolify-helper/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
||||||
labels: |
|
|
||||||
coolify.managed=true
|
|
||||||
aarch64:
|
|
||||||
runs-on: [ self-hosted, arm64 ]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and Push Image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/coolify-helper/Dockerfile
|
|
||||||
platforms: linux/aarch64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
|
|
||||||
labels: |
|
labels: |
|
||||||
coolify.managed=true
|
coolify.managed=true
|
||||||
|
|
||||||
merge-manifest:
|
merge-manifest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
needs: [ amd64, aarch64 ]
|
needs: build-push
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -126,14 +97,16 @@ jobs:
|
|||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
||||||
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
||||||
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,17 @@ env:
|
|||||||
IMAGE_NAME: "coollabsio/coolify-helper"
|
IMAGE_NAME: "coollabsio/coolify-helper"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
amd64:
|
build-push:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
platform: linux/amd64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- arch: aarch64
|
||||||
|
platform: linux/aarch64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -43,59 +52,21 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and Push Image
|
- name: Build and Push Image (${{ matrix.arch }})
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/coolify-helper/Dockerfile
|
file: docker/coolify-helper/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||||
labels: |
|
|
||||||
coolify.managed=true
|
|
||||||
aarch64:
|
|
||||||
runs-on: [ self-hosted, arm64 ]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and Push Image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/coolify-helper/Dockerfile
|
|
||||||
platforms: linux/aarch64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
|
||||||
labels: |
|
labels: |
|
||||||
coolify.managed=true
|
coolify.managed=true
|
||||||
merge-manifest:
|
merge-manifest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
needs: [ amd64, aarch64 ]
|
needs: build-push
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -125,14 +96,16 @@ jobs:
|
|||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||||
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||||
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,17 @@ env:
|
|||||||
IMAGE_NAME: "coollabsio/coolify"
|
IMAGE_NAME: "coollabsio/coolify"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
amd64:
|
build-push:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
platform: linux/amd64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- arch: aarch64
|
||||||
|
platform: linux/aarch64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -50,57 +59,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
|
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and Push Image
|
- name: Build and Push Image (${{ matrix.arch }})
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/production/Dockerfile
|
file: docker/production/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||||
|
|
||||||
aarch64:
|
|
||||||
runs-on: [self-hosted, arm64]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and Push Image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/production/Dockerfile
|
|
||||||
platforms: linux/aarch64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
|
||||||
|
|
||||||
merge-manifest:
|
merge-manifest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
needs: [amd64, aarch64]
|
needs: build-push
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -130,14 +102,16 @@ jobs:
|
|||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||||
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||||
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,17 @@ env:
|
|||||||
IMAGE_NAME: "coollabsio/coolify-realtime"
|
IMAGE_NAME: "coollabsio/coolify-realtime"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
amd64:
|
build-push:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
platform: linux/amd64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- arch: aarch64
|
||||||
|
platform: linux/aarch64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -47,62 +56,22 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and Push Image
|
- name: Build and Push Image (${{ matrix.arch }})
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/coolify-realtime/Dockerfile
|
file: docker/coolify-realtime/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
||||||
labels: |
|
|
||||||
coolify.managed=true
|
|
||||||
|
|
||||||
aarch64:
|
|
||||||
runs-on: [ self-hosted, arm64 ]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and Push Image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/coolify-realtime/Dockerfile
|
|
||||||
platforms: linux/aarch64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
|
|
||||||
labels: |
|
labels: |
|
||||||
coolify.managed=true
|
coolify.managed=true
|
||||||
|
|
||||||
merge-manifest:
|
merge-manifest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
needs: [ amd64, aarch64 ]
|
needs: build-push
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -132,14 +101,16 @@ jobs:
|
|||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
||||||
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
||||||
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,17 @@ env:
|
|||||||
IMAGE_NAME: "coollabsio/coolify-realtime"
|
IMAGE_NAME: "coollabsio/coolify-realtime"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
amd64:
|
build-push:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
platform: linux/amd64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- arch: aarch64
|
||||||
|
platform: linux/aarch64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -47,61 +56,22 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and Push Image
|
- name: Build and Push Image (${{ matrix.arch }})
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/coolify-realtime/Dockerfile
|
file: docker/coolify-realtime/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||||
labels: |
|
|
||||||
coolify.managed=true
|
|
||||||
|
|
||||||
aarch64:
|
|
||||||
runs-on: [ self-hosted, arm64 ]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and Push Image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/coolify-realtime/Dockerfile
|
|
||||||
platforms: linux/aarch64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
|
||||||
labels: |
|
labels: |
|
||||||
coolify.managed=true
|
coolify.managed=true
|
||||||
|
|
||||||
merge-manifest:
|
merge-manifest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
needs: [ amd64, aarch64 ]
|
needs: build-push
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -131,14 +101,16 @@ jobs:
|
|||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||||
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||||
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,17 @@ env:
|
|||||||
IMAGE_NAME: "coollabsio/coolify-testing-host"
|
IMAGE_NAME: "coollabsio/coolify-testing-host"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
amd64:
|
build-push:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
platform: linux/amd64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- arch: aarch64
|
||||||
|
platform: linux/aarch64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -38,56 +47,22 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Build and Push Image
|
- name: Build and Push Image (${{ matrix.arch }})
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/testing-host/Dockerfile
|
file: docker/testing-host/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
|
||||||
labels: |
|
|
||||||
coolify.managed=true
|
|
||||||
|
|
||||||
aarch64:
|
|
||||||
runs-on: [ self-hosted, arm64 ]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and Push Image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/testing-host/Dockerfile
|
|
||||||
platforms: linux/aarch64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64
|
|
||||||
labels: |
|
labels: |
|
||||||
coolify.managed=true
|
coolify.managed=true
|
||||||
|
|
||||||
merge-manifest:
|
merge-manifest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
needs: [ amd64, aarch64 ]
|
needs: build-push
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -112,13 +87,15 @@ jobs:
|
|||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
|
||||||
|
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
|
||||||
|
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
- uses: sarisia/actions-status-discord@v1
|
- uses: sarisia/actions-status-discord@v1
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
This file provides guidance to **Claude Code** (claude.ai/code) when working with code in this repository.
|
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
|
## Project Overview
|
||||||
|
|
||||||
@@ -27,7 +27,8 @@ Only run artisan commands inside "coolify" container when in development.
|
|||||||
### Code Quality
|
### Code Quality
|
||||||
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
|
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
|
||||||
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
|
- `./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
|
### Running Tests
|
||||||
**IMPORTANT**: Tests that require database connections MUST be run inside the Docker container:
|
**IMPORTANT**: Tests that require database connections MUST be run inside the Docker container:
|
||||||
@@ -39,12 +40,14 @@ Only run artisan commands inside "coolify" container when in development.
|
|||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
### Technology Stack
|
### Technology Stack
|
||||||
- **Backend**: Laravel 12 (PHP 8.4)
|
- **Backend**: Laravel 12.4.1 (PHP 8.4.7)
|
||||||
- **Frontend**: Livewire 3.5+ with Alpine.js and Tailwind CSS 4.1+
|
- **Frontend**: Livewire 3.5.20 with Alpine.js and Tailwind CSS 4.1.4
|
||||||
- **Database**: PostgreSQL 15 (primary), Redis 7 (cache/queues)
|
- **Database**: PostgreSQL 15 (primary), Redis 7 (cache/queues)
|
||||||
- **Real-time**: Soketi (WebSocket server)
|
- **Real-time**: Soketi (WebSocket server)
|
||||||
- **Containerization**: Docker & Docker Compose
|
- **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
|
### Key Components
|
||||||
|
|
||||||
@@ -256,453 +259,61 @@ When developing features:
|
|||||||
|
|
||||||
## Additional Documentation
|
## 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
|
### Core Documentation
|
||||||
- [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure
|
- [Technology Stack](.ai/core/technology-stack.md) - All versions, packages, and dependencies (single source of truth)
|
||||||
- [Deployment Architecture](.cursor/rules/deployment-architecture.mdc) - Deployment patterns and flows
|
- [Project Overview](.ai/core/project-overview.md) - What Coolify is and how it works
|
||||||
- [Database Patterns](.cursor/rules/database-patterns.mdc) - Database design and query patterns
|
- [Application Architecture](.ai/core/application-architecture.md) - System design and component relationships
|
||||||
- [Frontend Patterns](.cursor/rules/frontend-patterns.mdc) - Livewire and Alpine.js patterns
|
- [Deployment Architecture](.ai/core/deployment-architecture.md) - How deployments work end-to-end
|
||||||
- [API & Routing](.cursor/rules/api-and-routing.mdc) - API design and routing conventions
|
|
||||||
|
|
||||||
### Development & Security
|
### Development Practices
|
||||||
- [Development Workflow](.cursor/rules/development-workflow.mdc) - Development best practices
|
- [Development Workflow](.ai/development/development-workflow.md) - Development setup, commands, and workflows
|
||||||
- [Security Patterns](.cursor/rules/security-patterns.mdc) - Security implementation details
|
- [Testing Patterns](.ai/development/testing-patterns.md) - Testing strategies and examples (Docker requirements!)
|
||||||
- [Form Components](.cursor/rules/form-components.mdc) - Enhanced form components with authorization
|
- [Laravel Boost](.ai/development/laravel-boost.md) - Laravel-specific guidelines and best practices
|
||||||
- [Testing Patterns](.cursor/rules/testing-patterns.mdc) - Testing strategies and examples
|
|
||||||
|
|
||||||
### Project Information
|
### Code Patterns
|
||||||
- [Project Overview](.cursor/rules/project-overview.mdc) - High-level project structure
|
- [Database Patterns](.ai/patterns/database-patterns.md) - Eloquent, migrations, relationships
|
||||||
- [Technology Stack](.cursor/rules/technology-stack.mdc) - Detailed tech stack information
|
- [Frontend Patterns](.ai/patterns/frontend-patterns.md) - Livewire, Alpine.js, Tailwind CSS
|
||||||
- [Cursor Rules Guide](.cursor/rules/cursor_rules.mdc) - How to maintain cursor rules
|
- [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>
|
## Laravel Boost Guidelines
|
||||||
=== foundation rules ===
|
|
||||||
|
|
||||||
# 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
|
- Use PHP 8.4 constructor property promotion and typed properties
|
||||||
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.
|
- 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
|
### Testing Requirements
|
||||||
- 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
|
|
||||||
|
|
||||||
|
- **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
|
### Livewire & Frontend
|
||||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
|
||||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
|
||||||
- Check for existing components to reuse before writing a new one.
|
|
||||||
|
|
||||||
## Verification Scripts
|
- Livewire components require single root element
|
||||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
- Use `wire:model.live` for real-time updates
|
||||||
|
- Alpine.js included with Livewire
|
||||||
## Application Structure & Architecture
|
- Tailwind CSS 4.1.4 (use new utilities, not deprecated ones)
|
||||||
- Stick to existing directory structure - don't create new base folders without approval.
|
- Use `gap` utilities for spacing, not margins
|
||||||
- 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>
|
|
||||||
|
|
||||||
|
|
||||||
Random other things you should remember:
|
Random other things you should remember:
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||

|
|
||||||
|
|
||||||
[](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.
|
Coolify is an open-source & self-hostable alternative to Heroku / Netlify / Vercel / etc.
|
||||||
|
|
||||||
@@ -15,7 +19,7 @@ No vendor lock-in, which means that all the configurations for your applications
|
|||||||
|
|
||||||
For more information, take a look at our landing page at [coolify.io](https://coolify.io).
|
For more information, take a look at our landing page at [coolify.io](https://coolify.io).
|
||||||
|
|
||||||
# Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||||
@@ -25,11 +29,11 @@ You can find the installation script source [here](./scripts/install.sh).
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Please refer to the [docs](https://coolify.io/docs/installation) for more information about the installation.
|
> 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).
|
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)
|
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 @@ By subscribing to the cloud version, you get the Coolify server for the same pri
|
|||||||
- Better support
|
- Better support
|
||||||
- Less maintenance for you
|
- Less maintenance for you
|
||||||
|
|
||||||
# Donations
|
## 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.
|
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)
|
[coolify.io/sponsorships](https://coolify.io/sponsorships)
|
||||||
|
|
||||||
Thank you so much!
|
Thank you so much!
|
||||||
|
|
||||||
## Big Sponsors
|
### Big Sponsors
|
||||||
|
|
||||||
* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions!
|
* [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
|
* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform
|
||||||
@@ -88,7 +92,7 @@ Thank you so much!
|
|||||||
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
|
* [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://open-elements.com/?utm_source=coolify.io"><img width="60px" alt="OpenElements" src="https://github.com/OpenElements.png"/></a>
|
||||||
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="XamanApp" src="https://github.com/XamanApp.png"/></a>
|
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="XamanApp" src="https://github.com/XamanApp.png"/></a>
|
||||||
@@ -141,7 +145,7 @@ Thank you so much!
|
|||||||
|
|
||||||
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
|
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
|
||||||
|
|
||||||
# Recognitions
|
## Recognitions
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://news.ycombinator.com/item?id=26624341">
|
<a href="https://news.ycombinator.com/item?id=26624341">
|
||||||
@@ -157,17 +161,17 @@ Thank you so much!
|
|||||||
|
|
||||||
<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>
|
<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 |
|
| 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" /> |
|
| <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> |
|
| <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)
|
[](https://star-history.com/#coollabsio/coolify&Date)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class RestartDatabase
|
|||||||
if (! $server->isFunctional()) {
|
if (! $server->isFunctional()) {
|
||||||
return 'Server is not functional';
|
return 'Server is not functional';
|
||||||
}
|
}
|
||||||
StopDatabase::run($database);
|
StopDatabase::run($database, dockerCleanup: false);
|
||||||
|
|
||||||
return StartDatabase::run($database);
|
return StartDatabase::run($database);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,17 @@ use App\Events\ServiceChecked;
|
|||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\ServiceDatabase;
|
use App\Models\ServiceDatabase;
|
||||||
|
use App\Services\ContainerStatusAggregator;
|
||||||
|
use App\Traits\CalculatesExcludedStatus;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
|
||||||
class GetContainersStatus
|
class GetContainersStatus
|
||||||
{
|
{
|
||||||
use AsAction;
|
use AsAction;
|
||||||
|
use CalculatesExcludedStatus;
|
||||||
|
|
||||||
public string $jobQueue = 'high';
|
public string $jobQueue = 'high';
|
||||||
|
|
||||||
@@ -28,6 +32,10 @@ class GetContainersStatus
|
|||||||
|
|
||||||
protected ?Collection $applicationContainerStatuses;
|
protected ?Collection $applicationContainerStatuses;
|
||||||
|
|
||||||
|
protected ?Collection $applicationContainerRestartCounts;
|
||||||
|
|
||||||
|
protected ?Collection $serviceContainerStatuses;
|
||||||
|
|
||||||
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
||||||
{
|
{
|
||||||
$this->containers = $containers;
|
$this->containers = $containers;
|
||||||
@@ -95,11 +103,15 @@ class GetContainersStatus
|
|||||||
$labels = data_get($container, 'Config.Labels');
|
$labels = data_get($container, 'Config.Labels');
|
||||||
}
|
}
|
||||||
$containerStatus = data_get($container, 'State.Status');
|
$containerStatus = data_get($container, 'State.Status');
|
||||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
$containerHealth = data_get($container, 'State.Health.Status');
|
||||||
if ($containerStatus === 'restarting') {
|
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 {
|
} else {
|
||||||
$containerStatus = "$containerStatus ($containerHealth)";
|
$healthSuffix = $containerHealth ?? 'unknown';
|
||||||
|
$containerStatus = "$containerStatus:$healthSuffix";
|
||||||
}
|
}
|
||||||
$labels = Arr::undot(format_docker_labels_to_json($labels));
|
$labels = Arr::undot(format_docker_labels_to_json($labels));
|
||||||
$applicationId = data_get($labels, 'coolify.applicationId');
|
$applicationId = data_get($labels, 'coolify.applicationId');
|
||||||
@@ -136,6 +148,18 @@ class GetContainersStatus
|
|||||||
if ($containerName) {
|
if ($containerName) {
|
||||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track restart counts for applications
|
||||||
|
$restartCount = data_get($container, 'RestartCount', 0);
|
||||||
|
if (! isset($this->applicationContainerRestartCounts)) {
|
||||||
|
$this->applicationContainerRestartCounts = collect();
|
||||||
|
}
|
||||||
|
if (! $this->applicationContainerRestartCounts->has($applicationId)) {
|
||||||
|
$this->applicationContainerRestartCounts->put($applicationId, collect());
|
||||||
|
}
|
||||||
|
if ($containerName) {
|
||||||
|
$this->applicationContainerRestartCounts->get($applicationId)->put($containerName, $restartCount);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Notify user that this container should not be there.
|
// Notify user that this container should not be there.
|
||||||
}
|
}
|
||||||
@@ -207,23 +231,34 @@ class GetContainersStatus
|
|||||||
if ($serviceLabelId) {
|
if ($serviceLabelId) {
|
||||||
$subType = data_get($labels, 'coolify.service.subType');
|
$subType = data_get($labels, 'coolify.service.subType');
|
||||||
$subId = data_get($labels, 'coolify.service.subId');
|
$subId = data_get($labels, 'coolify.service.subId');
|
||||||
$service = $services->where('id', $serviceLabelId)->first();
|
$parentService = $services->where('id', $serviceLabelId)->first();
|
||||||
if (! $service) {
|
if (! $parentService) {
|
||||||
continue;
|
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') {
|
if ($subType === 'application') {
|
||||||
$service = $service->applications()->where('id', $subId)->first();
|
$service = $parentService->applications()->where('id', $subId)->first();
|
||||||
} else {
|
} else {
|
||||||
$service = $service->databases()->where('id', $subId)->first();
|
$service = $parentService->databases()->where('id', $subId)->first();
|
||||||
}
|
}
|
||||||
if ($service) {
|
if ($service) {
|
||||||
$foundServices[] = "$service->id-$service->name";
|
$foundServices[] = "$service->id-$service->name";
|
||||||
$statusFromDb = $service->status;
|
|
||||||
if ($statusFromDb !== $containerStatus) {
|
|
||||||
$service->update(['status' => $containerStatus]);
|
|
||||||
} else {
|
|
||||||
$service->update(['last_online_at' => now()]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,7 +326,24 @@ class GetContainersStatus
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$application->update(['status' => 'exited']);
|
// If container was recently restarting (crash loop), keep it as degraded for a grace period
|
||||||
|
// This prevents false "exited" status during the brief moment between container removal and recreation
|
||||||
|
$recentlyRestarted = $application->restart_count > 0 &&
|
||||||
|
$application->last_restart_at &&
|
||||||
|
$application->last_restart_at->greaterThan(now()->subSeconds(30));
|
||||||
|
|
||||||
|
if ($recentlyRestarted) {
|
||||||
|
// Keep it as degraded if it was recently in a crash loop
|
||||||
|
$application->update(['status' => 'degraded:unhealthy']);
|
||||||
|
} else {
|
||||||
|
// Reset restart count when application exits completely
|
||||||
|
$application->update([
|
||||||
|
'status' => 'exited',
|
||||||
|
'restart_count' => 0,
|
||||||
|
'last_restart_at' => null,
|
||||||
|
'last_restart_type' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews);
|
$notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews);
|
||||||
foreach ($notRunningApplicationPreviews as $previewId) {
|
foreach ($notRunningApplicationPreviews as $previewId) {
|
||||||
@@ -340,88 +392,144 @@ class GetContainersStatus
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
|
// Track restart counts first
|
||||||
if ($aggregatedStatus) {
|
$maxRestartCount = 0;
|
||||||
$statusFromDb = $application->status;
|
if (isset($this->applicationContainerRestartCounts) && $this->applicationContainerRestartCounts->has($applicationId)) {
|
||||||
if ($statusFromDb !== $aggregatedStatus) {
|
$containerRestartCounts = $this->applicationContainerRestartCounts->get($applicationId);
|
||||||
$application->update(['status' => $aggregatedStatus]);
|
$maxRestartCount = $containerRestartCounts->max() ?? 0;
|
||||||
} else {
|
|
||||||
$application->update(['last_online_at' => now()]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wrap all database updates in a transaction to ensure consistency
|
||||||
|
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
|
||||||
|
$previousRestartCount = $application->restart_count ?? 0;
|
||||||
|
|
||||||
|
if ($maxRestartCount > $previousRestartCount) {
|
||||||
|
// Restart count increased - this is a crash restart
|
||||||
|
$application->update([
|
||||||
|
'restart_count' => $maxRestartCount,
|
||||||
|
'last_restart_at' => now(),
|
||||||
|
'last_restart_type' => 'crash',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send notification
|
||||||
|
$containerName = $application->name;
|
||||||
|
$projectUuid = data_get($application, 'environment.project.uuid');
|
||||||
|
$environmentName = data_get($application, 'environment.name');
|
||||||
|
$applicationUuid = data_get($application, 'uuid');
|
||||||
|
|
||||||
|
if ($projectUuid && $applicationUuid && $environmentName) {
|
||||||
|
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
|
||||||
|
} else {
|
||||||
|
$url = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate status after tracking restart counts
|
||||||
|
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount);
|
||||||
|
if ($aggregatedStatus) {
|
||||||
|
$statusFromDb = $application->status;
|
||||||
|
if ($statusFromDb !== $aggregatedStatus) {
|
||||||
|
$application->update(['status' => $aggregatedStatus]);
|
||||||
|
} else {
|
||||||
|
$application->update(['last_online_at' => now()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aggregate multi-container service statuses
|
||||||
|
$this->aggregateServiceContainerStatuses($services);
|
||||||
|
|
||||||
ServiceChecked::dispatch($this->server->team->id);
|
ServiceChecked::dispatch($this->server->team->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
|
private function aggregateApplicationStatus($application, Collection $containerStatuses, int $maxRestartCount = 0): ?string
|
||||||
{
|
{
|
||||||
// Parse docker compose to check for excluded containers
|
// Parse docker compose to check for excluded containers
|
||||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
$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) {
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out excluded containers
|
// Filter out excluded containers
|
||||||
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||||
return ! $excludedContainers->contains($containerName);
|
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()) {
|
if ($relevantStatuses->isEmpty()) {
|
||||||
return null;
|
return $this->calculateExcludedStatusFromStrings($containerStatuses);
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasRunning = false;
|
// Use ContainerStatusAggregator service for state machine logic
|
||||||
$hasRestarting = false;
|
$aggregator = new ContainerStatusAggregator;
|
||||||
$hasUnhealthy = false;
|
|
||||||
$hasExited = false;
|
|
||||||
|
|
||||||
foreach ($relevantStatuses as $status) {
|
return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount);
|
||||||
if (str($status)->contains('restarting')) {
|
}
|
||||||
$hasRestarting = true;
|
|
||||||
} elseif (str($status)->contains('running')) {
|
private function aggregateServiceContainerStatuses($services)
|
||||||
$hasRunning = true;
|
{
|
||||||
if (str($status)->contains('unhealthy')) {
|
if (! isset($this->serviceContainerStatuses) || $this->serviceContainerStatuses->isEmpty()) {
|
||||||
$hasUnhealthy = true;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->serviceContainerStatuses as $key => $containerStatuses) {
|
||||||
|
// Parse key: serviceId:subType:subId
|
||||||
|
[$serviceId, $subType, $subId] = explode(':', $key);
|
||||||
|
|
||||||
|
$service = $services->where('id', $serviceId)->first();
|
||||||
|
if (! $service) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
|
||||||
|
$subResource = null;
|
||||||
|
if ($subType === 'application') {
|
||||||
|
$subResource = $service->applications()->where('id', $subId)->first();
|
||||||
|
} elseif ($subType === 'database') {
|
||||||
|
$subResource = $service->databases()->where('id', $subId)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $subResource) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse docker compose from service to check for excluded containers
|
||||||
|
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
|
||||||
|
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||||
|
|
||||||
|
// Filter out excluded containers
|
||||||
|
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||||
|
return ! $excludedContainers->contains($containerName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If all containers are excluded, calculate status from excluded containers
|
||||||
|
if ($relevantStatuses->isEmpty()) {
|
||||||
|
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
|
||||||
|
if ($aggregatedStatus) {
|
||||||
|
$statusFromDb = $subResource->status;
|
||||||
|
if ($statusFromDb !== $aggregatedStatus) {
|
||||||
|
$subResource->update(['status' => $aggregatedStatus]);
|
||||||
|
} else {
|
||||||
|
$subResource->update(['last_online_at' => now()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ContainerStatusAggregator service for state machine logic
|
||||||
|
$aggregator = new ContainerStatusAggregator;
|
||||||
|
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses);
|
||||||
|
|
||||||
|
// Update service sub-resource status with aggregated result
|
||||||
|
if ($aggregatedStatus) {
|
||||||
|
$statusFromDb = $subResource->status;
|
||||||
|
if ($statusFromDb !== $aggregatedStatus) {
|
||||||
|
$subResource->update(['status' => $aggregatedStatus]);
|
||||||
|
} else {
|
||||||
|
$subResource->update(['last_online_at' => now()]);
|
||||||
}
|
}
|
||||||
} elseif (str($status)->contains('exited')) {
|
|
||||||
$hasExited = true;
|
|
||||||
$hasUnhealthy = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($hasRestarting) {
|
|
||||||
return 'degraded (unhealthy)';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasRunning && $hasExited) {
|
|
||||||
return 'degraded (unhealthy)';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasRunning) {
|
|
||||||
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
|
||||||
}
|
|
||||||
|
|
||||||
// All containers are exited
|
|
||||||
return 'exited (unhealthy)';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class StartProxy
|
|||||||
{
|
{
|
||||||
use AsAction;
|
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();
|
$proxyType = $server->proxyType();
|
||||||
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) {
|
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) {
|
||||||
@@ -22,7 +22,10 @@ class StartProxy
|
|||||||
$server->proxy->set('status', 'starting');
|
$server->proxy->set('status', 'starting');
|
||||||
$server->save();
|
$server->save();
|
||||||
$server->refresh();
|
$server->refresh();
|
||||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
|
||||||
|
if (! $restarting) {
|
||||||
|
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||||
|
}
|
||||||
|
|
||||||
$commands = collect([]);
|
$commands = collect([]);
|
||||||
$proxy_path = $server->proxyPath();
|
$proxy_path = $server->proxyPath();
|
||||||
@@ -60,9 +63,22 @@ class StartProxy
|
|||||||
'docker compose pull',
|
'docker compose pull',
|
||||||
'if docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
|
'if docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
|
||||||
" echo 'Stopping and removing existing coolify-proxy.'",
|
" 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.'",
|
" echo 'Successfully stopped and removed existing coolify-proxy.'",
|
||||||
'fi',
|
'fi',
|
||||||
|
]);
|
||||||
|
// Ensure required networks exist BEFORE docker compose up (networks are declared as external)
|
||||||
|
$commands = $commands->merge(ensureProxyNetworksExist($server));
|
||||||
|
$commands = $commands->merge([
|
||||||
"echo 'Starting coolify-proxy.'",
|
"echo 'Starting coolify-proxy.'",
|
||||||
'docker compose up -d --wait --remove-orphans',
|
'docker compose up -d --wait --remove-orphans',
|
||||||
"echo 'Successfully started coolify-proxy.'",
|
"echo 'Successfully started coolify-proxy.'",
|
||||||
|
|||||||
@@ -12,17 +12,27 @@ class StopProxy
|
|||||||
{
|
{
|
||||||
use AsAction;
|
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 {
|
try {
|
||||||
$containerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
|
$containerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
|
||||||
$server->proxy->status = 'stopping';
|
$server->proxy->status = 'stopping';
|
||||||
$server->save();
|
$server->save();
|
||||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
|
||||||
|
if (! $restarting) {
|
||||||
|
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||||
|
}
|
||||||
|
|
||||||
instant_remote_process(command: [
|
instant_remote_process(command: [
|
||||||
"docker stop -t $timeout $containerName",
|
"docker stop -t=$timeout $containerName 2>/dev/null || true",
|
||||||
"docker rm -f $containerName",
|
"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: $server, throwError: false);
|
||||||
|
|
||||||
$server->proxy->force_stop = $forceStop;
|
$server->proxy->force_stop = $forceStop;
|
||||||
@@ -32,7 +42,10 @@ class StopProxy
|
|||||||
return handleError($e);
|
return handleError($e);
|
||||||
} finally {
|
} finally {
|
||||||
ProxyDashboardCacheService::clearCache($server);
|
ProxyDashboardCacheService::clearCache($server);
|
||||||
ProxyStatusChanged::dispatch($server->id);
|
|
||||||
|
if (! $restarting) {
|
||||||
|
ProxyStatusChanged::dispatch($server->id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ class InstallDocker
|
|||||||
$command = collect([]);
|
$command = collect([]);
|
||||||
if (isDev() && $server->id === 0) {
|
if (isDev() && $server->id === 0) {
|
||||||
$command = $command->merge([
|
$command = $command->merge([
|
||||||
"echo 'Installing Prerequisites...'",
|
|
||||||
'sleep 1',
|
|
||||||
"echo 'Installing Docker Engine...'",
|
"echo 'Installing Docker Engine...'",
|
||||||
"echo 'Configuring Docker Engine (merging existing configuration with the required)...'",
|
"echo 'Configuring Docker Engine (merging existing configuration with the required)...'",
|
||||||
'sleep 4',
|
'sleep 4',
|
||||||
@@ -70,35 +68,6 @@ class InstallDocker
|
|||||||
|
|
||||||
return remote_process($command, $server);
|
return remote_process($command, $server);
|
||||||
} else {
|
} 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([
|
$command = $command->merge([
|
||||||
"echo 'Installing Docker Engine...'",
|
"echo 'Installing Docker Engine...'",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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;
|
namespace App\Actions\Server;
|
||||||
|
|
||||||
use App\Jobs\PullHelperImageJob;
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Support\Sleep;
|
use Illuminate\Support\Sleep;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
@@ -50,7 +49,9 @@ class UpdateCoolify
|
|||||||
|
|
||||||
private function update()
|
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;
|
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
|
||||||
instant_remote_process(["docker pull -q $image"], $this->server, false);
|
instant_remote_process(["docker pull -q $image"], $this->server, false);
|
||||||
|
|||||||
@@ -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 @@ class ValidateServer
|
|||||||
throw new \Exception($this->error);
|
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_installed = $server->validateDockerEngine();
|
||||||
$this->docker_compose_installed = $server->validateDockerCompose();
|
$this->docker_compose_installed = $server->validateDockerCompose();
|
||||||
if (! $this->docker_installed || ! $this->docker_compose_installed) {
|
if (! $this->docker_installed || ! $this->docker_compose_installed) {
|
||||||
|
|||||||
@@ -20,18 +20,23 @@ class StartService
|
|||||||
}
|
}
|
||||||
$service->saveComposeConfigs();
|
$service->saveComposeConfigs();
|
||||||
$service->isConfigurationChanged(save: true);
|
$service->isConfigurationChanged(save: true);
|
||||||
$commands[] = 'cd '.$service->workdir();
|
$workdir = $service->workdir();
|
||||||
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
|
// $commands[] = "cd {$workdir}";
|
||||||
|
$commands[] = "echo 'Saved configuration files to {$workdir}.'";
|
||||||
|
// Ensure .env exists in the correct directory before docker compose tries to load it
|
||||||
|
// This is defensive programming - saveComposeConfigs() already creates it,
|
||||||
|
// but we guarantee it here in case of any edge cases or manual deployments
|
||||||
|
$commands[] = "touch {$workdir}/.env";
|
||||||
if ($pullLatestImages) {
|
if ($pullLatestImages) {
|
||||||
$commands[] = "echo 'Pulling images.'";
|
$commands[] = "echo 'Pulling images.'";
|
||||||
$commands[] = 'docker compose pull';
|
$commands[] = "docker compose --project-directory {$workdir} pull";
|
||||||
}
|
}
|
||||||
if ($service->networks()->count() > 0) {
|
if ($service->networks()->count() > 0) {
|
||||||
$commands[] = "echo 'Creating Docker network.'";
|
$commands[] = "echo 'Creating Docker network.'";
|
||||||
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
|
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
|
||||||
}
|
}
|
||||||
$commands[] = 'echo Starting service.';
|
$commands[] = 'echo Starting service.';
|
||||||
$commands[] = 'docker compose up -d --remove-orphans --force-recreate --build';
|
$commands[] = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --remove-orphans --force-recreate --build";
|
||||||
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
|
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
|
||||||
if (data_get($service, 'connect_to_docker_network')) {
|
if (data_get($service, 'connect_to_docker_network')) {
|
||||||
$compose = data_get($service, 'docker_compose', []);
|
$compose = data_get($service, 'docker_compose', []);
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
namespace App\Actions\Shared;
|
namespace App\Actions\Shared;
|
||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use App\Services\ContainerStatusAggregator;
|
||||||
|
use App\Traits\CalculatesExcludedStatus;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
|
||||||
class ComplexStatusCheck
|
class ComplexStatusCheck
|
||||||
{
|
{
|
||||||
use AsAction;
|
use AsAction;
|
||||||
|
use CalculatesExcludedStatus;
|
||||||
|
|
||||||
public function handle(Application $application)
|
public function handle(Application $application)
|
||||||
{
|
{
|
||||||
@@ -17,11 +20,11 @@ class ComplexStatusCheck
|
|||||||
$is_main_server = $application->destination->server->id === $server->id;
|
$is_main_server = $application->destination->server->id === $server->id;
|
||||||
if (! $server->isFunctional()) {
|
if (! $server->isFunctional()) {
|
||||||
if ($is_main_server) {
|
if ($is_main_server) {
|
||||||
$application->update(['status' => 'exited:unhealthy']);
|
$application->update(['status' => 'exited']);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited:unhealthy']);
|
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited']);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -46,11 +49,11 @@ class ComplexStatusCheck
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if ($is_main_server) {
|
if ($is_main_server) {
|
||||||
$application->update(['status' => 'exited:unhealthy']);
|
$application->update(['status' => 'exited']);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited:unhealthy']);
|
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited']);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -61,74 +64,25 @@ class ComplexStatusCheck
|
|||||||
private function aggregateContainerStatuses($application, $containers)
|
private function aggregateContainerStatuses($application, $containers)
|
||||||
{
|
{
|
||||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||||
$excludedContainers = collect();
|
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||||
|
|
||||||
if ($dockerComposeRaw) {
|
// Filter non-excluded containers
|
||||||
try {
|
$relevantContainers = collect($containers)->filter(function ($container) use ($excludedContainers) {
|
||||||
$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) {
|
|
||||||
$labels = data_get($container, 'Config.Labels', []);
|
$labels = data_get($container, 'Config.Labels', []);
|
||||||
$serviceName = data_get($labels, 'com.docker.compose.service');
|
$serviceName = data_get($labels, 'com.docker.compose.service');
|
||||||
|
|
||||||
if ($serviceName && $excludedContainers->contains($serviceName)) {
|
return ! ($serviceName && $excludedContainers->contains($serviceName));
|
||||||
continue;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
$relevantContainerCount++;
|
// If all containers are excluded, calculate status from excluded containers
|
||||||
$containerStatus = data_get($container, 'State.Status');
|
// but mark it with :excluded to indicate monitoring is disabled
|
||||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
if ($relevantContainers->isEmpty()) {
|
||||||
|
return $this->calculateExcludedStatus($containers, $excludedContainers);
|
||||||
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 ($relevantContainerCount === 0) {
|
// Use ContainerStatusAggregator service for state machine logic
|
||||||
return 'running:healthy';
|
$aggregator = new ContainerStatusAggregator;
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasRestarting) {
|
return $aggregator->aggregateFromContainers($relevantContainers);
|
||||||
return 'degraded:unhealthy';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasRunning && $hasExited) {
|
|
||||||
return 'degraded:unhealthy';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasRunning) {
|
|
||||||
return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'exited:unhealthy';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\CheckTraefikVersionJob;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class CheckTraefikVersionCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'traefik:check-version';
|
||||||
|
|
||||||
|
protected $description = 'Check Traefik proxy versions on all servers and send notifications for outdated versions';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->info('Checking Traefik versions on all servers...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
CheckTraefikVersionJob::dispatch();
|
||||||
|
$this->info('Traefik version check job dispatched successfully.');
|
||||||
|
$this->info('Notifications will be sent to teams with outdated Traefik versions.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error('Failed to dispatch Traefik version check job: '.$e->getMessage());
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,8 +63,6 @@ class CleanupNames extends Command
|
|||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$this->info('🔍 Scanning for invalid characters in name fields...');
|
|
||||||
|
|
||||||
if ($this->option('backup') && ! $this->option('dry-run')) {
|
if ($this->option('backup') && ! $this->option('dry-run')) {
|
||||||
$this->createBackup();
|
$this->createBackup();
|
||||||
}
|
}
|
||||||
@@ -75,7 +73,7 @@ class CleanupNames extends Command
|
|||||||
: $this->modelsToClean;
|
: $this->modelsToClean;
|
||||||
|
|
||||||
if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
|
if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
|
||||||
$this->error("❌ Unknown model: {$modelFilter}");
|
$this->error("Unknown model: {$modelFilter}");
|
||||||
$this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
|
$this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
@@ -88,19 +86,21 @@ class CleanupNames extends Command
|
|||||||
$this->processModel($modelName, $modelClass);
|
$this->processModel($modelName, $modelClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->displaySummary();
|
|
||||||
|
|
||||||
if (! $this->option('dry-run') && $this->totalCleaned > 0) {
|
if (! $this->option('dry-run') && $this->totalCleaned > 0) {
|
||||||
$this->logChanges();
|
$this->logChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->option('dry-run')) {
|
||||||
|
$this->info("Name cleanup: would sanitize {$this->totalCleaned} records");
|
||||||
|
} else {
|
||||||
|
$this->info("Name cleanup: sanitized {$this->totalCleaned} records");
|
||||||
|
}
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function processModel(string $modelName, string $modelClass): void
|
protected function processModel(string $modelName, string $modelClass): void
|
||||||
{
|
{
|
||||||
$this->info("\n📋 Processing {$modelName}...");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$records = $modelClass::all(['id', 'name']);
|
$records = $modelClass::all(['id', 'name']);
|
||||||
$cleaned = 0;
|
$cleaned = 0;
|
||||||
@@ -128,21 +128,17 @@ class CleanupNames extends Command
|
|||||||
$cleaned++;
|
$cleaned++;
|
||||||
$this->totalCleaned++;
|
$this->totalCleaned++;
|
||||||
|
|
||||||
$this->warn(" 🧹 {$modelName} #{$record->id}:");
|
// Only log in dry-run mode to preview changes
|
||||||
$this->line(' From: '.$this->truncate($originalName, 80));
|
if ($this->option('dry-run')) {
|
||||||
$this->line(' To: '.$this->truncate($sanitizedName, 80));
|
$this->warn(" 🧹 {$modelName} #{$record->id}:");
|
||||||
|
$this->line(' From: '.$this->truncate($originalName, 80));
|
||||||
|
$this->line(' To: '.$this->truncate($sanitizedName, 80));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($cleaned > 0) {
|
|
||||||
$action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized';
|
|
||||||
$this->info(" ✅ {$cleaned}/{$records->count()} records {$action}");
|
|
||||||
} else {
|
|
||||||
$this->info(' ✨ No invalid characters found');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->error(" ❌ Error processing {$modelName}: ".$e->getMessage());
|
$this->error("Error processing {$modelName}: ".$e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,28 +161,6 @@ class CleanupNames extends Command
|
|||||||
return $sanitized;
|
return $sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function displaySummary(): void
|
|
||||||
{
|
|
||||||
$this->info("\n".str_repeat('=', 60));
|
|
||||||
$this->info('📊 CLEANUP SUMMARY');
|
|
||||||
$this->info(str_repeat('=', 60));
|
|
||||||
|
|
||||||
$this->line("Records processed: {$this->totalProcessed}");
|
|
||||||
$this->line("Records with invalid characters: {$this->totalCleaned}");
|
|
||||||
|
|
||||||
if ($this->option('dry-run')) {
|
|
||||||
$this->warn("\n🔍 DRY RUN - No changes were made to the database");
|
|
||||||
$this->info('Run without --dry-run to apply these changes');
|
|
||||||
} else {
|
|
||||||
if ($this->totalCleaned > 0) {
|
|
||||||
$this->info("\n✅ Database successfully sanitized!");
|
|
||||||
$this->info('Changes logged to storage/logs/name-cleanup.log');
|
|
||||||
} else {
|
|
||||||
$this->info("\n✨ No cleanup needed - all names are valid!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function logChanges(): void
|
protected function logChanges(): void
|
||||||
{
|
{
|
||||||
$logFile = storage_path('logs/name-cleanup.log');
|
$logFile = storage_path('logs/name-cleanup.log');
|
||||||
@@ -208,8 +182,6 @@ class CleanupNames extends Command
|
|||||||
|
|
||||||
protected function createBackup(): void
|
protected function createBackup(): void
|
||||||
{
|
{
|
||||||
$this->info('💾 Creating database backup...');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
|
$backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
|
||||||
|
|
||||||
@@ -229,15 +201,9 @@ class CleanupNames extends Command
|
|||||||
);
|
);
|
||||||
|
|
||||||
exec($command, $output, $returnCode);
|
exec($command, $output, $returnCode);
|
||||||
|
|
||||||
if ($returnCode === 0) {
|
|
||||||
$this->info("✅ Backup created: {$backupFile}");
|
|
||||||
} else {
|
|
||||||
$this->warn('⚠️ Backup creation may have failed. Proceeding anyway...');
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->warn('⚠️ Could not create backup: '.$e->getMessage());
|
// Log failure but continue - backup is optional safeguard
|
||||||
$this->warn('Proceeding without backup...');
|
Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Redis;
|
|||||||
|
|
||||||
class CleanupRedis extends Command
|
class CleanupRedis extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks}';
|
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks} {--restart : Aggressive cleanup mode for system restart (marks all processing jobs as failed)}';
|
||||||
|
|
||||||
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)';
|
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)';
|
||||||
|
|
||||||
@@ -18,10 +18,6 @@ class CleanupRedis extends Command
|
|||||||
$dryRun = $this->option('dry-run');
|
$dryRun = $this->option('dry-run');
|
||||||
$skipOverlapping = $this->option('skip-overlapping');
|
$skipOverlapping = $this->option('skip-overlapping');
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
$this->info('DRY RUN MODE - No data will be deleted');
|
|
||||||
}
|
|
||||||
|
|
||||||
$deletedCount = 0;
|
$deletedCount = 0;
|
||||||
$totalKeys = 0;
|
$totalKeys = 0;
|
||||||
|
|
||||||
@@ -29,8 +25,6 @@ class CleanupRedis extends Command
|
|||||||
$keys = $redis->keys('*');
|
$keys = $redis->keys('*');
|
||||||
$totalKeys = count($keys);
|
$totalKeys = count($keys);
|
||||||
|
|
||||||
$this->info("Scanning {$totalKeys} keys for cleanup...");
|
|
||||||
|
|
||||||
foreach ($keys as $key) {
|
foreach ($keys as $key) {
|
||||||
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
||||||
$type = $redis->command('type', [$keyWithoutPrefix]);
|
$type = $redis->command('type', [$keyWithoutPrefix]);
|
||||||
@@ -51,22 +45,27 @@ class CleanupRedis extends Command
|
|||||||
|
|
||||||
// Clean up overlapping queues if not skipped
|
// Clean up overlapping queues if not skipped
|
||||||
if (! $skipOverlapping) {
|
if (! $skipOverlapping) {
|
||||||
$this->info('Cleaning up overlapping queues...');
|
|
||||||
$overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
|
$overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
|
||||||
$deletedCount += $overlappingCleaned;
|
$deletedCount += $overlappingCleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up stale cache locks (WithoutOverlapping middleware)
|
// Clean up stale cache locks (WithoutOverlapping middleware)
|
||||||
if ($this->option('clear-locks')) {
|
if ($this->option('clear-locks')) {
|
||||||
$this->info('Cleaning up stale cache locks...');
|
|
||||||
$locksCleaned = $this->cleanupCacheLocks($dryRun);
|
$locksCleaned = $this->cleanupCacheLocks($dryRun);
|
||||||
$deletedCount += $locksCleaned;
|
$deletedCount += $locksCleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
|
||||||
|
$isRestart = $this->option('restart');
|
||||||
|
if ($isRestart || $this->option('clear-locks')) {
|
||||||
|
$jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart);
|
||||||
|
$deletedCount += $jobsCleaned;
|
||||||
|
}
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
|
$this->info("Redis cleanup: would delete {$deletedCount} items");
|
||||||
} else {
|
} else {
|
||||||
$this->info("Deleted {$deletedCount} out of {$totalKeys} keys");
|
$this->info("Redis cleanup: deleted {$deletedCount} items");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +76,8 @@ class CleanupRedis extends Command
|
|||||||
|
|
||||||
// Delete completed and failed jobs
|
// Delete completed and failed jobs
|
||||||
if (in_array($status, ['completed', 'failed'])) {
|
if (in_array($status, ['completed', 'failed'])) {
|
||||||
if ($dryRun) {
|
if (! $dryRun) {
|
||||||
$this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})");
|
|
||||||
} else {
|
|
||||||
$redis->command('del', [$keyWithoutPrefix]);
|
$redis->command('del', [$keyWithoutPrefix]);
|
||||||
$this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -107,11 +103,8 @@ class CleanupRedis extends Command
|
|||||||
|
|
||||||
foreach ($patterns as $pattern => $description) {
|
foreach ($patterns as $pattern => $description) {
|
||||||
if (str_contains($keyWithoutPrefix, $pattern)) {
|
if (str_contains($keyWithoutPrefix, $pattern)) {
|
||||||
if ($dryRun) {
|
if (! $dryRun) {
|
||||||
$this->line("Would delete {$description}: {$keyWithoutPrefix}");
|
|
||||||
} else {
|
|
||||||
$redis->command('del', [$keyWithoutPrefix]);
|
$redis->command('del', [$keyWithoutPrefix]);
|
||||||
$this->line("Deleted {$description}: {$keyWithoutPrefix}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -124,11 +117,8 @@ class CleanupRedis extends Command
|
|||||||
$weekAgo = now()->subDays(7)->timestamp;
|
$weekAgo = now()->subDays(7)->timestamp;
|
||||||
|
|
||||||
if ($timestamp < $weekAgo) {
|
if ($timestamp < $weekAgo) {
|
||||||
if ($dryRun) {
|
if (! $dryRun) {
|
||||||
$this->line("Would delete old timestamped data: {$keyWithoutPrefix}");
|
|
||||||
} else {
|
|
||||||
$redis->command('del', [$keyWithoutPrefix]);
|
$redis->command('del', [$keyWithoutPrefix]);
|
||||||
$this->line("Deleted old timestamped data: {$keyWithoutPrefix}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -152,8 +142,6 @@ class CleanupRedis extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info('Found '.count($queueKeys).' queue-related keys');
|
|
||||||
|
|
||||||
// Group queues by name pattern to find duplicates
|
// Group queues by name pattern to find duplicates
|
||||||
$queueGroups = [];
|
$queueGroups = [];
|
||||||
foreach ($queueKeys as $queueKey) {
|
foreach ($queueKeys as $queueKey) {
|
||||||
@@ -185,7 +173,6 @@ class CleanupRedis extends Command
|
|||||||
private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
|
private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
|
||||||
{
|
{
|
||||||
$cleanedCount = 0;
|
$cleanedCount = 0;
|
||||||
$this->line("Processing queue group: {$baseName} (".count($keys).' keys)');
|
|
||||||
|
|
||||||
// Sort keys to keep the most recent one
|
// Sort keys to keep the most recent one
|
||||||
usort($keys, function ($a, $b) {
|
usort($keys, function ($a, $b) {
|
||||||
@@ -236,11 +223,8 @@ class CleanupRedis extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($shouldDelete) {
|
if ($shouldDelete) {
|
||||||
if ($dryRun) {
|
if (! $dryRun) {
|
||||||
$this->line(" Would delete empty queue: {$redundantKey}");
|
|
||||||
} else {
|
|
||||||
$redis->command('del', [$redundantKey]);
|
$redis->command('del', [$redundantKey]);
|
||||||
$this->line(" Deleted empty queue: {$redundantKey}");
|
|
||||||
}
|
}
|
||||||
$cleanedCount++;
|
$cleanedCount++;
|
||||||
}
|
}
|
||||||
@@ -263,15 +247,12 @@ class CleanupRedis extends Command
|
|||||||
if (count($uniqueItems) < count($items)) {
|
if (count($uniqueItems) < count($items)) {
|
||||||
$duplicates = count($items) - count($uniqueItems);
|
$duplicates = count($items) - count($uniqueItems);
|
||||||
|
|
||||||
if ($dryRun) {
|
if (! $dryRun) {
|
||||||
$this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}");
|
|
||||||
} else {
|
|
||||||
// Rebuild the list with unique items
|
// Rebuild the list with unique items
|
||||||
$redis->command('del', [$queueKey]);
|
$redis->command('del', [$queueKey]);
|
||||||
foreach (array_reverse($uniqueItems) as $item) {
|
foreach (array_reverse($uniqueItems) as $item) {
|
||||||
$redis->command('lpush', [$queueKey, $item]);
|
$redis->command('lpush', [$queueKey, $item]);
|
||||||
}
|
}
|
||||||
$this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}");
|
|
||||||
}
|
}
|
||||||
$cleanedCount += $duplicates;
|
$cleanedCount += $duplicates;
|
||||||
}
|
}
|
||||||
@@ -299,13 +280,9 @@ class CleanupRedis extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (empty($lockKeys)) {
|
if (empty($lockKeys)) {
|
||||||
$this->info(' No cache locks found.');
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info(' Found '.count($lockKeys).' cache lock(s)');
|
|
||||||
|
|
||||||
foreach ($lockKeys as $lockKey) {
|
foreach ($lockKeys as $lockKey) {
|
||||||
// Check TTL to identify stale locks
|
// Check TTL to identify stale locks
|
||||||
$ttl = $redis->ttl($lockKey);
|
$ttl = $redis->ttl($lockKey);
|
||||||
@@ -318,16 +295,129 @@ class CleanupRedis extends Command
|
|||||||
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
|
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
|
||||||
} else {
|
} else {
|
||||||
$redis->del($lockKey);
|
$redis->del($lockKey);
|
||||||
$this->info(" ✓ Deleted STALE lock: {$lockKey}");
|
|
||||||
}
|
}
|
||||||
$cleanedCount++;
|
$cleanedCount++;
|
||||||
} elseif ($ttl > 0) {
|
|
||||||
$this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($cleanedCount === 0) {
|
return $cleanedCount;
|
||||||
$this->info(' No stale locks found (all locks have expiration set)');
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up stuck jobs based on mode (restart vs runtime).
|
||||||
|
*
|
||||||
|
* @param mixed $redis Redis connection
|
||||||
|
* @param string $prefix Horizon prefix
|
||||||
|
* @param bool $dryRun Dry run mode
|
||||||
|
* @param bool $isRestart Restart mode (aggressive) vs runtime mode (conservative)
|
||||||
|
* @return int Number of jobs cleaned
|
||||||
|
*/
|
||||||
|
private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $isRestart): int
|
||||||
|
{
|
||||||
|
$cleanedCount = 0;
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
// Get all keys with the horizon prefix
|
||||||
|
$cursor = 0;
|
||||||
|
$keys = [];
|
||||||
|
do {
|
||||||
|
$result = $redis->scan($cursor, ['match' => '*', 'count' => 100]);
|
||||||
|
|
||||||
|
// Guard against scan() returning false
|
||||||
|
if ($result === false) {
|
||||||
|
$this->error('Redis scan failed, stopping key retrieval');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cursor = $result[0];
|
||||||
|
$keys = array_merge($keys, $result[1]);
|
||||||
|
} while ($cursor !== 0);
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
||||||
|
$type = $redis->command('type', [$keyWithoutPrefix]);
|
||||||
|
|
||||||
|
// Only process hash-type keys (individual jobs)
|
||||||
|
if ($type !== 5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $redis->command('hgetall', [$keyWithoutPrefix]);
|
||||||
|
$status = data_get($data, 'status');
|
||||||
|
$payload = data_get($data, 'payload');
|
||||||
|
|
||||||
|
// Only process jobs in "processing" or "reserved" state
|
||||||
|
if (! in_array($status, ['processing', 'reserved'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse job payload to get job class and started time
|
||||||
|
$payloadData = json_decode($payload, true);
|
||||||
|
|
||||||
|
// Check for JSON decode errors
|
||||||
|
if ($payloadData === null || json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
$errorMsg = json_last_error_msg();
|
||||||
|
$truncatedPayload = is_string($payload) ? substr($payload, 0, 200) : 'non-string payload';
|
||||||
|
$this->error("Failed to decode job payload for {$keyWithoutPrefix}: {$errorMsg}. Payload: {$truncatedPayload}");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobClass = data_get($payloadData, 'displayName', 'Unknown');
|
||||||
|
|
||||||
|
// Prefer reserved_at (when job started processing), fallback to created_at
|
||||||
|
$reservedAt = (int) data_get($data, 'reserved_at', 0);
|
||||||
|
$createdAt = (int) data_get($data, 'created_at', 0);
|
||||||
|
$startTime = $reservedAt ?: $createdAt;
|
||||||
|
|
||||||
|
// If we can't determine when the job started, skip it
|
||||||
|
if (! $startTime) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how long the job has been processing
|
||||||
|
$processingTime = $now - $startTime;
|
||||||
|
|
||||||
|
$shouldFail = false;
|
||||||
|
$reason = '';
|
||||||
|
|
||||||
|
if ($isRestart) {
|
||||||
|
// RESTART MODE: Mark ALL processing/reserved jobs as failed
|
||||||
|
// Safe because all workers are dead on restart
|
||||||
|
$shouldFail = true;
|
||||||
|
$reason = 'System restart - all workers terminated';
|
||||||
|
} else {
|
||||||
|
// RUNTIME MODE: Only mark truly stuck jobs as failed
|
||||||
|
// Be conservative to avoid killing legitimate long-running jobs
|
||||||
|
|
||||||
|
// Skip ApplicationDeploymentJob entirely (has dynamic_timeout, can run 2+ hours)
|
||||||
|
if (str_contains($jobClass, 'ApplicationDeploymentJob')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip DatabaseBackupJob (large backups can take hours)
|
||||||
|
if (str_contains($jobClass, 'DatabaseBackupJob')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other jobs, only fail if processing > 12 hours
|
||||||
|
if ($processingTime > 43200) { // 12 hours
|
||||||
|
$shouldFail = true;
|
||||||
|
$reason = 'Processing for more than 12 hours';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($shouldFail) {
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn(" Would mark as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1)." min) - {$reason}");
|
||||||
|
} else {
|
||||||
|
// Mark job as failed
|
||||||
|
$redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
|
||||||
|
$redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
|
||||||
|
$redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]);
|
||||||
|
}
|
||||||
|
$cleanedCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $cleanedCount;
|
return $cleanedCount;
|
||||||
|
|||||||
@@ -222,9 +222,14 @@ class CleanupStuckedResources extends Command
|
|||||||
try {
|
try {
|
||||||
$scheduled_backups = ScheduledDatabaseBackup::all();
|
$scheduled_backups = ScheduledDatabaseBackup::all();
|
||||||
foreach ($scheduled_backups as $scheduled_backup) {
|
foreach ($scheduled_backups as $scheduled_backup) {
|
||||||
if (! $scheduled_backup->server()) {
|
try {
|
||||||
echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
|
$server = $scheduled_backup->server();
|
||||||
$scheduled_backup->delete();
|
if (! $server) {
|
||||||
|
echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
|
||||||
|
$scheduled_backup->delete();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo "Error checking server for scheduledbackup {$scheduled_backup->id}: {$e->getMessage()}\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -416,7 +421,7 @@ class CleanupStuckedResources extends Command
|
|||||||
foreach ($serviceApplications as $service) {
|
foreach ($serviceApplications as $service) {
|
||||||
if (! data_get($service, 'service')) {
|
if (! data_get($service, 'service')) {
|
||||||
echo 'ServiceApplication without service: '.$service->name.'\n';
|
echo 'ServiceApplication without service: '.$service->name.'\n';
|
||||||
DeleteResourceJob::dispatch($service);
|
$service->forceDelete();
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -429,7 +434,7 @@ class CleanupStuckedResources extends Command
|
|||||||
foreach ($serviceDatabases as $service) {
|
foreach ($serviceDatabases as $service) {
|
||||||
if (! data_get($service, 'service')) {
|
if (! data_get($service, 'service')) {
|
||||||
echo 'ServiceDatabase without service: '.$service->name.'\n';
|
echo 'ServiceDatabase without service: '.$service->name.'\n';
|
||||||
DeleteResourceJob::dispatch($service);
|
$service->forceDelete();
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ namespace App\Console\Commands;
|
|||||||
|
|
||||||
use App\Jobs\CheckHelperImageJob;
|
use App\Jobs\CheckHelperImageJob;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\ScheduledDatabaseBackupExecution;
|
||||||
|
use App\Models\ScheduledTaskExecution;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
|
||||||
@@ -45,6 +48,44 @@ class Dev extends Command
|
|||||||
} else {
|
} else {
|
||||||
echo "Instance already initialized.\n";
|
echo "Instance already initialized.\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up stuck jobs and stale locks on development startup
|
||||||
|
try {
|
||||||
|
echo "Cleaning up Redis (stuck jobs and stale locks)...\n";
|
||||||
|
Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
|
||||||
|
echo "Redis cleanup completed.\n";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo "Error in cleanup:redis: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||||
|
'finished_at' => Carbon::now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($updatedTaskCount > 0) {
|
||||||
|
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||||
|
'finished_at' => Carbon::now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($updatedBackupCount > 0) {
|
||||||
|
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
|
||||||
CheckHelperImageJob::dispatch();
|
CheckHelperImageJob::dispatch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ class Emails extends Command
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$output = 'Because of an error, the backup of the database '.$db->name.' failed.';
|
$output = 'Because of an error, the backup of the database '.$db->name.' failed.';
|
||||||
$this->mail = (new BackupFailed($backup, $db, $output))->toMail();
|
$this->mail = (new BackupFailed($backup, $db, $output, $backup->database_name ?? 'unknown'))->toMail();
|
||||||
$this->sendEmail();
|
$this->sendEmail();
|
||||||
break;
|
break;
|
||||||
case 'backup-success':
|
case 'backup-success':
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ use App\Models\ApplicationDeploymentQueue;
|
|||||||
use App\Models\Environment;
|
use App\Models\Environment;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\ScheduledDatabaseBackup;
|
use App\Models\ScheduledDatabaseBackup;
|
||||||
|
use App\Models\ScheduledDatabaseBackupExecution;
|
||||||
|
use App\Models\ScheduledTaskExecution;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\StandalonePostgresql;
|
use App\Models\StandalonePostgresql;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
@@ -73,7 +76,7 @@ class Init extends Command
|
|||||||
$this->cleanupUnusedNetworkFromCoolifyProxy();
|
$this->cleanupUnusedNetworkFromCoolifyProxy();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->call('cleanup:redis', ['--clear-locks' => true]);
|
$this->call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
|
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
|
||||||
}
|
}
|
||||||
@@ -86,6 +89,7 @@ class Init extends Command
|
|||||||
$this->call('cleanup:stucked-resources');
|
$this->call('cleanup:stucked-resources');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n";
|
echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n";
|
||||||
|
echo "Continuing with initialization - cleanup errors will not prevent Coolify from starting\n";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$updatedCount = ApplicationDeploymentQueue::whereIn('status', [
|
$updatedCount = ApplicationDeploymentQueue::whereIn('status', [
|
||||||
@@ -102,6 +106,34 @@ class Init extends Command
|
|||||||
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
|
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||||
|
'finished_at' => Carbon::now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($updatedTaskCount > 0) {
|
||||||
|
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||||
|
'finished_at' => Carbon::now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($updatedBackupCount > 0) {
|
||||||
|
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$localhost = $this->servers->where('id', 0)->first();
|
$localhost = $this->servers->where('id', 0)->first();
|
||||||
if ($localhost) {
|
if ($localhost) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class SyncBunny extends Command
|
|||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}';
|
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The console command description.
|
* The console command description.
|
||||||
@@ -50,6 +50,7 @@ class SyncBunny extends Command
|
|||||||
|
|
||||||
// Clone the repository
|
// Clone the repository
|
||||||
$this->info('Cloning coolify-cdn repository...');
|
$this->info('Cloning coolify-cdn repository...');
|
||||||
|
$output = [];
|
||||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||||
if ($returnCode !== 0) {
|
if ($returnCode !== 0) {
|
||||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||||
@@ -59,6 +60,7 @@ class SyncBunny extends Command
|
|||||||
|
|
||||||
// Create feature branch
|
// Create feature branch
|
||||||
$this->info('Creating feature branch...');
|
$this->info('Creating feature branch...');
|
||||||
|
$output = [];
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||||
if ($returnCode !== 0) {
|
if ($returnCode !== 0) {
|
||||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||||
@@ -70,12 +72,25 @@ class SyncBunny extends Command
|
|||||||
// Write releases.json
|
// Write releases.json
|
||||||
$this->info('Writing releases.json...');
|
$this->info('Writing releases.json...');
|
||||||
$releasesPath = "$tmpDir/json/releases.json";
|
$releasesPath = "$tmpDir/json/releases.json";
|
||||||
|
$releasesDir = dirname($releasesPath);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (! is_dir($releasesDir)) {
|
||||||
|
$this->info("Creating directory: $releasesDir");
|
||||||
|
if (! mkdir($releasesDir, 0755, true)) {
|
||||||
|
$this->error("Failed to create directory: $releasesDir");
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
|
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
|
||||||
|
|
||||||
if ($bytesWritten === false) {
|
if ($bytesWritten === false) {
|
||||||
$this->error("Failed to write releases.json to: $releasesPath");
|
$this->error("Failed to write releases.json to: $releasesPath");
|
||||||
$this->error('Possible reasons: directory does not exist, permission denied, or disk full.');
|
$this->error('Possible reasons: permission denied or disk full.');
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -83,6 +98,7 @@ class SyncBunny extends Command
|
|||||||
|
|
||||||
// Stage and commit
|
// Stage and commit
|
||||||
$this->info('Committing changes...');
|
$this->info('Committing changes...');
|
||||||
|
$output = [];
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
|
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
|
||||||
if ($returnCode !== 0) {
|
if ($returnCode !== 0) {
|
||||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||||
@@ -120,6 +136,7 @@ class SyncBunny extends Command
|
|||||||
|
|
||||||
// Push to remote
|
// Push to remote
|
||||||
$this->info('Pushing branch to remote...');
|
$this->info('Pushing branch to remote...');
|
||||||
|
$output = [];
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||||
if ($returnCode !== 0) {
|
if ($returnCode !== 0) {
|
||||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||||
@@ -133,6 +150,7 @@ class SyncBunny extends Command
|
|||||||
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
|
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
|
||||||
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
|
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
|
||||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||||
|
$output = [];
|
||||||
exec($prCommand, $output, $returnCode);
|
exec($prCommand, $output, $returnCode);
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
@@ -158,6 +176,343 @@ class SyncBunny extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync both releases.json and versions.json to GitHub repository in one PR
|
||||||
|
*/
|
||||||
|
private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
|
||||||
|
{
|
||||||
|
$this->info('Syncing releases.json and versions.json to GitHub repository...');
|
||||||
|
try {
|
||||||
|
// 1. Fetch releases from GitHub API
|
||||||
|
$this->info('Fetching releases from GitHub API...');
|
||||||
|
$response = Http::timeout(30)
|
||||||
|
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
|
||||||
|
'per_page' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
$this->error('Failed to fetch releases from GitHub: '.$response->status());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$releases = $response->json();
|
||||||
|
|
||||||
|
// 2. Read versions.json
|
||||||
|
if (! file_exists($versionsLocation)) {
|
||||||
|
$this->error("versions.json not found at: $versionsLocation");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = file_get_contents($versionsLocation);
|
||||||
|
$versionsJson = json_decode($file, true);
|
||||||
|
$actualVersion = data_get($versionsJson, 'coolify.v4.version');
|
||||||
|
|
||||||
|
$timestamp = time();
|
||||||
|
$tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
|
||||||
|
$branchName = 'update-releases-and-versions-'.$timestamp;
|
||||||
|
$versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
|
||||||
|
|
||||||
|
// 3. Clone the repository
|
||||||
|
$this->info('Cloning coolify-cdn repository...');
|
||||||
|
$output = [];
|
||||||
|
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create feature branch
|
||||||
|
$this->info('Creating feature branch...');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Write releases.json
|
||||||
|
$this->info('Writing releases.json...');
|
||||||
|
$releasesPath = "$tmpDir/json/releases.json";
|
||||||
|
$releasesDir = dirname($releasesPath);
|
||||||
|
|
||||||
|
if (! is_dir($releasesDir)) {
|
||||||
|
if (! mkdir($releasesDir, 0755, true)) {
|
||||||
|
$this->error("Failed to create directory: $releasesDir");
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
|
||||||
|
$this->error("Failed to write releases.json to: $releasesPath");
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Write versions.json
|
||||||
|
$this->info('Writing versions.json...');
|
||||||
|
$versionsPath = "$tmpDir/$versionsTargetPath";
|
||||||
|
$versionsDir = dirname($versionsPath);
|
||||||
|
|
||||||
|
if (! is_dir($versionsDir)) {
|
||||||
|
if (! mkdir($versionsDir, 0755, true)) {
|
||||||
|
$this->error("Failed to create directory: $versionsDir");
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
|
||||||
|
$this->error("Failed to write versions.json to: $versionsPath");
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Stage both files
|
||||||
|
$this->info('Staging changes...');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Check for changes
|
||||||
|
$this->info('Checking for changes...');
|
||||||
|
$statusOutput = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty(array_filter($statusOutput))) {
|
||||||
|
$this->info('Both files are already up to date. No changes to commit.');
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Commit changes
|
||||||
|
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||||
|
$commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to commit changes: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Push to remote
|
||||||
|
$this->info('Pushing branch to remote...');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Create pull request
|
||||||
|
$this->info('Creating pull request...');
|
||||||
|
$prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||||
|
$prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
|
||||||
|
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||||
|
$output = [];
|
||||||
|
exec($prCommand, $output, $returnCode);
|
||||||
|
|
||||||
|
// 12. Clean up
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to create PR: '.implode("\n", $output));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Pull request created successfully!');
|
||||||
|
if (! empty($output)) {
|
||||||
|
$this->info('PR URL: '.implode("\n", $output));
|
||||||
|
}
|
||||||
|
$this->info("Version synced: $actualVersion");
|
||||||
|
$this->info('Total releases synced: '.count($releases));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Error syncing to GitHub: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync versions.json to GitHub repository via PR
|
||||||
|
*/
|
||||||
|
private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
|
||||||
|
{
|
||||||
|
$this->info('Syncing versions.json to GitHub repository...');
|
||||||
|
try {
|
||||||
|
if (! file_exists($versionsLocation)) {
|
||||||
|
$this->error("versions.json not found at: $versionsLocation");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = file_get_contents($versionsLocation);
|
||||||
|
$json = json_decode($file, true);
|
||||||
|
$actualVersion = data_get($json, 'coolify.v4.version');
|
||||||
|
|
||||||
|
$timestamp = time();
|
||||||
|
$tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
|
||||||
|
$branchName = 'update-versions-'.$timestamp;
|
||||||
|
$targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
|
||||||
|
|
||||||
|
// Clone the repository
|
||||||
|
$this->info('Cloning coolify-cdn repository...');
|
||||||
|
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create feature branch
|
||||||
|
$this->info('Creating feature branch...');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write versions.json
|
||||||
|
$this->info('Writing versions.json...');
|
||||||
|
$versionsPath = "$tmpDir/$targetPath";
|
||||||
|
$versionsDir = dirname($versionsPath);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (! is_dir($versionsDir)) {
|
||||||
|
$this->info("Creating directory: $versionsDir");
|
||||||
|
if (! mkdir($versionsDir, 0755, true)) {
|
||||||
|
$this->error("Failed to create directory: $versionsDir");
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
$bytesWritten = file_put_contents($versionsPath, $jsonContent);
|
||||||
|
|
||||||
|
if ($bytesWritten === false) {
|
||||||
|
$this->error("Failed to write versions.json to: $versionsPath");
|
||||||
|
$this->error('Possible reasons: permission denied or disk full.');
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage and commit
|
||||||
|
$this->info('Committing changes...');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Checking for changes...');
|
||||||
|
$statusOutput = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty(array_filter($statusOutput))) {
|
||||||
|
$this->info('versions.json is already up to date. No changes to commit.');
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||||
|
$commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to commit changes: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to remote
|
||||||
|
$this->info('Pushing branch to remote...');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pull request
|
||||||
|
$this->info('Creating pull request...');
|
||||||
|
$prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||||
|
$prBody = "Automated update of $envLabel versions.json to version $actualVersion";
|
||||||
|
$output = [];
|
||||||
|
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||||
|
exec($prCommand, $output, $returnCode);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to create PR: '.implode("\n", $output));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Pull request created successfully!');
|
||||||
|
if (! empty($output)) {
|
||||||
|
$this->info('PR URL: '.implode("\n", $output));
|
||||||
|
}
|
||||||
|
$this->info("Version synced: $actualVersion");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Error syncing versions.json: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the console command.
|
* Execute the console command.
|
||||||
*/
|
*/
|
||||||
@@ -167,6 +522,7 @@ class SyncBunny extends Command
|
|||||||
$only_template = $this->option('templates');
|
$only_template = $this->option('templates');
|
||||||
$only_version = $this->option('release');
|
$only_version = $this->option('release');
|
||||||
$only_github_releases = $this->option('github-releases');
|
$only_github_releases = $this->option('github-releases');
|
||||||
|
$only_github_versions = $this->option('github-versions');
|
||||||
$nightly = $this->option('nightly');
|
$nightly = $this->option('nightly');
|
||||||
$bunny_cdn = 'https://cdn.coollabs.io';
|
$bunny_cdn = 'https://cdn.coollabs.io';
|
||||||
$bunny_cdn_path = 'coolify';
|
$bunny_cdn_path = 'coolify';
|
||||||
@@ -224,7 +580,7 @@ class SyncBunny extends Command
|
|||||||
$install_script_location = "$parent_dir/other/nightly/$install_script";
|
$install_script_location = "$parent_dir/other/nightly/$install_script";
|
||||||
$versions_location = "$parent_dir/other/nightly/$versions";
|
$versions_location = "$parent_dir/other/nightly/$versions";
|
||||||
}
|
}
|
||||||
if (! $only_template && ! $only_version && ! $only_github_releases) {
|
if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
|
||||||
if ($nightly) {
|
if ($nightly) {
|
||||||
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
|
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
|
||||||
} else {
|
} else {
|
||||||
@@ -250,25 +606,47 @@ class SyncBunny extends Command
|
|||||||
return;
|
return;
|
||||||
} elseif ($only_version) {
|
} elseif ($only_version) {
|
||||||
if ($nightly) {
|
if ($nightly) {
|
||||||
$this->info('About to sync NIGHLTY versions.json to BunnyCDN.');
|
$this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.');
|
||||||
} else {
|
} else {
|
||||||
$this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
|
$this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.');
|
||||||
}
|
}
|
||||||
$file = file_get_contents($versions_location);
|
$file = file_get_contents($versions_location);
|
||||||
$json = json_decode($file, true);
|
$json = json_decode($file, true);
|
||||||
$actual_version = data_get($json, 'coolify.v4.version');
|
$actual_version = data_get($json, 'coolify.v4.version');
|
||||||
|
|
||||||
$confirmed = confirm("Are you sure you want to sync to {$actual_version}?");
|
$this->info("Version: {$actual_version}");
|
||||||
|
$this->info('This will:');
|
||||||
|
$this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)');
|
||||||
|
$this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$confirmed = confirm('Are you sure you want to proceed?');
|
||||||
if (! $confirmed) {
|
if (! $confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync versions.json to BunnyCDN
|
// 1. Sync versions.json to BunnyCDN (deprecated but still needed)
|
||||||
|
$this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
|
||||||
Http::pool(fn (Pool $pool) => [
|
Http::pool(fn (Pool $pool) => [
|
||||||
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
|
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
|
||||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
|
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
|
||||||
]);
|
]);
|
||||||
$this->info('versions.json uploaded & purged...');
|
$this->info('✓ versions.json uploaded & purged to BunnyCDN');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// 2. Create GitHub PR with both releases.json and versions.json
|
||||||
|
$this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
|
||||||
|
$githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
|
||||||
|
if ($githubSuccess) {
|
||||||
|
$this->info('✓ GitHub PR created successfully with both files');
|
||||||
|
} else {
|
||||||
|
$this->error('✗ Failed to create GitHub PR');
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->info('=== Summary ===');
|
||||||
|
$this->info('BunnyCDN sync: ✓ Complete');
|
||||||
|
$this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed'));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} elseif ($only_github_releases) {
|
} elseif ($only_github_releases) {
|
||||||
@@ -281,6 +659,22 @@ class SyncBunny extends Command
|
|||||||
// Sync releases to GitHub repository
|
// Sync releases to GitHub repository
|
||||||
$this->syncReleasesToGitHubRepo();
|
$this->syncReleasesToGitHubRepo();
|
||||||
|
|
||||||
|
return;
|
||||||
|
} elseif ($only_github_versions) {
|
||||||
|
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||||
|
$file = file_get_contents($versions_location);
|
||||||
|
$json = json_decode($file, true);
|
||||||
|
$actual_version = data_get($json, 'coolify.v4.version');
|
||||||
|
|
||||||
|
$this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository.");
|
||||||
|
$confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?');
|
||||||
|
if (! $confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync versions.json to GitHub repository
|
||||||
|
$this->syncVersionsToGitHubRepo($versions_location, $nightly);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Console;
|
|||||||
use App\Jobs\CheckAndStartSentinelJob;
|
use App\Jobs\CheckAndStartSentinelJob;
|
||||||
use App\Jobs\CheckForUpdatesJob;
|
use App\Jobs\CheckForUpdatesJob;
|
||||||
use App\Jobs\CheckHelperImageJob;
|
use App\Jobs\CheckHelperImageJob;
|
||||||
|
use App\Jobs\CheckTraefikVersionJob;
|
||||||
use App\Jobs\CleanupInstanceStuffsJob;
|
use App\Jobs\CleanupInstanceStuffsJob;
|
||||||
use App\Jobs\PullChangelog;
|
use App\Jobs\PullChangelog;
|
||||||
use App\Jobs\PullTemplatesFromCDN;
|
use App\Jobs\PullTemplatesFromCDN;
|
||||||
@@ -83,6 +84,8 @@ class Kernel extends ConsoleKernel
|
|||||||
|
|
||||||
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
|
$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('cleanup:database --yes')->daily();
|
||||||
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class ServerMetadata extends Data
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public ?ProxyTypes $type,
|
public ?ProxyTypes $type,
|
||||||
public ?ProxyStatus $status
|
public ?ProxyStatus $status,
|
||||||
|
public ?string $last_saved_settings = null,
|
||||||
|
public ?string $last_applied_settings = null
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,17 +17,23 @@ class RestoreJobFinished
|
|||||||
$tmpPath = data_get($data, 'tmpPath');
|
$tmpPath = data_get($data, 'tmpPath');
|
||||||
$container = data_get($data, 'container');
|
$container = data_get($data, 'container');
|
||||||
$serverId = data_get($data, 'serverId');
|
$serverId = data_get($data, 'serverId');
|
||||||
if (filled($scriptPath) && filled($tmpPath) && filled($container) && filled($serverId)) {
|
|
||||||
if (str($tmpPath)->startsWith('/tmp/')
|
if (filled($container) && filled($serverId)) {
|
||||||
&& str($scriptPath)->startsWith('/tmp/')
|
$commands = [];
|
||||||
&& ! str($tmpPath)->contains('..')
|
|
||||||
&& ! str($scriptPath)->contains('..')
|
if (isSafeTmpPath($scriptPath)) {
|
||||||
&& strlen($tmpPath) > 5 // longer than just "/tmp/"
|
$commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($scriptPath)." 2>/dev/null || true'";
|
||||||
&& strlen($scriptPath) > 5
|
}
|
||||||
) {
|
|
||||||
$commands[] = "docker exec {$container} sh -c 'rm {$scriptPath}'";
|
if (isSafeTmpPath($tmpPath)) {
|
||||||
$commands[] = "docker exec {$container} sh -c 'rm {$tmpPath}'";
|
$commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($tmpPath)." 2>/dev/null || true'";
|
||||||
instant_remote_process($commands, Server::find($serverId), throwError: true);
|
}
|
||||||
|
|
||||||
|
if (! empty($commands)) {
|
||||||
|
$server = Server::find($serverId);
|
||||||
|
if ($server) {
|
||||||
|
instant_remote_process($commands, $server, throwError: false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class S3RestoreJobFinished
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct($data)
|
||||||
|
{
|
||||||
|
$containerName = data_get($data, 'containerName');
|
||||||
|
$serverTmpPath = data_get($data, 'serverTmpPath');
|
||||||
|
$scriptPath = data_get($data, 'scriptPath');
|
||||||
|
$containerTmpPath = data_get($data, 'containerTmpPath');
|
||||||
|
$container = data_get($data, 'container');
|
||||||
|
$serverId = data_get($data, 'serverId');
|
||||||
|
|
||||||
|
// Most cleanup now happens inline during restore process
|
||||||
|
// This acts as a safety net for edge cases (errors, interruptions)
|
||||||
|
if (filled($serverId)) {
|
||||||
|
$commands = [];
|
||||||
|
|
||||||
|
// Ensure helper container is removed (may already be gone from inline cleanup)
|
||||||
|
if (filled($containerName)) {
|
||||||
|
$commands[] = 'docker rm -f '.escapeshellarg($containerName).' 2>/dev/null || true';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up server temp file if still exists (should already be cleaned)
|
||||||
|
if (isSafeTmpPath($serverTmpPath)) {
|
||||||
|
$commands[] = 'rm -f '.escapeshellarg($serverTmpPath).' 2>/dev/null || true';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any remaining files in database container (may already be cleaned)
|
||||||
|
if (filled($container)) {
|
||||||
|
if (isSafeTmpPath($containerTmpPath)) {
|
||||||
|
$commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($containerTmpPath).' 2>/dev/null || true';
|
||||||
|
}
|
||||||
|
if (isSafeTmpPath($scriptPath)) {
|
||||||
|
$commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($scriptPath).' 2>/dev/null || true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($commands)) {
|
||||||
|
$server = Server::find($serverId);
|
||||||
|
if ($server) {
|
||||||
|
instant_remote_process($commands, $server, throwError: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception for expected deployment failures caused by user/application errors.
|
||||||
|
* These are not Coolify bugs and should not be logged to laravel.log.
|
||||||
|
* Examples: Nixpacks detection failures, missing Dockerfiles, invalid configs, etc.
|
||||||
|
*/
|
||||||
|
class DeploymentException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a new deployment exception instance.
|
||||||
|
*
|
||||||
|
* @param string $message
|
||||||
|
* @param int $code
|
||||||
|
*/
|
||||||
|
public function __construct($message = '', $code = 0, ?\Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from another exception, preserving its message and stack trace.
|
||||||
|
*/
|
||||||
|
public static function fromException(\Throwable $exception): static
|
||||||
|
{
|
||||||
|
return new static($exception->getMessage(), $exception->getCode(), $exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ class Handler extends ExceptionHandler
|
|||||||
protected $dontReport = [
|
protected $dontReport = [
|
||||||
ProcessException::class,
|
ProcessException::class,
|
||||||
NonReportableException::class,
|
NonReportableException::class,
|
||||||
|
DeploymentException::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ class ServicesController extends Controller
|
|||||||
'destination_id' => $destination->id,
|
'destination_id' => $destination->id,
|
||||||
'destination_type' => $destination->getMorphClass(),
|
'destination_type' => $destination->getMorphClass(),
|
||||||
];
|
];
|
||||||
if ($oneClickServiceName === 'cloudflared') {
|
if ($oneClickServiceName === 'pgadmin') {
|
||||||
data_set($servicePayload, 'connect_to_docker_network', true);
|
data_set($servicePayload, 'connect_to_docker_network', true);
|
||||||
}
|
}
|
||||||
$service = Service::create($servicePayload);
|
$service = Service::create($servicePayload);
|
||||||
|
|||||||
@@ -246,6 +246,40 @@ class Github extends Controller
|
|||||||
if ($action === 'closed') {
|
if ($action === 'closed') {
|
||||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||||
if ($found) {
|
if ($found) {
|
||||||
|
// Cancel any active deployments for this PR immediately
|
||||||
|
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||||
|
->where('pull_request_id', $pull_request_id)
|
||||||
|
->whereIn('status', [
|
||||||
|
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||||
|
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||||
|
])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($activeDeployment) {
|
||||||
|
try {
|
||||||
|
// Mark deployment as cancelled
|
||||||
|
$activeDeployment->update([
|
||||||
|
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add cancellation log entry
|
||||||
|
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||||
|
|
||||||
|
// Check if helper container exists and kill it
|
||||||
|
$deployment_uuid = $activeDeployment->deployment_uuid;
|
||||||
|
$server = $application->destination->server;
|
||||||
|
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||||
|
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||||
|
|
||||||
|
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||||
|
instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
|
||||||
|
$activeDeployment->addLogEntry('Deployment container stopped.');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Silently handle errors during deployment cancellation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DeleteResourceJob::dispatch($found);
|
DeleteResourceJob::dispatch($found);
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
@@ -481,6 +515,42 @@ class Github extends Controller
|
|||||||
if ($action === 'closed' || $action === 'close') {
|
if ($action === 'closed' || $action === 'close') {
|
||||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||||
if ($found) {
|
if ($found) {
|
||||||
|
// Cancel any active deployments for this PR immediately
|
||||||
|
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||||
|
->where('pull_request_id', $pull_request_id)
|
||||||
|
->whereIn('status', [
|
||||||
|
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||||
|
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||||
|
])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($activeDeployment) {
|
||||||
|
try {
|
||||||
|
// Mark deployment as cancelled
|
||||||
|
$activeDeployment->update([
|
||||||
|
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add cancellation log entry
|
||||||
|
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||||
|
|
||||||
|
// Check if helper container exists and kill it
|
||||||
|
$deployment_uuid = $activeDeployment->deployment_uuid;
|
||||||
|
$server = $application->destination->server;
|
||||||
|
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||||
|
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||||
|
|
||||||
|
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||||
|
instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
|
||||||
|
$activeDeployment->addLogEntry('Deployment container stopped.');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Silently handle errors during deployment cancellation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any deployed containers
|
||||||
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
|
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
|
||||||
if ($containers->isNotEmpty()) {
|
if ($containers->isNotEmpty()) {
|
||||||
$containers->each(function ($container) use ($application) {
|
$containers->each(function ($container) use ($application) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,9 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
// New version available
|
// New version available
|
||||||
$settings->update(['new_version_available' => true]);
|
$settings->update(['new_version_available' => true]);
|
||||||
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
|
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
// Invalidate cache to ensure fresh data is loaded
|
||||||
|
invalidate_versions_cache();
|
||||||
} else {
|
} else {
|
||||||
$settings->update(['new_version_available' => false]);
|
$settings->update(['new_version_available' => false]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Enums\ProxyTypes;
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class CheckTraefikVersionJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public $tries = 3;
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
// Load versions from cached data
|
||||||
|
$traefikVersions = get_traefik_versions();
|
||||||
|
|
||||||
|
if (empty($traefikVersions)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query all servers with Traefik proxy that are reachable
|
||||||
|
$servers = Server::whereNotNull('proxy')
|
||||||
|
->whereProxyType(ProxyTypes::TRAEFIK->value)
|
||||||
|
->whereRelation('settings', 'is_reachable', true)
|
||||||
|
->whereRelation('settings', 'is_usable', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($servers->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch individual server check jobs in parallel
|
||||||
|
// Each job will send immediate notifications when outdated Traefik is detected
|
||||||
|
foreach ($servers as $server) {
|
||||||
|
CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationDeploymentStatus;
|
||||||
|
use App\Models\ApplicationDeploymentQueue;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
@@ -20,10 +22,51 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
|
|||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
// Get all active deployments on this server
|
||||||
|
$activeDeployments = ApplicationDeploymentQueue::where('server_id', $this->server->id)
|
||||||
|
->whereIn('status', [
|
||||||
|
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||||
|
ApplicationDeploymentStatus::QUEUED->value,
|
||||||
|
])
|
||||||
|
->pluck('deployment_uuid')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
\Log::info('CleanupHelperContainersJob - Active deployments', [
|
||||||
|
'server' => $this->server->name,
|
||||||
|
'active_deployment_uuids' => $activeDeployments,
|
||||||
|
]);
|
||||||
|
|
||||||
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
|
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
|
||||||
$containerIds = collect(json_decode($containers))->pluck('ID');
|
$helperContainers = collect(json_decode($containers));
|
||||||
if ($containerIds->count() > 0) {
|
|
||||||
foreach ($containerIds as $containerId) {
|
if ($helperContainers->count() > 0) {
|
||||||
|
foreach ($helperContainers as $container) {
|
||||||
|
$containerId = data_get($container, 'ID');
|
||||||
|
$containerName = data_get($container, 'Names');
|
||||||
|
|
||||||
|
// Check if this container belongs to an active deployment
|
||||||
|
$isActiveDeployment = false;
|
||||||
|
foreach ($activeDeployments as $deploymentUuid) {
|
||||||
|
if (str_contains($containerName, $deploymentUuid)) {
|
||||||
|
$isActiveDeployment = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isActiveDeployment) {
|
||||||
|
\Log::info('CleanupHelperContainersJob - Skipping active deployment container', [
|
||||||
|
'container' => $containerName,
|
||||||
|
'id' => $containerId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
\Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [
|
||||||
|
'container' => $containerName,
|
||||||
|
'id' => $containerId,
|
||||||
|
]);
|
||||||
|
|
||||||
instant_remote_process_with_timeout(['docker container rm -f '.$containerId], $this->server, false);
|
instant_remote_process_with_timeout(['docker container rm -f '.$containerId], $this->server, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,35 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Actions\CoolifyTask\RunRemoteProcess;
|
use App\Actions\CoolifyTask\RunRemoteProcess;
|
||||||
|
use App\Enums\ProcessStatus;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Spatie\Activitylog\Models\Activity;
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
|
||||||
class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
|
class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of times the job may be attempted.
|
||||||
|
*/
|
||||||
|
public $tries = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of unhandled exceptions to allow before failing.
|
||||||
|
*/
|
||||||
|
public $maxExceptions = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of seconds the job can run before timing out.
|
||||||
|
*/
|
||||||
|
public $timeout = 600;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new job instance.
|
* Create a new job instance.
|
||||||
*/
|
*/
|
||||||
@@ -42,4 +59,53 @@ class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
$remote_process();
|
$remote_process();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the number of seconds to wait before retrying the job.
|
||||||
|
*/
|
||||||
|
public function backoff(): array
|
||||||
|
{
|
||||||
|
return [30, 90, 180]; // 30s, 90s, 180s between retries
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a job failure.
|
||||||
|
*/
|
||||||
|
public function failed(?\Throwable $exception): void
|
||||||
|
{
|
||||||
|
Log::channel('scheduled-errors')->error('CoolifyTask permanently failed', [
|
||||||
|
'job' => 'CoolifyTask',
|
||||||
|
'activity_id' => $this->activity->id,
|
||||||
|
'server_uuid' => $this->activity->getExtraProperty('server_uuid'),
|
||||||
|
'command_preview' => substr($this->activity->getExtraProperty('command') ?? '', 0, 200),
|
||||||
|
'error' => $exception?->getMessage(),
|
||||||
|
'total_attempts' => $this->attempts(),
|
||||||
|
'trace' => $exception?->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update activity status to reflect permanent failure
|
||||||
|
$this->activity->properties = $this->activity->properties->merge([
|
||||||
|
'status' => ProcessStatus::ERROR->value,
|
||||||
|
'error' => $exception?->getMessage() ?? 'Job permanently failed',
|
||||||
|
'failed_at' => now()->toIso8601String(),
|
||||||
|
]);
|
||||||
|
$this->activity->save();
|
||||||
|
|
||||||
|
// Dispatch cleanup event on failure (same as on success)
|
||||||
|
if ($this->call_event_on_finish) {
|
||||||
|
try {
|
||||||
|
$eventClass = "App\\Events\\$this->call_event_on_finish";
|
||||||
|
if (! is_null($this->call_event_data)) {
|
||||||
|
event(new $eventClass($this->call_event_data));
|
||||||
|
} else {
|
||||||
|
event(new $eventClass($this->activity->causer_id));
|
||||||
|
}
|
||||||
|
Log::info('Cleanup event dispatched after job failure', [
|
||||||
|
'event' => $this->call_event_on_finish,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Error dispatching cleanup event on failure: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
|||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
@@ -31,6 +32,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public $maxExceptions = 1;
|
||||||
|
|
||||||
public ?Team $team = null;
|
public ?Team $team = null;
|
||||||
|
|
||||||
public Server $server;
|
public Server $server;
|
||||||
@@ -74,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
public function __construct(public ScheduledDatabaseBackup $backup)
|
public function __construct(public ScheduledDatabaseBackup $backup)
|
||||||
{
|
{
|
||||||
$this->onQueue('high');
|
$this->onQueue('high');
|
||||||
$this->timeout = $backup->timeout;
|
$this->timeout = $backup->timeout ?? 3600;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
@@ -486,17 +489,22 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$collectionsToExclude = collect();
|
$collectionsToExclude = collect();
|
||||||
}
|
}
|
||||||
$commands[] = 'mkdir -p '.$this->backup_dir;
|
$commands[] = 'mkdir -p '.$this->backup_dir;
|
||||||
|
|
||||||
|
// Validate and escape database name to prevent command injection
|
||||||
|
validateShellSafePath($databaseName, 'database name');
|
||||||
|
$escapedDatabaseName = escapeshellarg($databaseName);
|
||||||
|
|
||||||
if ($collectionsToExclude->count() === 0) {
|
if ($collectionsToExclude->count() === 0) {
|
||||||
if (str($this->database->image)->startsWith('mongo:4')) {
|
if (str($this->database->image)->startsWith('mongo:4')) {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (str($this->database->image)->startsWith('mongo:4')) {
|
if (str($this->database->image)->startsWith('mongo:4')) {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,7 +530,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
if ($this->backup->dump_all) {
|
if ($this->backup->dump_all) {
|
||||||
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
|
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $database > $this->backup_location";
|
// Validate and escape database name to prevent command injection
|
||||||
|
validateShellSafePath($database, 'database name');
|
||||||
|
$escapedDatabase = escapeshellarg($database);
|
||||||
|
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location";
|
||||||
}
|
}
|
||||||
|
|
||||||
$commands[] = $backupCommand;
|
$commands[] = $backupCommand;
|
||||||
@@ -544,7 +555,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
if ($this->backup->dump_all) {
|
if ($this->backup->dump_all) {
|
||||||
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $database > $this->backup_location";
|
// Validate and escape database name to prevent command injection
|
||||||
|
validateShellSafePath($database, 'database name');
|
||||||
|
$escapedDatabase = escapeshellarg($database);
|
||||||
|
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location";
|
||||||
}
|
}
|
||||||
$this->backup_output = instant_remote_process($commands, $this->server);
|
$this->backup_output = instant_remote_process($commands, $this->server);
|
||||||
$this->backup_output = trim($this->backup_output);
|
$this->backup_output = trim($this->backup_output);
|
||||||
@@ -564,7 +578,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
if ($this->backup->dump_all) {
|
if ($this->backup->dump_all) {
|
||||||
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $database > $this->backup_location";
|
// Validate and escape database name to prevent command injection
|
||||||
|
validateShellSafePath($database, 'database name');
|
||||||
|
$escapedDatabase = escapeshellarg($database);
|
||||||
|
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location";
|
||||||
}
|
}
|
||||||
$this->backup_output = instant_remote_process($commands, $this->server);
|
$this->backup_output = instant_remote_process($commands, $this->server);
|
||||||
$this->backup_output = trim($this->backup_output);
|
$this->backup_output = trim($this->backup_output);
|
||||||
@@ -636,7 +653,13 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
} else {
|
} else {
|
||||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
|
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
|
||||||
}
|
}
|
||||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
|
|
||||||
|
// Escape S3 credentials to prevent command injection
|
||||||
|
$escapedEndpoint = escapeshellarg($endpoint);
|
||||||
|
$escapedKey = escapeshellarg($key);
|
||||||
|
$escapedSecret = escapeshellarg($secret);
|
||||||
|
|
||||||
|
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
||||||
instant_remote_process($commands, $this->server);
|
instant_remote_process($commands, $this->server);
|
||||||
|
|
||||||
@@ -661,15 +684,34 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
public function failed(?Throwable $exception): void
|
public function failed(?Throwable $exception): void
|
||||||
{
|
{
|
||||||
|
Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [
|
||||||
|
'job' => 'DatabaseBackupJob',
|
||||||
|
'backup_id' => $this->backup->uuid,
|
||||||
|
'database' => $this->database?->name ?? 'unknown',
|
||||||
|
'database_type' => get_class($this->database ?? new \stdClass),
|
||||||
|
'server' => $this->server?->name ?? 'unknown',
|
||||||
|
'total_attempts' => $this->attempts(),
|
||||||
|
'error' => $exception?->getMessage(),
|
||||||
|
'trace' => $exception?->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
|
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
|
||||||
|
|
||||||
if ($log) {
|
if ($log) {
|
||||||
$log->update([
|
$log->update([
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
'message' => 'Job failed: '.($exception?->getMessage() ?? 'Unknown error'),
|
'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
|
||||||
'size' => 0,
|
'size' => 0,
|
||||||
'filename' => null,
|
'filename' => null,
|
||||||
|
'finished_at' => Carbon::now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify team about permanent failure
|
||||||
|
if ($this->team) {
|
||||||
|
$databaseName = $log?->database_name ?? 'unknown';
|
||||||
|
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
|
||||||
|
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,16 +124,54 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->resource->delete();
|
$this->resource->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel any active deployments for this PR (same logic as API cancel_deployment)
|
||||||
|
$activeDeployments = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||||
|
->where('pull_request_id', $pull_request_id)
|
||||||
|
->whereIn('status', [
|
||||||
|
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||||
|
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||||
|
])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($activeDeployments as $activeDeployment) {
|
||||||
|
try {
|
||||||
|
// Mark deployment as cancelled
|
||||||
|
$activeDeployment->update([
|
||||||
|
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add cancellation log entry
|
||||||
|
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||||
|
|
||||||
|
// Check if helper container exists and kill it
|
||||||
|
$deployment_uuid = $activeDeployment->deployment_uuid;
|
||||||
|
$escapedDeploymentUuid = escapeshellarg($deployment_uuid);
|
||||||
|
$checkCommand = "docker ps -a --filter name={$escapedDeploymentUuid} --format '{{.Names}}'";
|
||||||
|
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||||
|
|
||||||
|
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||||
|
instant_remote_process(["docker rm -f {$escapedDeploymentUuid}"], $server);
|
||||||
|
$activeDeployment->addLogEntry('Deployment container stopped.');
|
||||||
|
} else {
|
||||||
|
$activeDeployment->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Silently handle errors during deployment cancellation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($server->isSwarm()) {
|
if ($server->isSwarm()) {
|
||||||
instant_remote_process(["docker stack rm {$application->uuid}-{$pull_request_id}"], $server);
|
$escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
|
||||||
|
instant_remote_process(["docker stack rm {$escapedStackName}"], $server);
|
||||||
} else {
|
} else {
|
||||||
$containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray();
|
$containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray();
|
||||||
$this->stopPreviewContainers($containers, $server);
|
$this->stopPreviewContainers($containers, $server);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// Log the error but don't fail the job
|
// Log the error but don't fail the job
|
||||||
ray('Error stopping preview containers: '.$e->getMessage());
|
\Log::warning('Error stopping preview containers for application '.$application->uuid.', PR #'.$pull_request_id.': '.$e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finally, force delete to trigger resource cleanup
|
// Finally, force delete to trigger resource cleanup
|
||||||
@@ -156,7 +194,6 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
"docker stop -t $timeout $containerList",
|
"docker stop -t $timeout $containerList",
|
||||||
"docker rm -f $containerList",
|
"docker rm -f $containerList",
|
||||||
];
|
];
|
||||||
|
|
||||||
instant_remote_process(
|
instant_remote_process(
|
||||||
command: $commands,
|
command: $commands,
|
||||||
server: $server,
|
server: $server,
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\Server;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public $timeout = 1000;
|
|
||||||
|
|
||||||
public function __construct(public Server $server)
|
|
||||||
{
|
|
||||||
$this->onQueue('high');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(): void
|
|
||||||
{
|
|
||||||
$helperImage = config('constants.coolify.helper_image');
|
|
||||||
$latest_version = getHelperVersion();
|
|
||||||
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,8 @@ use App\Models\Server;
|
|||||||
use App\Models\ServiceApplication;
|
use App\Models\ServiceApplication;
|
||||||
use App\Models\ServiceDatabase;
|
use App\Models\ServiceDatabase;
|
||||||
use App\Notifications\Container\ContainerRestarted;
|
use App\Notifications\Container\ContainerRestarted;
|
||||||
|
use App\Services\ContainerStatusAggregator;
|
||||||
|
use App\Traits\CalculatesExcludedStatus;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@@ -25,6 +27,7 @@ use Laravel\Horizon\Contracts\Silenced;
|
|||||||
|
|
||||||
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||||
{
|
{
|
||||||
|
use CalculatesExcludedStatus;
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
public $tries = 1;
|
public $tries = 1;
|
||||||
@@ -67,6 +70,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||||||
|
|
||||||
public Collection $applicationContainerStatuses;
|
public Collection $applicationContainerStatuses;
|
||||||
|
|
||||||
|
public Collection $serviceContainerStatuses;
|
||||||
|
|
||||||
public bool $foundProxy = false;
|
public bool $foundProxy = false;
|
||||||
|
|
||||||
public bool $foundLogDrainContainer = false;
|
public bool $foundLogDrainContainer = false;
|
||||||
@@ -90,6 +95,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||||||
$this->foundApplicationPreviewsIds = collect();
|
$this->foundApplicationPreviewsIds = collect();
|
||||||
$this->foundServiceDatabaseIds = collect();
|
$this->foundServiceDatabaseIds = collect();
|
||||||
$this->applicationContainerStatuses = collect();
|
$this->applicationContainerStatuses = collect();
|
||||||
|
$this->serviceContainerStatuses = collect();
|
||||||
$this->allApplicationIds = collect();
|
$this->allApplicationIds = collect();
|
||||||
$this->allDatabaseUuids = collect();
|
$this->allDatabaseUuids = collect();
|
||||||
$this->allTcpProxyUuids = collect();
|
$this->allTcpProxyUuids = collect();
|
||||||
@@ -99,6 +105,20 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||||||
|
|
||||||
public function handle()
|
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
|
// TODO: Swarm is not supported yet
|
||||||
if (! $this->data) {
|
if (! $this->data) {
|
||||||
throw new \Exception('No data provided');
|
throw new \Exception('No data provided');
|
||||||
@@ -108,7 +128,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||||||
$this->server->sentinelHeartbeat();
|
$this->server->sentinelHeartbeat();
|
||||||
|
|
||||||
$this->containers = collect(data_get($data, 'containers'));
|
$this->containers = collect(data_get($data, 'containers'));
|
||||||
|
|
||||||
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
|
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
|
||||||
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
|
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
|
||||||
|
|
||||||
@@ -141,65 +160,88 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||||||
|
|
||||||
foreach ($this->containers as $container) {
|
foreach ($this->containers as $container) {
|
||||||
$containerStatus = data_get($container, 'state', 'exited');
|
$containerStatus = data_get($container, 'state', 'exited');
|
||||||
$containerHealth = data_get($container, 'health_status', 'unhealthy');
|
$rawHealthStatus = data_get($container, 'health_status');
|
||||||
$containerStatus = "$containerStatus ($containerHealth)";
|
$containerHealth = $rawHealthStatus ?? 'unknown';
|
||||||
|
// Only append health status if container is not exited
|
||||||
|
if ($containerStatus !== 'exited') {
|
||||||
|
$containerStatus = "$containerStatus:$containerHealth";
|
||||||
|
}
|
||||||
$labels = collect(data_get($container, 'labels'));
|
$labels = collect(data_get($container, 'labels'));
|
||||||
$coolify_managed = $labels->has('coolify.managed');
|
$coolify_managed = $labels->has('coolify.managed');
|
||||||
if ($coolify_managed) {
|
|
||||||
$name = data_get($container, 'name');
|
if (! $coolify_managed) {
|
||||||
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
|
continue;
|
||||||
$this->foundLogDrainContainer = true;
|
}
|
||||||
}
|
|
||||||
if ($labels->has('coolify.applicationId')) {
|
$name = data_get($container, 'name');
|
||||||
$applicationId = $labels->get('coolify.applicationId');
|
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
|
||||||
$pullRequestId = $labels->get('coolify.pullRequestId', '0');
|
$this->foundLogDrainContainer = true;
|
||||||
try {
|
}
|
||||||
if ($pullRequestId === '0') {
|
if ($labels->has('coolify.applicationId')) {
|
||||||
if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) {
|
$applicationId = $labels->get('coolify.applicationId');
|
||||||
$this->foundApplicationIds->push($applicationId);
|
$pullRequestId = $labels->get('coolify.pullRequestId', '0');
|
||||||
}
|
try {
|
||||||
// Store container status for aggregation
|
if ($pullRequestId === '0') {
|
||||||
if (! $this->applicationContainerStatuses->has($applicationId)) {
|
if ($this->allApplicationIds->contains($applicationId)) {
|
||||||
$this->applicationContainerStatuses->put($applicationId, collect());
|
$this->foundApplicationIds->push($applicationId);
|
||||||
}
|
}
|
||||||
$containerName = $labels->get('com.docker.compose.service');
|
// Store container status for aggregation
|
||||||
if ($containerName) {
|
if (! $this->applicationContainerStatuses->has($applicationId)) {
|
||||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
$this->applicationContainerStatuses->put($applicationId, collect());
|
||||||
}
|
}
|
||||||
} else {
|
$containerName = $labels->get('com.docker.compose.service');
|
||||||
$previewKey = $applicationId.':'.$pullRequestId;
|
if ($containerName) {
|
||||||
if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) {
|
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||||
$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->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 {
|
} else {
|
||||||
if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
$previewKey = $applicationId.':'.$pullRequestId;
|
||||||
$this->foundDatabaseUuids->push($uuid);
|
if ($this->allApplicationPreviewsIds->contains($previewKey)) {
|
||||||
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
$this->foundApplicationPreviewsIds->push($previewKey);
|
||||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
|
}
|
||||||
} else {
|
$this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
|
||||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false);
|
}
|
||||||
}
|
} 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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||||||
// Aggregate multi-container application statuses
|
// Aggregate multi-container application statuses
|
||||||
$this->aggregateMultiContainerStatuses();
|
$this->aggregateMultiContainerStatuses();
|
||||||
|
|
||||||
|
// Aggregate multi-container service statuses
|
||||||
|
$this->aggregateServiceContainerStatuses();
|
||||||
|
|
||||||
$this->checkLogDrainContainer();
|
$this->checkLogDrainContainer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,57 +280,28 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||||||
|
|
||||||
// Parse docker compose to check for excluded containers
|
// Parse docker compose to check for excluded containers
|
||||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
$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) {
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out excluded containers
|
// Filter out excluded containers
|
||||||
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||||
return ! $excludedContainers->contains($containerName);
|
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()) {
|
if ($relevantStatuses->isEmpty()) {
|
||||||
|
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
|
||||||
|
|
||||||
|
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||||
|
$application->status = $aggregatedStatus;
|
||||||
|
$application->save();
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregate status: if any container is running, app is running
|
// Use ContainerStatusAggregator service for state machine logic
|
||||||
$hasRunning = false;
|
$aggregator = new ContainerStatusAggregator;
|
||||||
$hasUnhealthy = false;
|
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
|
||||||
|
|
||||||
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)';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update application status with aggregated result
|
// Update application status with aggregated result
|
||||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||||
@@ -295,6 +311,66 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
private function updateApplicationStatus(string $applicationId, string $containerStatus)
|
||||||
{
|
{
|
||||||
$application = $this->applications->where('id', $applicationId)->first();
|
$application = $this->applications->where('id', $applicationId)->first();
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
StopProxy::run($this->server);
|
StopProxy::run($this->server, restarting: true);
|
||||||
|
|
||||||
$this->server->proxy->force_stop = false;
|
$this->server->proxy->force_stop = false;
|
||||||
$this->server->save();
|
$this->server->save();
|
||||||
|
|
||||||
StartProxy::run($this->server, force: true);
|
StartProxy::run($this->server, force: true, restarting: true);
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e);
|
return handleError($e);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class ScheduledJobManager implements ShouldQueue
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
(new WithoutOverlapping('scheduled-job-manager'))
|
(new WithoutOverlapping('scheduled-job-manager'))
|
||||||
->expireAfter(60) // Lock expires after 1 minute to prevent stale locks
|
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
|
||||||
->dontRelease(), // Don't re-queue on lock conflict
|
->dontRelease(), // Don't re-queue on lock conflict
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,30 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
|||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class ScheduledTaskJob implements ShouldQueue
|
class ScheduledTaskJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of times the job may be attempted.
|
||||||
|
*/
|
||||||
|
public $tries = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of unhandled exceptions to allow before failing.
|
||||||
|
*/
|
||||||
|
public $maxExceptions = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of seconds the job can run before timing out.
|
||||||
|
*/
|
||||||
|
public $timeout = 300;
|
||||||
|
|
||||||
public Team $team;
|
public Team $team;
|
||||||
|
|
||||||
public Server $server;
|
public ?Server $server = null;
|
||||||
|
|
||||||
public ScheduledTask $task;
|
public ScheduledTask $task;
|
||||||
|
|
||||||
@@ -33,6 +49,11 @@ class ScheduledTaskJob implements ShouldQueue
|
|||||||
|
|
||||||
public ?ScheduledTaskExecution $task_log = null;
|
public ?ScheduledTaskExecution $task_log = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store execution ID to survive job serialization for timeout handling.
|
||||||
|
*/
|
||||||
|
protected ?int $executionId = null;
|
||||||
|
|
||||||
public string $task_status = 'failed';
|
public string $task_status = 'failed';
|
||||||
|
|
||||||
public ?string $task_output = null;
|
public ?string $task_output = null;
|
||||||
@@ -55,6 +76,9 @@ class ScheduledTaskJob implements ShouldQueue
|
|||||||
}
|
}
|
||||||
$this->team = Team::findOrFail($task->team_id);
|
$this->team = Team::findOrFail($task->team_id);
|
||||||
$this->server_timezone = $this->getServerTimezone();
|
$this->server_timezone = $this->getServerTimezone();
|
||||||
|
|
||||||
|
// Set timeout from task configuration
|
||||||
|
$this->timeout = $this->task->timeout ?? 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getServerTimezone(): string
|
private function getServerTimezone(): string
|
||||||
@@ -70,11 +94,18 @@ class ScheduledTaskJob implements ShouldQueue
|
|||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
|
$startTime = Carbon::now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->task_log = ScheduledTaskExecution::create([
|
$this->task_log = ScheduledTaskExecution::create([
|
||||||
'scheduled_task_id' => $this->task->id,
|
'scheduled_task_id' => $this->task->id,
|
||||||
|
'started_at' => $startTime,
|
||||||
|
'retry_count' => $this->attempts() - 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Store execution ID for timeout handling
|
||||||
|
$this->executionId = $this->task_log->id;
|
||||||
|
|
||||||
$this->server = $this->resource->destination->server;
|
$this->server = $this->resource->destination->server;
|
||||||
|
|
||||||
if ($this->resource->type() === 'application') {
|
if ($this->resource->type() === 'application') {
|
||||||
@@ -129,15 +160,101 @@ class ScheduledTaskJob implements ShouldQueue
|
|||||||
'message' => $this->task_output ?? $e->getMessage(),
|
'message' => $this->task_output ?? $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$this->team?->notify(new TaskFailed($this->task, $e->getMessage()));
|
|
||||||
|
// Log the error to the scheduled-errors channel
|
||||||
|
Log::channel('scheduled-errors')->error('ScheduledTask execution failed', [
|
||||||
|
'job' => 'ScheduledTaskJob',
|
||||||
|
'task_id' => $this->task->uuid,
|
||||||
|
'task_name' => $this->task->name,
|
||||||
|
'server' => $this->server?->name ?? 'unknown',
|
||||||
|
'attempt' => $this->attempts(),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only notify and throw on final failure
|
||||||
|
|
||||||
|
// Re-throw to trigger Laravel's retry mechanism with backoff
|
||||||
throw $e;
|
throw $e;
|
||||||
} finally {
|
} finally {
|
||||||
ScheduledTaskDone::dispatch($this->team->id);
|
ScheduledTaskDone::dispatch($this->team->id);
|
||||||
if ($this->task_log) {
|
if ($this->task_log) {
|
||||||
|
$finishedAt = Carbon::now();
|
||||||
|
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
|
||||||
|
|
||||||
$this->task_log->update([
|
$this->task_log->update([
|
||||||
'finished_at' => Carbon::now()->toImmutable(),
|
'finished_at' => $finishedAt->toImmutable(),
|
||||||
|
'duration' => $duration,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the number of seconds to wait before retrying the job.
|
||||||
|
*/
|
||||||
|
public function backoff(): array
|
||||||
|
{
|
||||||
|
return [30, 60, 120]; // 30s, 60s, 120s between retries
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a job failure.
|
||||||
|
*/
|
||||||
|
public function failed(?\Throwable $exception): void
|
||||||
|
{
|
||||||
|
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
|
||||||
|
'job' => 'ScheduledTaskJob',
|
||||||
|
'task_id' => $this->task->uuid,
|
||||||
|
'task_name' => $this->task->name,
|
||||||
|
'server' => $this->server?->name ?? 'unknown',
|
||||||
|
'total_attempts' => $this->attempts(),
|
||||||
|
'error' => $exception?->getMessage(),
|
||||||
|
'trace' => $exception?->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reload execution log from database
|
||||||
|
// When a job times out, failed() is called in a fresh process with the original
|
||||||
|
// queue payload, so $executionId will be null. We need to query for the latest execution.
|
||||||
|
$execution = null;
|
||||||
|
|
||||||
|
// Try to find execution using stored ID first (works for non-timeout failures)
|
||||||
|
if ($this->executionId) {
|
||||||
|
$execution = ScheduledTaskExecution::find($this->executionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no stored ID or not found, query for the most recent execution log for this task
|
||||||
|
if (! $execution) {
|
||||||
|
$execution = ScheduledTaskExecution::query()
|
||||||
|
->where('scheduled_task_id', $this->task->id)
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: check task_log property
|
||||||
|
if (! $execution && $this->task_log) {
|
||||||
|
$execution = $this->task_log;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($execution) {
|
||||||
|
$errorMessage = 'Job permanently failed after '.$this->attempts().' attempts';
|
||||||
|
if ($exception) {
|
||||||
|
$errorMessage .= ': '.$exception->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
$execution->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => $errorMessage,
|
||||||
|
'error_details' => $exception?->getTraceAsString(),
|
||||||
|
'finished_at' => Carbon::now()->toImmutable(),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
Log::channel('scheduled-errors')->warning('Could not find execution log to update', [
|
||||||
|
'execution_id' => $this->executionId,
|
||||||
|
'task_id' => $this->task->uuid,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify team about permanent failure
|
||||||
|
$this->team?->notify(new TaskFailed($this->task, $exception?->getMessage() ?? 'Unknown error'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class ServerManagerJob implements ShouldQueue
|
|||||||
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
|
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
|
||||||
'server_id' => $server->id,
|
'server_id' => $server->id,
|
||||||
'server_name' => $server->name,
|
'server_name' => $server->name,
|
||||||
'error' => $e->getMessage(),
|
'error' => get_class($e).': '.$e->getMessage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -103,7 +103,7 @@ class ServerManagerJob implements ShouldQueue
|
|||||||
Log::channel('scheduled-errors')->error('Error processing server tasks', [
|
Log::channel('scheduled-errors')->error('Error processing server tasks', [
|
||||||
'server_id' => $server->id,
|
'server_id' => $server->id,
|
||||||
'server_name' => $server->name,
|
'server_name' => $server->name,
|
||||||
'error' => $e->getMessage(),
|
'error' => get_class($e).': '.$e->getMessage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,42 @@ class ValidateAndInstallServerJob implements ShouldQueue
|
|||||||
return;
|
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
|
// Check if Docker is installed
|
||||||
$dockerInstalled = $this->server->validateDockerEngine();
|
$dockerInstalled = $this->server->validateDockerEngine();
|
||||||
$dockerComposeInstalled = $this->server->validateDockerCompose();
|
$dockerComposeInstalled = $this->server->validateDockerCompose();
|
||||||
@@ -132,6 +168,9 @@ class ValidateAndInstallServerJob implements ShouldQueue
|
|||||||
if (! $this->server->isBuildServer()) {
|
if (! $this->server->isBuildServer()) {
|
||||||
$proxyShouldRun = CheckProxy::run($this->server, true);
|
$proxyShouldRun = CheckProxy::run($this->server, true);
|
||||||
if ($proxyShouldRun) {
|
if ($proxyShouldRun) {
|
||||||
|
// Ensure networks exist BEFORE dispatching async proxy startup
|
||||||
|
// This prevents race condition where proxy tries to start before networks are created
|
||||||
|
instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
|
||||||
StartProxy::dispatch($this->server);
|
StartProxy::dispatch($this->server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class ActivityMonitor extends Component
|
|||||||
{
|
{
|
||||||
public ?string $header = null;
|
public ?string $header = null;
|
||||||
|
|
||||||
public $activityId;
|
public $activityId = null;
|
||||||
|
|
||||||
public $eventToDispatch = 'activityFinished';
|
public $eventToDispatch = 'activityFinished';
|
||||||
|
|
||||||
@@ -28,12 +28,20 @@ class ActivityMonitor extends Component
|
|||||||
|
|
||||||
protected $listeners = ['activityMonitor' => 'newMonitorActivity'];
|
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->activityId = $activityId;
|
||||||
$this->eventToDispatch = $eventToDispatch;
|
$this->eventToDispatch = $eventToDispatch;
|
||||||
$this->eventData = $eventData;
|
$this->eventData = $eventData;
|
||||||
|
|
||||||
|
// Update header if provided
|
||||||
|
if ($header !== null) {
|
||||||
|
$this->header = $header;
|
||||||
|
}
|
||||||
|
|
||||||
$this->hydrateActivity();
|
$this->hydrateActivity();
|
||||||
|
|
||||||
$this->isPollingActive = true;
|
$this->isPollingActive = true;
|
||||||
@@ -41,9 +49,24 @@ class ActivityMonitor extends Component
|
|||||||
|
|
||||||
public function hydrateActivity()
|
public function hydrateActivity()
|
||||||
{
|
{
|
||||||
|
if ($this->activityId === null) {
|
||||||
|
$this->activity = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->activity = Activity::find($this->activityId);
|
$this->activity = Activity::find($this->activityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updatedActivityId($value)
|
||||||
|
{
|
||||||
|
if ($value) {
|
||||||
|
$this->hydrateActivity();
|
||||||
|
$this->isPollingActive = true;
|
||||||
|
self::$eventDispatched = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function polling()
|
public function polling()
|
||||||
{
|
{
|
||||||
$this->hydrateActivity();
|
$this->hydrateActivity();
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ use Visus\Cuid2\Cuid2;
|
|||||||
|
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
{
|
{
|
||||||
protected $listeners = ['refreshBoardingIndex' => 'validateServer'];
|
protected $listeners = [
|
||||||
|
'refreshBoardingIndex' => 'validateServer',
|
||||||
|
'prerequisitesInstalled' => 'handlePrerequisitesInstalled',
|
||||||
|
];
|
||||||
|
|
||||||
#[\Livewire\Attributes\Url(as: 'step', history: true)]
|
#[\Livewire\Attributes\Url(as: 'step', history: true)]
|
||||||
public string $currentState = 'welcome';
|
public string $currentState = 'welcome';
|
||||||
@@ -76,6 +79,10 @@ class Index extends Component
|
|||||||
|
|
||||||
public ?string $minDockerVersion = null;
|
public ?string $minDockerVersion = null;
|
||||||
|
|
||||||
|
public int $prerequisiteInstallAttempts = 0;
|
||||||
|
|
||||||
|
public int $maxPrerequisiteInstallAttempts = 3;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
if (auth()->user()?->isMember() && auth()->user()->currentTeam()->show_boarding === true) {
|
if (auth()->user()?->isMember() && auth()->user()->currentTeam()->show_boarding === true) {
|
||||||
@@ -320,6 +327,62 @@ class Index extends Component
|
|||||||
return handleError(error: $e, livewire: $this);
|
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 {
|
try {
|
||||||
$dockerVersion = instant_remote_process(["docker version|head -2|grep -i version| awk '{print $2}'"], $this->createdServer, true);
|
$dockerVersion = instant_remote_process(["docker version|head -2|grep -i version| awk '{print $2}'"], $this->createdServer, true);
|
||||||
$dockerVersion = checkMinimumDockerEngineVersion($dockerVersion);
|
$dockerVersion = checkMinimumDockerEngineVersion($dockerVersion);
|
||||||
@@ -347,6 +410,8 @@ class Index extends Component
|
|||||||
}
|
}
|
||||||
$this->createdServer->proxy->type = $proxyType;
|
$this->createdServer->proxy->type = $proxyType;
|
||||||
$this->createdServer->proxy->status = 'exited';
|
$this->createdServer->proxy->status = 'exited';
|
||||||
|
$this->createdServer->proxy->last_saved_settings = null;
|
||||||
|
$this->createdServer->proxy->last_applied_settings = null;
|
||||||
$this->createdServer->save();
|
$this->createdServer->save();
|
||||||
$this->getProjects();
|
$this->getProjects();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ class Discord extends Component
|
|||||||
#[Validate(['boolean'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $serverPatchDiscordNotifications = false;
|
public bool $serverPatchDiscordNotifications = false;
|
||||||
|
|
||||||
|
#[Validate(['boolean'])]
|
||||||
|
public bool $traefikOutdatedDiscordNotifications = true;
|
||||||
|
|
||||||
#[Validate(['boolean'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $discordPingEnabled = true;
|
public bool $discordPingEnabled = true;
|
||||||
|
|
||||||
@@ -98,6 +101,7 @@ class Discord extends Component
|
|||||||
$this->settings->server_reachable_discord_notifications = $this->serverReachableDiscordNotifications;
|
$this->settings->server_reachable_discord_notifications = $this->serverReachableDiscordNotifications;
|
||||||
$this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications;
|
$this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications;
|
||||||
$this->settings->server_patch_discord_notifications = $this->serverPatchDiscordNotifications;
|
$this->settings->server_patch_discord_notifications = $this->serverPatchDiscordNotifications;
|
||||||
|
$this->settings->traefik_outdated_discord_notifications = $this->traefikOutdatedDiscordNotifications;
|
||||||
|
|
||||||
$this->settings->discord_ping_enabled = $this->discordPingEnabled;
|
$this->settings->discord_ping_enabled = $this->discordPingEnabled;
|
||||||
|
|
||||||
@@ -120,6 +124,7 @@ class Discord extends Component
|
|||||||
$this->serverReachableDiscordNotifications = $this->settings->server_reachable_discord_notifications;
|
$this->serverReachableDiscordNotifications = $this->settings->server_reachable_discord_notifications;
|
||||||
$this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications;
|
$this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications;
|
||||||
$this->serverPatchDiscordNotifications = $this->settings->server_patch_discord_notifications;
|
$this->serverPatchDiscordNotifications = $this->settings->server_patch_discord_notifications;
|
||||||
|
$this->traefikOutdatedDiscordNotifications = $this->settings->traefik_outdated_discord_notifications;
|
||||||
|
|
||||||
$this->discordPingEnabled = $this->settings->discord_ping_enabled;
|
$this->discordPingEnabled = $this->settings->discord_ping_enabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ class Email extends Component
|
|||||||
#[Validate(['boolean'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $serverPatchEmailNotifications = false;
|
public bool $serverPatchEmailNotifications = false;
|
||||||
|
|
||||||
|
#[Validate(['boolean'])]
|
||||||
|
public bool $traefikOutdatedEmailNotifications = true;
|
||||||
|
|
||||||
#[Validate(['nullable', 'email'])]
|
#[Validate(['nullable', 'email'])]
|
||||||
public ?string $testEmailAddress = null;
|
public ?string $testEmailAddress = null;
|
||||||
|
|
||||||
@@ -155,6 +158,7 @@ class Email extends Component
|
|||||||
$this->settings->server_reachable_email_notifications = $this->serverReachableEmailNotifications;
|
$this->settings->server_reachable_email_notifications = $this->serverReachableEmailNotifications;
|
||||||
$this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications;
|
$this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications;
|
||||||
$this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications;
|
$this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications;
|
||||||
|
$this->settings->traefik_outdated_email_notifications = $this->traefikOutdatedEmailNotifications;
|
||||||
$this->settings->save();
|
$this->settings->save();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
@@ -187,6 +191,7 @@ class Email extends Component
|
|||||||
$this->serverReachableEmailNotifications = $this->settings->server_reachable_email_notifications;
|
$this->serverReachableEmailNotifications = $this->settings->server_reachable_email_notifications;
|
||||||
$this->serverUnreachableEmailNotifications = $this->settings->server_unreachable_email_notifications;
|
$this->serverUnreachableEmailNotifications = $this->settings->server_unreachable_email_notifications;
|
||||||
$this->serverPatchEmailNotifications = $this->settings->server_patch_email_notifications;
|
$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'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $serverPatchPushoverNotifications = false;
|
public bool $serverPatchPushoverNotifications = false;
|
||||||
|
|
||||||
|
#[Validate(['boolean'])]
|
||||||
|
public bool $traefikOutdatedPushoverNotifications = true;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -104,6 +107,7 @@ class Pushover extends Component
|
|||||||
$this->settings->server_reachable_pushover_notifications = $this->serverReachablePushoverNotifications;
|
$this->settings->server_reachable_pushover_notifications = $this->serverReachablePushoverNotifications;
|
||||||
$this->settings->server_unreachable_pushover_notifications = $this->serverUnreachablePushoverNotifications;
|
$this->settings->server_unreachable_pushover_notifications = $this->serverUnreachablePushoverNotifications;
|
||||||
$this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications;
|
$this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications;
|
||||||
|
$this->settings->traefik_outdated_pushover_notifications = $this->traefikOutdatedPushoverNotifications;
|
||||||
|
|
||||||
$this->settings->save();
|
$this->settings->save();
|
||||||
refreshSession();
|
refreshSession();
|
||||||
@@ -125,6 +129,7 @@ class Pushover extends Component
|
|||||||
$this->serverReachablePushoverNotifications = $this->settings->server_reachable_pushover_notifications;
|
$this->serverReachablePushoverNotifications = $this->settings->server_reachable_pushover_notifications;
|
||||||
$this->serverUnreachablePushoverNotifications = $this->settings->server_unreachable_pushover_notifications;
|
$this->serverUnreachablePushoverNotifications = $this->settings->server_unreachable_pushover_notifications;
|
||||||
$this->serverPatchPushoverNotifications = $this->settings->server_patch_pushover_notifications;
|
$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'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $serverPatchSlackNotifications = false;
|
public bool $serverPatchSlackNotifications = false;
|
||||||
|
|
||||||
|
#[Validate(['boolean'])]
|
||||||
|
public bool $traefikOutdatedSlackNotifications = true;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -100,6 +103,7 @@ class Slack extends Component
|
|||||||
$this->settings->server_reachable_slack_notifications = $this->serverReachableSlackNotifications;
|
$this->settings->server_reachable_slack_notifications = $this->serverReachableSlackNotifications;
|
||||||
$this->settings->server_unreachable_slack_notifications = $this->serverUnreachableSlackNotifications;
|
$this->settings->server_unreachable_slack_notifications = $this->serverUnreachableSlackNotifications;
|
||||||
$this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications;
|
$this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications;
|
||||||
|
$this->settings->traefik_outdated_slack_notifications = $this->traefikOutdatedSlackNotifications;
|
||||||
|
|
||||||
$this->settings->save();
|
$this->settings->save();
|
||||||
refreshSession();
|
refreshSession();
|
||||||
@@ -120,6 +124,7 @@ class Slack extends Component
|
|||||||
$this->serverReachableSlackNotifications = $this->settings->server_reachable_slack_notifications;
|
$this->serverReachableSlackNotifications = $this->settings->server_reachable_slack_notifications;
|
||||||
$this->serverUnreachableSlackNotifications = $this->settings->server_unreachable_slack_notifications;
|
$this->serverUnreachableSlackNotifications = $this->settings->server_unreachable_slack_notifications;
|
||||||
$this->serverPatchSlackNotifications = $this->settings->server_patch_slack_notifications;
|
$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'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $serverPatchTelegramNotifications = false;
|
public bool $serverPatchTelegramNotifications = false;
|
||||||
|
|
||||||
|
#[Validate(['boolean'])]
|
||||||
|
public bool $traefikOutdatedTelegramNotifications = true;
|
||||||
|
|
||||||
#[Validate(['nullable', 'string'])]
|
#[Validate(['nullable', 'string'])]
|
||||||
public ?string $telegramNotificationsDeploymentSuccessThreadId = null;
|
public ?string $telegramNotificationsDeploymentSuccessThreadId = null;
|
||||||
|
|
||||||
@@ -109,6 +112,9 @@ class Telegram extends Component
|
|||||||
#[Validate(['nullable', 'string'])]
|
#[Validate(['nullable', 'string'])]
|
||||||
public ?string $telegramNotificationsServerPatchThreadId = null;
|
public ?string $telegramNotificationsServerPatchThreadId = null;
|
||||||
|
|
||||||
|
#[Validate(['nullable', 'string'])]
|
||||||
|
public ?string $telegramNotificationsTraefikOutdatedThreadId = null;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -143,6 +149,7 @@ class Telegram extends Component
|
|||||||
$this->settings->server_reachable_telegram_notifications = $this->serverReachableTelegramNotifications;
|
$this->settings->server_reachable_telegram_notifications = $this->serverReachableTelegramNotifications;
|
||||||
$this->settings->server_unreachable_telegram_notifications = $this->serverUnreachableTelegramNotifications;
|
$this->settings->server_unreachable_telegram_notifications = $this->serverUnreachableTelegramNotifications;
|
||||||
$this->settings->server_patch_telegram_notifications = $this->serverPatchTelegramNotifications;
|
$this->settings->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_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId;
|
||||||
$this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId;
|
$this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId;
|
||||||
@@ -157,6 +164,7 @@ class Telegram extends Component
|
|||||||
$this->settings->telegram_notifications_server_reachable_thread_id = $this->telegramNotificationsServerReachableThreadId;
|
$this->settings->telegram_notifications_server_reachable_thread_id = $this->telegramNotificationsServerReachableThreadId;
|
||||||
$this->settings->telegram_notifications_server_unreachable_thread_id = $this->telegramNotificationsServerUnreachableThreadId;
|
$this->settings->telegram_notifications_server_unreachable_thread_id = $this->telegramNotificationsServerUnreachableThreadId;
|
||||||
$this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId;
|
$this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId;
|
||||||
|
$this->settings->telegram_notifications_traefik_outdated_thread_id = $this->telegramNotificationsTraefikOutdatedThreadId;
|
||||||
|
|
||||||
$this->settings->save();
|
$this->settings->save();
|
||||||
} else {
|
} else {
|
||||||
@@ -177,6 +185,7 @@ class Telegram extends Component
|
|||||||
$this->serverReachableTelegramNotifications = $this->settings->server_reachable_telegram_notifications;
|
$this->serverReachableTelegramNotifications = $this->settings->server_reachable_telegram_notifications;
|
||||||
$this->serverUnreachableTelegramNotifications = $this->settings->server_unreachable_telegram_notifications;
|
$this->serverUnreachableTelegramNotifications = $this->settings->server_unreachable_telegram_notifications;
|
||||||
$this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications;
|
$this->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->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id;
|
||||||
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
|
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
|
||||||
@@ -191,6 +200,7 @@ class Telegram extends Component
|
|||||||
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
|
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
|
||||||
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
|
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
|
||||||
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
|
$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'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $serverPatchWebhookNotifications = false;
|
public bool $serverPatchWebhookNotifications = false;
|
||||||
|
|
||||||
|
#[Validate(['boolean'])]
|
||||||
|
public bool $traefikOutdatedWebhookNotifications = true;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -95,6 +98,7 @@ class Webhook extends Component
|
|||||||
$this->settings->server_reachable_webhook_notifications = $this->serverReachableWebhookNotifications;
|
$this->settings->server_reachable_webhook_notifications = $this->serverReachableWebhookNotifications;
|
||||||
$this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications;
|
$this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications;
|
||||||
$this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications;
|
$this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications;
|
||||||
|
$this->settings->traefik_outdated_webhook_notifications = $this->traefikOutdatedWebhookNotifications;
|
||||||
|
|
||||||
$this->settings->save();
|
$this->settings->save();
|
||||||
refreshSession();
|
refreshSession();
|
||||||
@@ -115,6 +119,7 @@ class Webhook extends Component
|
|||||||
$this->serverReachableWebhookNotifications = $this->settings->server_reachable_webhook_notifications;
|
$this->serverReachableWebhookNotifications = $this->settings->server_reachable_webhook_notifications;
|
||||||
$this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications;
|
$this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications;
|
||||||
$this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications;
|
$this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications;
|
||||||
|
$this->traefikOutdatedWebhookNotifications = $this->settings->traefik_outdated_webhook_notifications;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ class Advanced extends Component
|
|||||||
#[Validate(['boolean'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $disableBuildCache = false;
|
public bool $disableBuildCache = false;
|
||||||
|
|
||||||
|
#[Validate(['boolean'])]
|
||||||
|
public bool $injectBuildArgsToDockerfile = true;
|
||||||
|
|
||||||
|
#[Validate(['boolean'])]
|
||||||
|
public bool $includeSourceCommitInBuild = false;
|
||||||
|
|
||||||
#[Validate(['boolean'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $isLogDrainEnabled = false;
|
public bool $isLogDrainEnabled = false;
|
||||||
|
|
||||||
@@ -110,6 +116,8 @@ class Advanced extends Component
|
|||||||
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
|
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
|
||||||
$this->application->settings->connect_to_docker_network = $this->isConnectToDockerNetworkEnabled;
|
$this->application->settings->connect_to_docker_network = $this->isConnectToDockerNetworkEnabled;
|
||||||
$this->application->settings->disable_build_cache = $this->disableBuildCache;
|
$this->application->settings->disable_build_cache = $this->disableBuildCache;
|
||||||
|
$this->application->settings->inject_build_args_to_dockerfile = $this->injectBuildArgsToDockerfile;
|
||||||
|
$this->application->settings->include_source_commit_in_build = $this->includeSourceCommitInBuild;
|
||||||
$this->application->settings->save();
|
$this->application->settings->save();
|
||||||
} else {
|
} else {
|
||||||
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
|
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
|
||||||
@@ -134,6 +142,8 @@ class Advanced extends Component
|
|||||||
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
|
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
|
||||||
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
|
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
|
||||||
$this->disableBuildCache = $this->application->settings->disable_build_cache;
|
$this->disableBuildCache = $this->application->settings->disable_build_cache;
|
||||||
|
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
|
||||||
|
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -641,8 +641,6 @@ class General extends Component
|
|||||||
$this->application->settings->is_static = false;
|
$this->application->settings->is_static = false;
|
||||||
$this->application->settings->save();
|
$this->application->settings->save();
|
||||||
} else {
|
} else {
|
||||||
$this->portsExposes = '3000';
|
|
||||||
$this->application->ports_exposes = '3000';
|
|
||||||
$this->resetDefaultLabels(false);
|
$this->resetDefaultLabels(false);
|
||||||
}
|
}
|
||||||
if ($this->buildPack === 'dockercompose') {
|
if ($this->buildPack === 'dockercompose') {
|
||||||
@@ -655,18 +653,6 @@ class General extends Component
|
|||||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
// User doesn't have update permission, just continue without saving
|
// 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') {
|
if ($this->buildPack === 'static') {
|
||||||
$this->portsExposes = '80';
|
$this->portsExposes = '80';
|
||||||
@@ -1000,4 +986,60 @@ class General extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDetectedPortInfoProperty(): ?array
|
||||||
|
{
|
||||||
|
$detectedPort = $this->application->detectPortFromEnvironment();
|
||||||
|
|
||||||
|
if (! $detectedPort) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$portsExposesArray = $this->application->ports_exposes_array;
|
||||||
|
$isMatch = in_array($detectedPort, $portsExposesArray);
|
||||||
|
$isEmpty = empty($portsExposesArray);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'port' => $detectedPort,
|
||||||
|
'matches' => $isMatch,
|
||||||
|
'isEmpty' => $isEmpty,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDockerComposeBuildCommandPreviewProperty(): string
|
||||||
|
{
|
||||||
|
if (! $this->dockerComposeCustomBuildCommand) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize baseDirectory to prevent double slashes (e.g., when baseDirectory is '/')
|
||||||
|
$normalizedBase = $this->baseDirectory === '/' ? '' : rtrim($this->baseDirectory, '/');
|
||||||
|
|
||||||
|
// Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
|
||||||
|
// Actual deployment uses absolute path: /artifacts/{deployment_uuid}{base_directory}{docker_compose_location}
|
||||||
|
// Build-time env path references ApplicationDeploymentJob::BUILD_TIME_ENV_PATH as source of truth
|
||||||
|
return injectDockerComposeFlags(
|
||||||
|
$this->dockerComposeCustomBuildCommand,
|
||||||
|
".{$normalizedBase}{$this->dockerComposeLocation}",
|
||||||
|
\App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDockerComposeStartCommandPreviewProperty(): string
|
||||||
|
{
|
||||||
|
if (! $this->dockerComposeCustomStartCommand) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize baseDirectory to prevent double slashes (e.g., when baseDirectory is '/')
|
||||||
|
$normalizedBase = $this->baseDirectory === '/' ? '' : rtrim($this->baseDirectory, '/');
|
||||||
|
|
||||||
|
// Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
|
||||||
|
// Placeholder {workdir}/.env shows it's the workdir .env file (runtime env, not build-time)
|
||||||
|
return injectDockerComposeFlags(
|
||||||
|
$this->dockerComposeCustomStartCommand,
|
||||||
|
".{$normalizedBase}{$this->dockerComposeLocation}",
|
||||||
|
'{workdir}/.env'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,11 +101,18 @@ class Heading extends Component
|
|||||||
force_rebuild: $force_rebuild,
|
force_rebuild: $force_rebuild,
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
$this->dispatch('error', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset restart count on successful deployment
|
||||||
|
$this->application->update([
|
||||||
|
'restart_count' => 0,
|
||||||
|
'last_restart_at' => null,
|
||||||
|
'last_restart_type' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->redirectRoute('project.application.deployment.show', [
|
return $this->redirectRoute('project.application.deployment.show', [
|
||||||
'project_uuid' => $this->parameters['project_uuid'],
|
'project_uuid' => $this->parameters['project_uuid'],
|
||||||
'application_uuid' => $this->parameters['application_uuid'],
|
'application_uuid' => $this->parameters['application_uuid'],
|
||||||
@@ -137,6 +144,7 @@ class Heading extends Component
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->setDeploymentUuid();
|
$this->setDeploymentUuid();
|
||||||
$result = queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $this->application,
|
application: $this->application,
|
||||||
@@ -149,6 +157,13 @@ class Heading extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset restart count on manual restart
|
||||||
|
$this->application->update([
|
||||||
|
'restart_count' => 0,
|
||||||
|
'last_restart_at' => now(),
|
||||||
|
'last_restart_type' => 'manual',
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->redirectRoute('project.application.deployment.show', [
|
return $this->redirectRoute('project.application.deployment.show', [
|
||||||
'project_uuid' => $this->parameters['project_uuid'],
|
'project_uuid' => $this->parameters['project_uuid'],
|
||||||
'application_uuid' => $this->parameters['application_uuid'],
|
'application_uuid' => $this->parameters['application_uuid'],
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class BackupEdit extends Component
|
|||||||
#[Validate(['required', 'boolean'])]
|
#[Validate(['required', 'boolean'])]
|
||||||
public bool $dumpAll = false;
|
public bool $dumpAll = false;
|
||||||
|
|
||||||
#[Validate(['required', 'int', 'min:1', 'max:36000'])]
|
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
|
||||||
public int $timeout = 3600;
|
public int $timeout = 3600;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
@@ -107,6 +107,25 @@ class BackupEdit extends Component
|
|||||||
$this->backup->save_s3 = $this->saveS3;
|
$this->backup->save_s3 = $this->saveS3;
|
||||||
$this->backup->disable_local_backup = $this->disableLocalBackup;
|
$this->backup->disable_local_backup = $this->disableLocalBackup;
|
||||||
$this->backup->s3_storage_id = $this->s3StorageId;
|
$this->backup->s3_storage_id = $this->s3StorageId;
|
||||||
|
|
||||||
|
// Validate databases_to_backup to prevent command injection
|
||||||
|
if (filled($this->databasesToBackup)) {
|
||||||
|
$databases = str($this->databasesToBackup)->explode(',');
|
||||||
|
foreach ($databases as $index => $db) {
|
||||||
|
$dbName = trim($db);
|
||||||
|
try {
|
||||||
|
validateShellSafePath($dbName, 'database name');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Provide specific error message indicating which database failed validation
|
||||||
|
$position = $index + 1;
|
||||||
|
throw new \Exception(
|
||||||
|
"Database #{$position} ('{$dbName}') validation failed: ".
|
||||||
|
$e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->backup->databases_to_backup = $this->databasesToBackup;
|
$this->backup->databases_to_backup = $this->databasesToBackup;
|
||||||
$this->backup->dump_all = $this->dumpAll;
|
$this->backup->dump_all = $this->dumpAll;
|
||||||
$this->backup->timeout = $this->timeout;
|
$this->backup->timeout = $this->timeout;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Project\Database;
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
|
use App\Models\S3Storage;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -12,6 +13,92 @@ class Import extends Component
|
|||||||
{
|
{
|
||||||
use AuthorizesRequests;
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string is safe for use as an S3 bucket name.
|
||||||
|
* Allows alphanumerics, dots, dashes, and underscores.
|
||||||
|
*/
|
||||||
|
private function validateBucketName(string $bucket): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string is safe for use as an S3 path.
|
||||||
|
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
|
||||||
|
*/
|
||||||
|
private function validateS3Path(string $path): bool
|
||||||
|
{
|
||||||
|
// Must not be empty
|
||||||
|
if (empty($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not contain dangerous shell metacharacters or command injection patterns
|
||||||
|
$dangerousPatterns = [
|
||||||
|
'..', // Directory traversal
|
||||||
|
'$(', // Command substitution
|
||||||
|
'`', // Backtick command substitution
|
||||||
|
'|', // Pipe
|
||||||
|
';', // Command separator
|
||||||
|
'&', // Background/AND
|
||||||
|
'>', // Redirect
|
||||||
|
'<', // Redirect
|
||||||
|
"\n", // Newline
|
||||||
|
"\r", // Carriage return
|
||||||
|
"\0", // Null byte
|
||||||
|
"'", // Single quote
|
||||||
|
'"', // Double quote
|
||||||
|
'\\', // Backslash
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($dangerousPatterns as $pattern) {
|
||||||
|
if (str_contains($path, $pattern)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
|
||||||
|
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string is safe for use as a file path on the server.
|
||||||
|
*/
|
||||||
|
private function validateServerPath(string $path): bool
|
||||||
|
{
|
||||||
|
// Must be an absolute path
|
||||||
|
if (! str_starts_with($path, '/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not contain dangerous shell metacharacters or command injection patterns
|
||||||
|
$dangerousPatterns = [
|
||||||
|
'..', // Directory traversal
|
||||||
|
'$(', // Command substitution
|
||||||
|
'`', // Backtick command substitution
|
||||||
|
'|', // Pipe
|
||||||
|
';', // Command separator
|
||||||
|
'&', // Background/AND
|
||||||
|
'>', // Redirect
|
||||||
|
'<', // Redirect
|
||||||
|
"\n", // Newline
|
||||||
|
"\r", // Carriage return
|
||||||
|
"\0", // Null byte
|
||||||
|
"'", // Single quote
|
||||||
|
'"', // Double quote
|
||||||
|
'\\', // Backslash
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($dangerousPatterns as $pattern) {
|
||||||
|
if (str_contains($path, $pattern)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
|
||||||
|
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
public bool $unsupported = false;
|
public bool $unsupported = false;
|
||||||
|
|
||||||
public $resource;
|
public $resource;
|
||||||
@@ -46,6 +133,8 @@ class Import extends Component
|
|||||||
|
|
||||||
public string $customLocation = '';
|
public string $customLocation = '';
|
||||||
|
|
||||||
|
public ?int $activityId = null;
|
||||||
|
|
||||||
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
|
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
|
||||||
|
|
||||||
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||||
@@ -54,22 +143,35 @@ class Import extends Component
|
|||||||
|
|
||||||
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
||||||
|
|
||||||
|
// S3 Restore properties
|
||||||
|
public $availableS3Storages = [];
|
||||||
|
|
||||||
|
public ?int $s3StorageId = null;
|
||||||
|
|
||||||
|
public string $s3Path = '';
|
||||||
|
|
||||||
|
public ?int $s3FileSize = null;
|
||||||
|
|
||||||
public function getListeners()
|
public function getListeners()
|
||||||
{
|
{
|
||||||
$userId = Auth::id();
|
$userId = Auth::id();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||||
|
'slideOverClosed' => 'resetActivityId',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resetActivityId()
|
||||||
|
{
|
||||||
|
$this->activityId = null;
|
||||||
|
}
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
if (isDev()) {
|
|
||||||
$this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz';
|
|
||||||
}
|
|
||||||
$this->parameters = get_route_parameters();
|
$this->parameters = get_route_parameters();
|
||||||
$this->getContainers();
|
$this->getContainers();
|
||||||
|
$this->loadAvailableS3Storages();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedDumpAll($value)
|
public function updatedDumpAll($value)
|
||||||
@@ -152,8 +254,16 @@ EOD;
|
|||||||
public function checkFile()
|
public function checkFile()
|
||||||
{
|
{
|
||||||
if (filled($this->customLocation)) {
|
if (filled($this->customLocation)) {
|
||||||
|
// Validate the custom location to prevent command injection
|
||||||
|
if (! $this->validateServerPath($this->customLocation)) {
|
||||||
|
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = instant_remote_process(["ls -l {$this->customLocation}"], $this->server, throwError: false);
|
$escapedPath = escapeshellarg($this->customLocation);
|
||||||
|
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
|
||||||
if (blank($result)) {
|
if (blank($result)) {
|
||||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||||
|
|
||||||
@@ -179,59 +289,35 @@ EOD;
|
|||||||
try {
|
try {
|
||||||
$this->importRunning = true;
|
$this->importRunning = true;
|
||||||
$this->importCommands = [];
|
$this->importCommands = [];
|
||||||
if (filled($this->customLocation)) {
|
$backupFileName = "upload/{$this->resource->uuid}/restore";
|
||||||
$backupFileName = '/tmp/restore_'.$this->resource->uuid;
|
|
||||||
$this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$backupFileName}";
|
|
||||||
$tmpPath = $backupFileName;
|
|
||||||
} else {
|
|
||||||
$backupFileName = "upload/{$this->resource->uuid}/restore";
|
|
||||||
$path = Storage::path($backupFileName);
|
|
||||||
if (! Storage::exists($backupFileName)) {
|
|
||||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
|
||||||
|
|
||||||
return;
|
// Check if an uploaded file exists first (takes priority over custom location)
|
||||||
}
|
if (Storage::exists($backupFileName)) {
|
||||||
|
$path = Storage::path($backupFileName);
|
||||||
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid;
|
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid;
|
||||||
instant_scp($path, $tmpPath, $this->server);
|
instant_scp($path, $tmpPath, $this->server);
|
||||||
Storage::delete($backupFileName);
|
Storage::delete($backupFileName);
|
||||||
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
||||||
|
} elseif (filled($this->customLocation)) {
|
||||||
|
// Validate the custom location to prevent command injection
|
||||||
|
if (! $this->validateServerPath($this->customLocation)) {
|
||||||
|
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tmpPath = '/tmp/restore_'.$this->resource->uuid;
|
||||||
|
$escapedCustomLocation = escapeshellarg($this->customLocation);
|
||||||
|
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
|
||||||
|
} else {
|
||||||
|
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy the restore command to a script file
|
// Copy the restore command to a script file
|
||||||
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
|
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
|
||||||
|
|
||||||
switch ($this->resource->getMorphClass()) {
|
$restoreCommand = $this->buildRestoreCommand($tmpPath);
|
||||||
case \App\Models\StandaloneMariadb::class:
|
|
||||||
$restoreCommand = $this->mariadbRestoreCommand;
|
|
||||||
if ($this->dumpAll) {
|
|
||||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
|
|
||||||
} else {
|
|
||||||
$restoreCommand .= " < {$tmpPath}";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case \App\Models\StandaloneMysql::class:
|
|
||||||
$restoreCommand = $this->mysqlRestoreCommand;
|
|
||||||
if ($this->dumpAll) {
|
|
||||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
|
|
||||||
} else {
|
|
||||||
$restoreCommand .= " < {$tmpPath}";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case \App\Models\StandalonePostgresql::class:
|
|
||||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
|
||||||
if ($this->dumpAll) {
|
|
||||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
|
|
||||||
} else {
|
|
||||||
$restoreCommand .= " {$tmpPath}";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case \App\Models\StandaloneMongodb::class:
|
|
||||||
$restoreCommand = $this->mongodbRestoreCommand;
|
|
||||||
if ($this->dumpAll === false) {
|
|
||||||
$restoreCommand .= "{$tmpPath}";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||||
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||||
@@ -248,7 +334,13 @@ EOD;
|
|||||||
'container' => $this->container,
|
'container' => $this->container,
|
||||||
'serverId' => $this->server->id,
|
'serverId' => $this->server->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Track the activity ID
|
||||||
|
$this->activityId = $activity->id;
|
||||||
|
|
||||||
|
// Dispatch activity to the monitor and open slide-over
|
||||||
$this->dispatch('activityMonitor', $activity->id);
|
$this->dispatch('activityMonitor', $activity->id);
|
||||||
|
$this->dispatch('databaserestore');
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
@@ -257,4 +349,267 @@ EOD;
|
|||||||
$this->importCommands = [];
|
$this->importCommands = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function loadAvailableS3Storages()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
|
||||||
|
->where('is_usable', true)
|
||||||
|
->get();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->availableS3Storages = collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedS3Path($value)
|
||||||
|
{
|
||||||
|
// Reset validation state when path changes
|
||||||
|
$this->s3FileSize = null;
|
||||||
|
|
||||||
|
// Ensure path starts with a slash
|
||||||
|
if ($value !== null && $value !== '') {
|
||||||
|
$this->s3Path = str($value)->trim()->start('/')->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedS3StorageId()
|
||||||
|
{
|
||||||
|
// Reset validation state when storage changes
|
||||||
|
$this->s3FileSize = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkS3File()
|
||||||
|
{
|
||||||
|
if (! $this->s3StorageId) {
|
||||||
|
$this->dispatch('error', 'Please select an S3 storage.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blank($this->s3Path)) {
|
||||||
|
$this->dispatch('error', 'Please provide an S3 path.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the path (remove leading slash if present)
|
||||||
|
$cleanPath = ltrim($this->s3Path, '/');
|
||||||
|
|
||||||
|
// Validate the S3 path early to prevent command injection in subsequent operations
|
||||||
|
if (! $this->validateS3Path($cleanPath)) {
|
||||||
|
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||||
|
|
||||||
|
// Validate bucket name early
|
||||||
|
if (! $this->validateBucketName($s3Storage->bucket)) {
|
||||||
|
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
$s3Storage->testConnection();
|
||||||
|
|
||||||
|
// Build S3 disk configuration
|
||||||
|
$disk = Storage::build([
|
||||||
|
'driver' => 's3',
|
||||||
|
'region' => $s3Storage->region,
|
||||||
|
'key' => $s3Storage->key,
|
||||||
|
'secret' => $s3Storage->secret,
|
||||||
|
'bucket' => $s3Storage->bucket,
|
||||||
|
'endpoint' => $s3Storage->endpoint,
|
||||||
|
'use_path_style_endpoint' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if (! $disk->exists($cleanPath)) {
|
||||||
|
$this->dispatch('error', 'File not found in S3. Please check the path.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
$this->s3FileSize = $disk->size($cleanPath);
|
||||||
|
|
||||||
|
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->s3FileSize = null;
|
||||||
|
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreFromS3()
|
||||||
|
{
|
||||||
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
|
if (! $this->s3StorageId || blank($this->s3Path)) {
|
||||||
|
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_null($this->s3FileSize)) {
|
||||||
|
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->importRunning = true;
|
||||||
|
|
||||||
|
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||||
|
|
||||||
|
$key = $s3Storage->key;
|
||||||
|
$secret = $s3Storage->secret;
|
||||||
|
$bucket = $s3Storage->bucket;
|
||||||
|
$endpoint = $s3Storage->endpoint;
|
||||||
|
|
||||||
|
// Validate bucket name to prevent command injection
|
||||||
|
if (! $this->validateBucketName($bucket)) {
|
||||||
|
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the S3 path
|
||||||
|
$cleanPath = ltrim($this->s3Path, '/');
|
||||||
|
|
||||||
|
// Validate the S3 path to prevent command injection
|
||||||
|
if (! $this->validateS3Path($cleanPath)) {
|
||||||
|
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get helper image
|
||||||
|
$helperImage = config('constants.coolify.helper_image');
|
||||||
|
$latestVersion = getHelperVersion();
|
||||||
|
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||||||
|
|
||||||
|
// Get the database destination network
|
||||||
|
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
|
||||||
|
|
||||||
|
// Generate unique names for this operation
|
||||||
|
$containerName = "s3-restore-{$this->resource->uuid}";
|
||||||
|
$helperTmpPath = '/tmp/'.basename($cleanPath);
|
||||||
|
$serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath);
|
||||||
|
$containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath);
|
||||||
|
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
|
||||||
|
|
||||||
|
// Prepare all commands in sequence
|
||||||
|
$commands = [];
|
||||||
|
|
||||||
|
// 1. Clean up any existing helper container and temp files from previous runs
|
||||||
|
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||||
|
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
|
||||||
|
$commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
|
||||||
|
|
||||||
|
// 2. Start helper container on the database network
|
||||||
|
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
|
||||||
|
|
||||||
|
// 3. Configure S3 access in helper container
|
||||||
|
$escapedEndpoint = escapeshellarg($endpoint);
|
||||||
|
$escapedKey = escapeshellarg($key);
|
||||||
|
$escapedSecret = escapeshellarg($secret);
|
||||||
|
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||||
|
|
||||||
|
// 4. Check file exists in S3 (bucket and path already validated above)
|
||||||
|
$escapedBucket = escapeshellarg($bucket);
|
||||||
|
$escapedCleanPath = escapeshellarg($cleanPath);
|
||||||
|
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
|
||||||
|
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
|
||||||
|
|
||||||
|
// 5. Download from S3 to helper container (progress shown by default)
|
||||||
|
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
|
||||||
|
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
|
||||||
|
|
||||||
|
// 6. Copy from helper to server, then immediately to database container
|
||||||
|
$commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
|
||||||
|
$commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
|
||||||
|
|
||||||
|
// 7. Cleanup helper container and server temp file immediately (no longer needed)
|
||||||
|
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||||
|
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
|
||||||
|
|
||||||
|
// 8. Build and execute restore command inside database container
|
||||||
|
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
|
||||||
|
|
||||||
|
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||||
|
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||||
|
$commands[] = "chmod +x {$scriptPath}";
|
||||||
|
$commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
||||||
|
|
||||||
|
// 9. Execute restore and cleanup temp files immediately after completion
|
||||||
|
$commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
|
||||||
|
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||||
|
|
||||||
|
// Execute all commands with cleanup event (as safety net for edge cases)
|
||||||
|
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
|
||||||
|
'containerName' => $containerName,
|
||||||
|
'serverTmpPath' => $serverTmpPath,
|
||||||
|
'scriptPath' => $scriptPath,
|
||||||
|
'containerTmpPath' => $containerTmpPath,
|
||||||
|
'container' => $this->container,
|
||||||
|
'serverId' => $this->server->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Track the activity ID
|
||||||
|
$this->activityId = $activity->id;
|
||||||
|
|
||||||
|
// Dispatch activity to the monitor and open slide-over
|
||||||
|
$this->dispatch('activityMonitor', $activity->id);
|
||||||
|
$this->dispatch('databaserestore');
|
||||||
|
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->importRunning = false;
|
||||||
|
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildRestoreCommand(string $tmpPath): string
|
||||||
|
{
|
||||||
|
switch ($this->resource->getMorphClass()) {
|
||||||
|
case \App\Models\StandaloneMariadb::class:
|
||||||
|
$restoreCommand = $this->mariadbRestoreCommand;
|
||||||
|
if ($this->dumpAll) {
|
||||||
|
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
|
||||||
|
} else {
|
||||||
|
$restoreCommand .= " < {$tmpPath}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case \App\Models\StandaloneMysql::class:
|
||||||
|
$restoreCommand = $this->mysqlRestoreCommand;
|
||||||
|
if ($this->dumpAll) {
|
||||||
|
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
|
||||||
|
} else {
|
||||||
|
$restoreCommand .= " < {$tmpPath}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case \App\Models\StandalonePostgresql::class:
|
||||||
|
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||||
|
if ($this->dumpAll) {
|
||||||
|
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
|
||||||
|
} else {
|
||||||
|
$restoreCommand .= " {$tmpPath}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case \App\Models\StandaloneMongodb::class:
|
||||||
|
$restoreCommand = $this->mongodbRestoreCommand;
|
||||||
|
if ($this->dumpAll === false) {
|
||||||
|
$restoreCommand .= "{$tmpPath}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$restoreCommand = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $restoreCommand;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,12 +328,15 @@ class General extends Component
|
|||||||
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||||
|
|
||||||
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
|
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
|
||||||
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
|
|
||||||
$delete_command = "rm -f $old_file_path";
|
|
||||||
try {
|
try {
|
||||||
|
// Validate and escape filename to prevent command injection
|
||||||
|
validateShellSafePath($oldScript['filename'], 'init script filename');
|
||||||
|
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
|
||||||
|
$escapedOldPath = escapeshellarg($old_file_path);
|
||||||
|
$delete_command = "rm -f {$escapedOldPath}";
|
||||||
instant_remote_process([$delete_command], $this->server);
|
instant_remote_process([$delete_command], $this->server);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage());
|
$this->dispatch('error', $e->getMessage());
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -370,13 +373,17 @@ class General extends Component
|
|||||||
if ($found) {
|
if ($found) {
|
||||||
$container_name = $this->database->uuid;
|
$container_name = $this->database->uuid;
|
||||||
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||||
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
|
|
||||||
|
|
||||||
$command = "rm -f $file_path";
|
|
||||||
try {
|
try {
|
||||||
|
// Validate and escape filename to prevent command injection
|
||||||
|
validateShellSafePath($script['filename'], 'init script filename');
|
||||||
|
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
|
||||||
|
$escapedPath = escapeshellarg($file_path);
|
||||||
|
|
||||||
|
$command = "rm -f {$escapedPath}";
|
||||||
instant_remote_process([$command], $this->server);
|
instant_remote_process([$command], $this->server);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage());
|
$this->dispatch('error', $e->getMessage());
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -405,6 +412,16 @@ class General extends Component
|
|||||||
'new_filename' => 'required|string',
|
'new_filename' => 'required|string',
|
||||||
'new_content' => 'required|string',
|
'new_content' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate filename to prevent command injection
|
||||||
|
validateShellSafePath($this->new_filename, 'init script filename');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->dispatch('error', $e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
|
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
|
||||||
if ($found) {
|
if ($found) {
|
||||||
$this->dispatch('error', 'Filename already exists.');
|
$this->dispatch('error', 'Filename already exists.');
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class Create extends Component
|
|||||||
'destination_id' => $destination->id,
|
'destination_id' => $destination->id,
|
||||||
'destination_type' => $destination->getMorphClass(),
|
'destination_type' => $destination->getMorphClass(),
|
||||||
];
|
];
|
||||||
if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') {
|
if ($oneClickServiceName === 'pgadmin' || $oneClickServiceName === 'postgresus') {
|
||||||
data_set($service_payload, 'connect_to_docker_network', true);
|
data_set($service_payload, 'connect_to_docker_network', true);
|
||||||
}
|
}
|
||||||
$service = Service::create($service_payload);
|
$service = Service::create($service_payload);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class EditDomain extends Component
|
|||||||
{
|
{
|
||||||
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
|
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
|
||||||
$this->authorize('view', $this->application);
|
$this->authorize('view', $this->application);
|
||||||
$this->requiredPort = $this->application->service->getRequiredPort();
|
$this->requiredPort = $this->application->getRequiredPort();
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +113,7 @@ class EditDomain extends Component
|
|||||||
|
|
||||||
// Check for required port
|
// Check for required port
|
||||||
if (! $this->forceRemovePort) {
|
if (! $this->forceRemovePort) {
|
||||||
$service = $this->application->service;
|
$requiredPort = $this->application->getRequiredPort();
|
||||||
$requiredPort = $service->getRequiredPort();
|
|
||||||
|
|
||||||
if ($requiredPort !== null) {
|
if ($requiredPort !== null) {
|
||||||
// Check if all FQDNs have a port
|
// Check if all FQDNs have a port
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class ServiceApplicationView extends Component
|
|||||||
try {
|
try {
|
||||||
$this->parameters = get_route_parameters();
|
$this->parameters = get_route_parameters();
|
||||||
$this->authorize('view', $this->application);
|
$this->authorize('view', $this->application);
|
||||||
$this->requiredPort = $this->application->service->getRequiredPort();
|
$this->requiredPort = $this->application->getRequiredPort();
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
@@ -268,8 +268,7 @@ class ServiceApplicationView extends Component
|
|||||||
|
|
||||||
// Check for required port
|
// Check for required port
|
||||||
if (! $this->forceRemovePort) {
|
if (! $this->forceRemovePort) {
|
||||||
$service = $this->application->service;
|
$requiredPort = $this->application->getRequiredPort();
|
||||||
$requiredPort = $service->getRequiredPort();
|
|
||||||
|
|
||||||
if ($requiredPort !== null) {
|
if ($requiredPort !== null) {
|
||||||
// Check if all FQDNs have a port
|
// Check if all FQDNs have a port
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service;
|
|||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Support\ValidationPatterns;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class StackForm extends Component
|
class StackForm extends Component
|
||||||
@@ -22,7 +23,7 @@ class StackForm extends Component
|
|||||||
|
|
||||||
public string $dockerComposeRaw;
|
public string $dockerComposeRaw;
|
||||||
|
|
||||||
public string $dockerCompose;
|
public ?string $dockerCompose = null;
|
||||||
|
|
||||||
public ?bool $connectToDockerNetwork = null;
|
public ?bool $connectToDockerNetwork = null;
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ class StackForm extends Component
|
|||||||
{
|
{
|
||||||
$baseRules = [
|
$baseRules = [
|
||||||
'dockerComposeRaw' => 'required',
|
'dockerComposeRaw' => 'required',
|
||||||
'dockerCompose' => 'required',
|
'dockerCompose' => 'nullable',
|
||||||
'name' => ValidationPatterns::nameRules(),
|
'name' => ValidationPatterns::nameRules(),
|
||||||
'description' => ValidationPatterns::descriptionRules(),
|
'description' => ValidationPatterns::descriptionRules(),
|
||||||
'connectToDockerNetwork' => 'nullable',
|
'connectToDockerNetwork' => 'nullable',
|
||||||
@@ -140,18 +141,27 @@ class StackForm extends Component
|
|||||||
$this->validate();
|
$this->validate();
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
|
|
||||||
// Validate for command injection BEFORE saving to database
|
// Validate for command injection BEFORE any database operations
|
||||||
validateDockerComposeForInjection($this->service->docker_compose_raw);
|
validateDockerComposeForInjection($this->service->docker_compose_raw);
|
||||||
|
|
||||||
$this->service->save();
|
// Use transaction to ensure atomicity - if parse fails, save is rolled back
|
||||||
$this->service->saveExtraFields($this->fields);
|
DB::transaction(function () {
|
||||||
$this->service->parse();
|
$this->service->save();
|
||||||
|
$this->service->saveExtraFields($this->fields);
|
||||||
|
$this->service->parse();
|
||||||
|
});
|
||||||
|
// Refresh and write files after a successful commit
|
||||||
$this->service->refresh();
|
$this->service->refresh();
|
||||||
$this->service->saveComposeConfigs();
|
$this->service->saveComposeConfigs();
|
||||||
|
|
||||||
$this->dispatch('refreshEnvs');
|
$this->dispatch('refreshEnvs');
|
||||||
$this->dispatch('refreshServices');
|
$this->dispatch('refreshServices');
|
||||||
$notify && $this->dispatch('success', 'Service saved.');
|
$notify && $this->dispatch('success', 'Service saved.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
// On error, refresh from database to restore clean state
|
||||||
|
$this->service->refresh();
|
||||||
|
$this->syncData(false);
|
||||||
|
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
} finally {
|
} finally {
|
||||||
if (is_null($this->service->config_hash)) {
|
if (is_null($this->service->config_hash)) {
|
||||||
|
|||||||
@@ -179,6 +179,10 @@ class Storage extends Component
|
|||||||
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
||||||
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
||||||
|
|
||||||
|
// Validate paths to prevent command injection
|
||||||
|
validateShellSafePath($this->file_storage_directory_source, 'storage source path');
|
||||||
|
validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
|
||||||
|
|
||||||
\App\Models\LocalFileVolume::create([
|
\App\Models\LocalFileVolume::create([
|
||||||
'fs_path' => $this->file_storage_directory_source,
|
'fs_path' => $this->file_storage_directory_source,
|
||||||
'mount_path' => $this->file_storage_directory_destination,
|
'mount_path' => $this->file_storage_directory_destination,
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||||
|
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\Project;
|
||||||
use App\Traits\EnvironmentVariableAnalyzer;
|
use App\Traits\EnvironmentVariableAnalyzer;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Add extends Component
|
class Add extends Component
|
||||||
@@ -56,6 +59,72 @@ class Add extends Component
|
|||||||
$this->problematicVariables = self::getProblematicVariablesForFrontend();
|
$this->problematicVariables = self::getProblematicVariablesForFrontend();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function availableSharedVariables(): array
|
||||||
|
{
|
||||||
|
$team = currentTeam();
|
||||||
|
$result = [
|
||||||
|
'team' => [],
|
||||||
|
'project' => [],
|
||||||
|
'environment' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Early return if no team
|
||||||
|
if (! $team) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can view team variables
|
||||||
|
try {
|
||||||
|
$this->authorize('view', $team);
|
||||||
|
$result['team'] = $team->environment_variables()
|
||||||
|
->pluck('key')
|
||||||
|
->toArray();
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User not authorized to view team variables
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project variables if we have a project_uuid in route
|
||||||
|
$projectUuid = data_get($this->parameters, 'project_uuid');
|
||||||
|
if ($projectUuid) {
|
||||||
|
$project = Project::where('team_id', $team->id)
|
||||||
|
->where('uuid', $projectUuid)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($project) {
|
||||||
|
try {
|
||||||
|
$this->authorize('view', $project);
|
||||||
|
$result['project'] = $project->environment_variables()
|
||||||
|
->pluck('key')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// Get environment variables if we have an environment_uuid in route
|
||||||
|
$environmentUuid = data_get($this->parameters, 'environment_uuid');
|
||||||
|
if ($environmentUuid) {
|
||||||
|
$environment = $project->environments()
|
||||||
|
->where('uuid', $environmentUuid)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($environment) {
|
||||||
|
try {
|
||||||
|
$this->authorize('view', $environment);
|
||||||
|
$result['environment'] = $environment->environment_variables()
|
||||||
|
->pluck('key')
|
||||||
|
->toArray();
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User not authorized to view environment variables
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User not authorized to view project variables
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||||
|
|
||||||
|
use App\Models\Environment;
|
||||||
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
||||||
|
use App\Models\Project;
|
||||||
use App\Models\SharedEnvironmentVariable;
|
use App\Models\SharedEnvironmentVariable;
|
||||||
use App\Traits\EnvironmentVariableAnalyzer;
|
use App\Traits\EnvironmentVariableAnalyzer;
|
||||||
use App\Traits\EnvironmentVariableProtection;
|
use App\Traits\EnvironmentVariableProtection;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Show extends Component
|
class Show extends Component
|
||||||
@@ -184,6 +187,7 @@ class Show extends Component
|
|||||||
|
|
||||||
$this->serialize();
|
$this->serialize();
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
|
$this->syncData(false);
|
||||||
$this->dispatch('success', 'Environment variable updated.');
|
$this->dispatch('success', 'Environment variable updated.');
|
||||||
$this->dispatch('envsUpdated');
|
$this->dispatch('envsUpdated');
|
||||||
$this->dispatch('configurationChanged');
|
$this->dispatch('configurationChanged');
|
||||||
@@ -192,6 +196,72 @@ class Show extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function availableSharedVariables(): array
|
||||||
|
{
|
||||||
|
$team = currentTeam();
|
||||||
|
$result = [
|
||||||
|
'team' => [],
|
||||||
|
'project' => [],
|
||||||
|
'environment' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Early return if no team
|
||||||
|
if (! $team) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can view team variables
|
||||||
|
try {
|
||||||
|
$this->authorize('view', $team);
|
||||||
|
$result['team'] = $team->environment_variables()
|
||||||
|
->pluck('key')
|
||||||
|
->toArray();
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User not authorized to view team variables
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project variables if we have a project_uuid in route
|
||||||
|
$projectUuid = data_get($this->parameters, 'project_uuid');
|
||||||
|
if ($projectUuid) {
|
||||||
|
$project = Project::where('team_id', $team->id)
|
||||||
|
->where('uuid', $projectUuid)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($project) {
|
||||||
|
try {
|
||||||
|
$this->authorize('view', $project);
|
||||||
|
$result['project'] = $project->environment_variables()
|
||||||
|
->pluck('key')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// Get environment variables if we have an environment_uuid in route
|
||||||
|
$environmentUuid = data_get($this->parameters, 'environment_uuid');
|
||||||
|
if ($environmentUuid) {
|
||||||
|
$environment = $project->environments()
|
||||||
|
->where('uuid', $environmentUuid)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($environment) {
|
||||||
|
try {
|
||||||
|
$this->authorize('view', $environment);
|
||||||
|
$result['environment'] = $environment->environment_variables()
|
||||||
|
->pluck('key')
|
||||||
|
->toArray();
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User not authorized to view environment variables
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User not authorized to view project variables
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
public function delete()
|
public function delete()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -34,11 +34,14 @@ class Add extends Component
|
|||||||
|
|
||||||
public ?string $container = '';
|
public ?string $container = '';
|
||||||
|
|
||||||
|
public int $timeout = 300;
|
||||||
|
|
||||||
protected $rules = [
|
protected $rules = [
|
||||||
'name' => 'required|string',
|
'name' => 'required|string',
|
||||||
'command' => 'required|string',
|
'command' => 'required|string',
|
||||||
'frequency' => 'required|string',
|
'frequency' => 'required|string',
|
||||||
'container' => 'nullable|string',
|
'container' => 'nullable|string',
|
||||||
|
'timeout' => 'required|integer|min:60|max:3600',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $validationAttributes = [
|
protected $validationAttributes = [
|
||||||
@@ -46,6 +49,7 @@ class Add extends Component
|
|||||||
'command' => 'command',
|
'command' => 'command',
|
||||||
'frequency' => 'frequency',
|
'frequency' => 'frequency',
|
||||||
'container' => 'container',
|
'container' => 'container',
|
||||||
|
'timeout' => 'timeout',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
@@ -103,6 +107,7 @@ class Add extends Component
|
|||||||
$task->command = $this->command;
|
$task->command = $this->command;
|
||||||
$task->frequency = $this->frequency;
|
$task->frequency = $this->frequency;
|
||||||
$task->container = $this->container;
|
$task->container = $this->container;
|
||||||
|
$task->timeout = $this->timeout;
|
||||||
$task->team_id = currentTeam()->id;
|
$task->team_id = currentTeam()->id;
|
||||||
|
|
||||||
switch ($this->type) {
|
switch ($this->type) {
|
||||||
@@ -130,5 +135,6 @@ class Add extends Component
|
|||||||
$this->command = '';
|
$this->command = '';
|
||||||
$this->frequency = '';
|
$this->frequency = '';
|
||||||
$this->container = '';
|
$this->container = '';
|
||||||
|
$this->timeout = 300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ class Show extends Component
|
|||||||
#[Validate(['string', 'nullable'])]
|
#[Validate(['string', 'nullable'])]
|
||||||
public ?string $container = null;
|
public ?string $container = null;
|
||||||
|
|
||||||
|
#[Validate(['integer', 'required', 'min:60', 'max:3600'])]
|
||||||
|
public $timeout = 300;
|
||||||
|
|
||||||
#[Locked]
|
#[Locked]
|
||||||
public ?string $application_uuid;
|
public ?string $application_uuid;
|
||||||
|
|
||||||
@@ -99,6 +102,7 @@ class Show extends Component
|
|||||||
$this->task->command = str($this->command)->trim()->value();
|
$this->task->command = str($this->command)->trim()->value();
|
||||||
$this->task->frequency = str($this->frequency)->trim()->value();
|
$this->task->frequency = str($this->frequency)->trim()->value();
|
||||||
$this->task->container = str($this->container)->trim()->value();
|
$this->task->container = str($this->container)->trim()->value();
|
||||||
|
$this->task->timeout = (int) $this->timeout;
|
||||||
$this->task->save();
|
$this->task->save();
|
||||||
} else {
|
} else {
|
||||||
$this->isEnabled = $this->task->enabled;
|
$this->isEnabled = $this->task->enabled;
|
||||||
@@ -106,6 +110,7 @@ class Show extends Component
|
|||||||
$this->command = $this->task->command;
|
$this->command = $this->task->command;
|
||||||
$this->frequency = $this->task->frequency;
|
$this->frequency = $this->task->frequency;
|
||||||
$this->container = $this->task->container;
|
$this->container = $this->task->container;
|
||||||
|
$this->timeout = $this->task->timeout ?? 300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ namespace App\Livewire\Server;
|
|||||||
use App\Actions\Proxy\CheckProxy;
|
use App\Actions\Proxy\CheckProxy;
|
||||||
use App\Actions\Proxy\StartProxy;
|
use App\Actions\Proxy\StartProxy;
|
||||||
use App\Actions\Proxy\StopProxy;
|
use App\Actions\Proxy\StopProxy;
|
||||||
use App\Jobs\RestartProxyJob;
|
use App\Enums\ProxyTypes;
|
||||||
|
use App\Jobs\CheckTraefikVersionForServerJob;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Services\ProxyDashboardCacheService;
|
use App\Services\ProxyDashboardCacheService;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
@@ -61,7 +62,18 @@ class Navbar extends Component
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('manageProxy', $this->server);
|
$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) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
@@ -118,19 +130,25 @@ class Navbar extends Component
|
|||||||
|
|
||||||
public function showNotification()
|
public function showNotification()
|
||||||
{
|
{
|
||||||
|
$previousStatus = $this->proxyStatus;
|
||||||
$this->server->refresh();
|
$this->server->refresh();
|
||||||
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
|
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
|
||||||
|
|
||||||
switch ($this->proxyStatus) {
|
switch ($this->proxyStatus) {
|
||||||
case 'running':
|
case 'running':
|
||||||
$this->loadProxyConfiguration();
|
$this->loadProxyConfiguration();
|
||||||
$this->dispatch('success', 'Proxy is running.');
|
// Only show "Proxy is running" notification when transitioning from a stopped/error state
|
||||||
break;
|
// Don't show during normal start/restart flows (starting, restarting, stopping)
|
||||||
case 'restarting':
|
if (in_array($previousStatus, ['exited', 'stopped', 'unknown', null])) {
|
||||||
$this->dispatch('info', 'Initiating proxy restart.');
|
$this->dispatch('success', 'Proxy is running.');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'exited':
|
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;
|
break;
|
||||||
case 'stopping':
|
case 'stopping':
|
||||||
$this->dispatch('info', 'Proxy is stopping.');
|
$this->dispatch('info', 'Proxy is stopping.');
|
||||||
@@ -154,6 +172,22 @@ class Navbar extends Component
|
|||||||
$this->server->load('settings');
|
$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()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.server.navbar');
|
return view('livewire.server.navbar');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Server;
|
|||||||
|
|
||||||
use App\Actions\Proxy\GetProxyConfiguration;
|
use App\Actions\Proxy\GetProxyConfiguration;
|
||||||
use App\Actions\Proxy\SaveProxyConfiguration;
|
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||||
|
use App\Enums\ProxyTypes;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -24,6 +25,12 @@ class Proxy extends Component
|
|||||||
|
|
||||||
public bool $generateExactLabels = false;
|
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()
|
public function getListeners()
|
||||||
{
|
{
|
||||||
$teamId = auth()->user()->currentTeam()->id;
|
$teamId = auth()->user()->currentTeam()->id;
|
||||||
@@ -55,9 +62,37 @@ class Proxy extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getConfigurationFilePathProperty()
|
/**
|
||||||
|
* Get Traefik versions from cached data with in-memory optimization.
|
||||||
|
* Returns array like: ['v3.5' => '3.5.6', 'v3.6' => '3.6.2']
|
||||||
|
*
|
||||||
|
* This method adds an in-memory cache layer on top of the global
|
||||||
|
* get_traefik_versions() helper to avoid multiple calls during
|
||||||
|
* a single component lifecycle/render.
|
||||||
|
*/
|
||||||
|
protected function getTraefikVersions(): ?array
|
||||||
{
|
{
|
||||||
return $this->server->proxyPath().'docker-compose.yml';
|
// In-memory cache for this component instance (per-request)
|
||||||
|
if ($this->cachedVersionsFile !== null) {
|
||||||
|
return data_get($this->cachedVersionsFile, 'traefik');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from global cached helper (Redis + filesystem)
|
||||||
|
$versionsData = get_versions_data();
|
||||||
|
$this->cachedVersionsFile = $versionsData;
|
||||||
|
|
||||||
|
if (! $versionsData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$traefikVersions = data_get($versionsData, 'traefik');
|
||||||
|
|
||||||
|
return is_array($traefikVersions) ? $traefikVersions : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConfigurationFilePathProperty(): string
|
||||||
|
{
|
||||||
|
return rtrim($this->server->proxyPath(), '/') . '/docker-compose.yml';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function changeProxy()
|
public function changeProxy()
|
||||||
@@ -144,4 +179,131 @@ class Proxy extends Component
|
|||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest Traefik version for this server's current branch.
|
||||||
|
*
|
||||||
|
* This compares the server's detected version against available versions
|
||||||
|
* in versions.json to determine the latest patch for the current branch,
|
||||||
|
* or the newest available version if no current version is detected.
|
||||||
|
*/
|
||||||
|
public function getLatestTraefikVersionProperty(): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$traefikVersions = $this->getTraefikVersions();
|
||||||
|
|
||||||
|
if (! $traefikVersions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get this server's current version
|
||||||
|
$currentVersion = $this->server->detected_traefik_version;
|
||||||
|
|
||||||
|
// If we have a current version, try to find matching branch
|
||||||
|
if ($currentVersion && $currentVersion !== 'latest') {
|
||||||
|
$current = ltrim($currentVersion, 'v');
|
||||||
|
if (preg_match('/^(\d+\.\d+)/', $current, $matches)) {
|
||||||
|
$branch = "v{$matches[1]}";
|
||||||
|
if (isset($traefikVersions[$branch])) {
|
||||||
|
$version = $traefikVersions[$branch];
|
||||||
|
|
||||||
|
return str_starts_with($version, 'v') ? $version : "v{$version}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the newest available version
|
||||||
|
$newestVersion = collect($traefikVersions)
|
||||||
|
->map(fn ($v) => ltrim($v, 'v'))
|
||||||
|
->sortBy(fn ($v) => $v, SORT_NATURAL)
|
||||||
|
->last();
|
||||||
|
|
||||||
|
return $newestVersion ? "v{$newestVersion}" : null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsTraefikOutdatedProperty(): bool
|
||||||
|
{
|
||||||
|
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentVersion = $this->server->detected_traefik_version;
|
||||||
|
if (! $currentVersion || $currentVersion === 'latest') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestVersion = $this->latestTraefikVersion;
|
||||||
|
if (! $latestVersion) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare versions (strip 'v' prefix)
|
||||||
|
$current = ltrim($currentVersion, 'v');
|
||||||
|
$latest = ltrim($latestVersion, 'v');
|
||||||
|
|
||||||
|
return version_compare($current, $latest, '<');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a newer Traefik branch (minor version) is available for this server.
|
||||||
|
* Returns the branch identifier (e.g., "v3.6") if a newer branch exists.
|
||||||
|
*/
|
||||||
|
public function getNewerTraefikBranchAvailableProperty(): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get this server's current version
|
||||||
|
$currentVersion = $this->server->detected_traefik_version;
|
||||||
|
if (! $currentVersion || $currentVersion === 'latest') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have outdated info stored for this server (faster than computing)
|
||||||
|
$outdatedInfo = $this->server->traefik_outdated_info;
|
||||||
|
if ($outdatedInfo && isset($outdatedInfo['type']) && $outdatedInfo['type'] === 'minor_upgrade') {
|
||||||
|
// Use the upgrade_target field if available (e.g., "v3.6")
|
||||||
|
if (isset($outdatedInfo['upgrade_target'])) {
|
||||||
|
return str_starts_with($outdatedInfo['upgrade_target'], 'v')
|
||||||
|
? $outdatedInfo['upgrade_target']
|
||||||
|
: "v{$outdatedInfo['upgrade_target']}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: compute from cached versions data
|
||||||
|
$traefikVersions = $this->getTraefikVersions();
|
||||||
|
|
||||||
|
if (! $traefikVersions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract current branch (e.g., "3.5" from "3.5.6")
|
||||||
|
$current = ltrim($currentVersion, 'v');
|
||||||
|
if (! preg_match('/^(\d+\.\d+)/', $current, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentBranch = $matches[1];
|
||||||
|
|
||||||
|
// Find the newest branch that's greater than current
|
||||||
|
$newestBranch = null;
|
||||||
|
foreach ($traefikVersions as $branch => $version) {
|
||||||
|
$branchNum = ltrim($branch, 'v');
|
||||||
|
if (version_compare($branchNum, $currentBranch, '>')) {
|
||||||
|
if (! $newestBranch || version_compare($branchNum, $newestBranch, '>')) {
|
||||||
|
$newestBranch = $branchNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $newestBranch ? "v{$newestBranch}" : null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,13 +25,25 @@ class DynamicConfigurationNavbar extends Component
|
|||||||
$this->authorize('update', $this->server);
|
$this->authorize('update', $this->server);
|
||||||
$proxy_path = $this->server->proxyPath();
|
$proxy_path = $this->server->proxyPath();
|
||||||
$proxy_type = $this->server->proxyType();
|
$proxy_type = $this->server->proxyType();
|
||||||
|
|
||||||
|
// Decode filename: pipes are used to encode dots for Livewire property binding
|
||||||
|
// (e.g., 'my|service.yaml' -> 'my.service.yaml')
|
||||||
|
// This must happen BEFORE validation because validateShellSafePath() correctly
|
||||||
|
// rejects pipe characters as dangerous shell metacharacters
|
||||||
$file = str_replace('|', '.', $fileName);
|
$file = str_replace('|', '.', $fileName);
|
||||||
|
|
||||||
|
// Validate filename to prevent command injection
|
||||||
|
validateShellSafePath($file, 'proxy configuration filename');
|
||||||
|
|
||||||
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
|
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
|
||||||
$this->dispatch('error', 'Cannot delete Caddyfile.');
|
$this->dispatch('error', 'Cannot delete Caddyfile.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $this->server);
|
|
||||||
|
$fullPath = "{$proxy_path}/dynamic/{$file}";
|
||||||
|
$escapedPath = escapeshellarg($fullPath);
|
||||||
|
instant_remote_process(["rm -f {$escapedPath}"], $this->server);
|
||||||
if ($proxy_type === 'CADDY') {
|
if ($proxy_type === 'CADDY') {
|
||||||
$this->server->reloadCaddy();
|
$this->server->reloadCaddy();
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user