diff --git a/.AI_INSTRUCTIONS_SYNC.md b/.AI_INSTRUCTIONS_SYNC.md new file mode 100644 index 000000000..bbe0a90e1 --- /dev/null +++ b/.AI_INSTRUCTIONS_SYNC.md @@ -0,0 +1,156 @@ +# 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 in two parallel systems: + +1. **CLAUDE.md** - For Claude Code (claude.ai/code) +2. **.cursor/rules/** - For Cursor IDE and other AI assistants + +Both systems share core principles but are optimized for their respective workflows. + +## Structure + +### CLAUDE.md +- **Purpose**: Condensed, workflow-focused guide for Claude Code +- **Format**: Single markdown file +- **Includes**: + - Quick-reference development commands + - High-level architecture overview + - Core patterns and guidelines + - Embedded Laravel Boost guidelines + - References to detailed .cursor/rules/ documentation + +### .cursor/rules/ +- **Purpose**: Detailed, topic-specific documentation +- **Format**: Multiple .mdc files organized by topic +- **Structure**: + - `README.mdc` - Main index and overview + - `cursor_rules.mdc` - Maintenance guidelines + - Topic-specific files (testing-patterns.mdc, security-patterns.mdc, etc.) +- **Used by**: Cursor IDE, Claude Code (for detailed reference), other AI assistants + +## Cross-References + +Both systems reference each other: + +- **CLAUDE.md** → references `.cursor/rules/` for detailed documentation +- **.cursor/rules/README.mdc** → references `CLAUDE.md` for Claude Code workflow +- **.cursor/rules/cursor_rules.mdc** → notes that changes should sync with CLAUDE.md + +## Maintaining Consistency + +When updating AI instructions, follow these guidelines: + +### 1. Core Principles (MUST be consistent) +- Laravel version (currently Laravel 12) +- PHP version (8.4) +- Testing execution rules (Docker for Feature tests, mocking for Unit tests) +- Security patterns and authorization requirements +- Code style requirements (Pint, PSR-12) + +### 2. Where to Make Changes + +**For workflow changes** (how to run commands, development setup): +- Primary: `CLAUDE.md` +- Secondary: `.cursor/rules/development-workflow.mdc` + +**For architectural patterns** (how code should be structured): +- Primary: `.cursor/rules/` topic files +- Secondary: Reference in `CLAUDE.md` "Additional Documentation" section + +**For testing patterns**: +- Both: Must be synchronized +- `CLAUDE.md` - Contains condensed testing execution rules +- `.cursor/rules/testing-patterns.mdc` - Contains detailed examples and patterns + +### 3. Update Checklist + +When making significant changes: + +- [ ] Identify if change affects core principles (version numbers, critical patterns) +- [ ] Update primary location (CLAUDE.md or .cursor/rules/) +- [ ] Check if update affects cross-referenced content +- [ ] Update secondary location if needed +- [ ] Verify cross-references are still accurate +- [ ] Run: `./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc` (if applicable) + +### 4. Common Inconsistencies to Watch + +- **Version numbers**: Laravel, PHP, package versions +- **Testing instructions**: Docker execution requirements +- **File paths**: Ensure relative paths work from root +- **Command syntax**: Docker commands, artisan commands +- **Architecture decisions**: Laravel 10 structure vs Laravel 12+ structure + +## File Organization + +``` +/ +├── CLAUDE.md # Claude Code instructions (condensed) +├── .AI_INSTRUCTIONS_SYNC.md # This file +└── .cursor/ + └── rules/ + ├── README.mdc # Index and overview + ├── cursor_rules.mdc # Maintenance guide + ├── testing-patterns.mdc # Testing details + ├── development-workflow.mdc # Dev setup details + ├── security-patterns.mdc # Security details + ├── application-architecture.mdc + ├── deployment-architecture.mdc + ├── database-patterns.mdc + ├── frontend-patterns.mdc + ├── api-and-routing.mdc + ├── form-components.mdc + ├── technology-stack.mdc + ├── project-overview.mdc + └── laravel-boost.mdc # Laravel-specific patterns +``` + +## Recent Updates + +### 2025-10-07 +- ✅ Added cross-references between CLAUDE.md and .cursor/rules/ +- ✅ Synchronized Laravel version (12) across all files +- ✅ Added comprehensive testing execution rules (Docker for Feature tests) +- ✅ Added test design philosophy (prefer mocking over database) +- ✅ Fixed inconsistencies in testing documentation +- ✅ Created this synchronization guide + +## Maintenance Commands + +```bash +# Check for version inconsistencies +grep -r "Laravel [0-9]" CLAUDE.md .cursor/rules/*.mdc + +# Check for PHP version consistency +grep -r "PHP [0-9]" CLAUDE.md .cursor/rules/*.mdc + +# Format all documentation +./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc + +# Search for specific patterns across all docs +grep -r "pattern_to_check" CLAUDE.md .cursor/rules/ +``` + +## Contributing + +When contributing documentation: + +1. Check both CLAUDE.md and .cursor/rules/ for existing documentation +2. Add to appropriate location(s) based on guidelines above +3. Add cross-references if creating new patterns +4. Update this file if changing organizational structure +5. Verify consistency before submitting PR + +## Questions? + +If unsure about where to document something: + +- **Quick reference / workflow** → CLAUDE.md +- **Detailed patterns / examples** → .cursor/rules/[topic].mdc +- **Both?** → Start with .cursor/rules/, then reference in CLAUDE.md + +When in doubt, prefer detailed documentation in .cursor/rules/ and concise references in CLAUDE.md. diff --git a/.cursor/rules/README.mdc b/.cursor/rules/README.mdc index 07f19a816..d0597bb72 100644 --- a/.cursor/rules/README.mdc +++ b/.cursor/rules/README.mdc @@ -9,6 +9,10 @@ alwaysApply: false 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 @@ -71,7 +75,7 @@ Coolify uses a **team-based multi-tenancy** model where: - **Multi-server** support with SSH connections ### 3. Technology Stack -- **Backend**: Laravel 11 + PHP 8.4 +- **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 diff --git a/.cursor/rules/cursor_rules.mdc b/.cursor/rules/cursor_rules.mdc index 7dfae3de0..9edccd496 100644 --- a/.cursor/rules/cursor_rules.mdc +++ b/.cursor/rules/cursor_rules.mdc @@ -4,6 +4,12 @@ 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 --- diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc index 005ede849..c409a4647 100644 --- a/.cursor/rules/laravel-boost.mdc +++ b/.cursor/rules/laravel-boost.mdc @@ -185,7 +185,7 @@ protected function isAccessible(User $user, ?string $path = null): bool ### 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 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. +- 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. diff --git a/.cursor/rules/testing-patterns.mdc b/.cursor/rules/testing-patterns.mdc index a0e64dbae..8d250b56a 100644 --- a/.cursor/rules/testing-patterns.mdc +++ b/.cursor/rules/testing-patterns.mdc @@ -5,11 +5,56 @@ alwaysApply: false --- # Coolify Testing Architecture & Patterns +> **Cross-Reference**: These detailed testing patterns align with the testing guidelines in **[CLAUDE.md](mdc:CLAUDE.md)**. Both documents share the same core principles about Docker execution and mocking preferences. + ## Testing Philosophy Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions. -!Important: Always run tests inside `coolify` container. +### Test Execution Rules + +**CRITICAL**: Tests are categorized by database dependency: + +#### Unit Tests (`tests/Unit/`) +- **MUST NOT** use database connections +- **MUST** use mocking for models and external dependencies +- **CAN** run outside Docker: `./vendor/bin/pest tests/Unit` +- Purpose: Test isolated logic, helper functions, and business rules + +#### Feature Tests (`tests/Feature/`) +- **MAY** use database connections (factories, migrations, models) +- **MUST** run inside Docker container: `docker exec coolify php artisan test` +- **MUST** use `RefreshDatabase` trait if touching database +- Purpose: Test API endpoints, workflows, and integration scenarios + +**Rule of thumb**: If your test needs `Server::factory()->create()` or any database operation, it's a Feature test and MUST run in Docker. + +### Prefer Mocking Over Database + +When writing tests, always prefer mocking over real database operations: + +```php +// ❌ BAD: Unit test using database +it('extracts custom commands', function () { + $server = Server::factory()->create(['ip' => '1.2.3.4']); + $commands = extract_custom_proxy_commands($server, $yaml); + expect($commands)->toBeArray(); +}); + +// ✅ GOOD: Unit test using mocking +it('extracts custom commands', function () { + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn('traefik'); + $commands = extract_custom_proxy_commands($server, $yaml); + expect($commands)->toBeArray(); +}); +``` + +**Design principles for testable code:** +- Use dependency injection instead of global state +- Create interfaces for external dependencies (SSH, Docker, etc.) +- Separate business logic from data persistence +- Make functions accept interfaces instead of concrete models when possible ## Testing Framework Stack diff --git a/CLAUDE.md b/CLAUDE.md index 22e762182..6c594955c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,10 @@ # CLAUDE.md -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. +> +> **Maintaining Instructions**: When updating AI instructions, see [.AI_INSTRUCTIONS_SYNC.md](.AI_INSTRUCTIONS_SYNC.md) for synchronization guidelines between CLAUDE.md and .cursor/rules/. ## Project Overview @@ -23,7 +27,14 @@ ### Backend Development ### Code Quality - `./vendor/bin/pint` - Run Laravel Pint for code formatting - `./vendor/bin/phpstan` - Run PHPStan for static analysis -- `./vendor/bin/pest` - Run Pest tests +- `./vendor/bin/pest` - Run Pest tests (unit tests only, without database) + +### Running Tests +**IMPORTANT**: Tests that require database connections MUST be run inside the Docker container: +- **Inside Docker**: `docker exec coolify php artisan test` (for feature tests requiring database) +- **Outside Docker**: `./vendor/bin/pest tests/Unit` (for pure unit tests without database dependencies) +- Unit tests should use mocking and avoid database connections +- Feature tests that require database must be run in the `coolify` container ## Architecture Overview @@ -173,6 +184,21 @@ ### Testing Strategy - **Mocking**: Use Laravel's built-in mocking for external services - **Database**: Use RefreshDatabase trait for test isolation +#### Test Execution Environment +**CRITICAL**: Database-dependent tests MUST run inside Docker container: +- **Unit Tests** (`tests/Unit/`): Should NOT use database. Use mocking. Run with `./vendor/bin/pest tests/Unit` +- **Feature Tests** (`tests/Feature/`): May use database. MUST run inside Docker with `docker exec coolify php artisan test` +- If a test needs database (factories, migrations, etc.), it belongs in `tests/Feature/` +- Always mock external services and SSH connections in tests + +#### Test Design Philosophy +**PREFER MOCKING**: When designing features and writing tests: +- **Design for testability**: Structure code so it can be tested without database (use dependency injection, interfaces) +- **Mock by default**: Unit tests should mock models and external dependencies using Mockery +- **Avoid database when possible**: If you can test the logic without database, write it as a Unit test +- **Only use database when necessary**: Feature tests should test integration points, not isolated logic +- **Example**: Instead of `Server::factory()->create()`, use `Mockery::mock('App\Models\Server')` in unit tests + ### Routing Conventions - Group routes by middleware and prefix - Use route model binding for cleaner controllers @@ -228,7 +254,9 @@ ## Important Reminders ## Additional Documentation -For more detailed guidelines and patterns, refer to the `.cursor/rules/` directory: +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): + +> **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. ### Architecture & Patterns - [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure @@ -434,7 +462,7 @@ ### Laravel 10 Structure ### 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 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. +- 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. @@ -543,6 +571,10 @@ ### Pest Tests - 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: it('is true', function () { @@ -551,11 +583,23 @@ ### Pest Tests ### Running Tests -- Run the minimal number of tests using an appropriate filter before finalizing code edits. -- To run all tests: `php artisan test`. -- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. -- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). -- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. +**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.: @@ -650,7 +694,12 @@ ### Replaced Utilities ## Test Enforcement - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +- 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 diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 3bf91c281..3aa1d8d34 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -33,7 +33,13 @@ public function handle(Server $server, bool $forceRegenerate = false): string // 1. Force regenerate is requested // 2. Configuration file doesn't exist or is empty if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) { - $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value(); + // Extract custom commands from existing config before regenerating + $custom_commands = []; + if (! empty(trim($proxy_configuration ?? ''))) { + $custom_commands = extractCustomProxyCommands($server, $proxy_configuration); + } + + $proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value(); } if (empty($proxy_configuration)) { diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index fafcdb618..8ffaabde5 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1005,6 +1005,10 @@ private function should_skip_build() $this->skip_build = true; $this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); $this->generate_compose_file(); + + // Save runtime environment variables even when skipping build + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); @@ -1014,6 +1018,10 @@ private function should_skip_build() $this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); $this->skip_build = true; $this->generate_compose_file(); + + // Save runtime environment variables even when skipping build + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); @@ -1694,7 +1702,7 @@ private function create_workdir() } } - private function prepare_builder_image() + private function prepare_builder_image(bool $firstTry = true) { $this->checkForCancellation(); $settings = instanceSettings(); @@ -1717,7 +1725,12 @@ private function prepare_builder_image() $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } } - $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage."); + if ($firstTry) { + $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage"); + } else { + $this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.'); + } + $this->graceful_shutdown_container($this->deployment_uuid); $this->execute_remote_command( [ @@ -1740,7 +1753,7 @@ private function restart_builder_container_with_actual_commit() $this->env_args = null; // Restart the helper container with updated environment variables (including actual SOURCE_COMMIT) - $this->prepare_builder_image(); + $this->prepare_builder_image(firstTry: false); } private function deploy_to_additional_destinations() @@ -1988,6 +2001,15 @@ private function generate_nixpacks_confs() if ($this->nixpacks_type === 'elixir') { $this->elixir_finetunes(); } + if ($this->nixpacks_type === 'node') { + // Check if NIXPACKS_NODE_VERSION is set + $variables = data_get($parsed, 'variables', []); + if (! isset($variables['NIXPACKS_NODE_VERSION'])) { + $this->application_deployment_queue->addLogEntry('----------------------------------------'); + $this->application_deployment_queue->addLogEntry('⚠️ NIXPACKS_NODE_VERSION not set. Nixpacks will use Node.js 18 by default, which is EOL.'); + $this->application_deployment_queue->addLogEntry('You can override this by setting NIXPACKS_NODE_VERSION=22 in your environment variables.'); + } + } $this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT); $this->nixpacks_plan_json = collect($parsed); $this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true); diff --git a/app/Jobs/ApplicationPullRequestUpdateJob.php b/app/Jobs/ApplicationPullRequestUpdateJob.php index ef8e6efb6..471edb4c6 100755 --- a/app/Jobs/ApplicationPullRequestUpdateJob.php +++ b/app/Jobs/ApplicationPullRequestUpdateJob.php @@ -49,7 +49,7 @@ public function handle() } elseif ($this->status === ProcessStatus::ERROR) { $this->body = "The preview deployment failed. 🔴\n\n"; } - $this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/{$this->application->environment->name}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; + $this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; $this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n"; $this->body .= 'Last updated at: '.now()->toDateTimeString().' CET'; diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 92db14a61..3cc372fd1 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -15,6 +15,7 @@ use App\Models\Team; use App\Notifications\Database\BackupFailed; use App\Notifications\Database\BackupSuccess; +use App\Notifications\Database\BackupSuccessWithS3Warning; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -74,6 +75,7 @@ public function __construct(public ScheduledDatabaseBackup $backup) { $this->onQueue('high'); $this->timeout = $backup->timeout; + $this->backup_log_uuid = (string) new Cuid2; } public function handle(): void @@ -298,6 +300,10 @@ public function handle(): void } while ($exists); $size = 0; + $localBackupSucceeded = false; + $s3UploadError = null; + + // Step 1: Create local backup try { if (str($databaseType)->contains('postgres')) { $this->backup_file = "/pg-dump-$database-".Carbon::now()->timestamp.'.dmp'; @@ -310,6 +316,7 @@ public function handle(): void 'database_name' => $database, 'filename' => $this->backup_location, 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, ]); $this->backup_standalone_postgresql($database); } elseif (str($databaseType)->contains('mongo')) { @@ -330,6 +337,7 @@ public function handle(): void 'database_name' => $databaseName, 'filename' => $this->backup_location, 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, ]); $this->backup_standalone_mongodb($database); } elseif (str($databaseType)->contains('mysql')) { @@ -343,6 +351,7 @@ public function handle(): void 'database_name' => $database, 'filename' => $this->backup_location, 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, ]); $this->backup_standalone_mysql($database); } elseif (str($databaseType)->contains('mariadb')) { @@ -356,56 +365,77 @@ public function handle(): void 'database_name' => $database, 'filename' => $this->backup_location, 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, ]); $this->backup_standalone_mariadb($database); } else { throw new \Exception('Unsupported database type'); } + $size = $this->calculate_size(); - if ($this->backup->save_s3) { + + // Verify local backup succeeded + if ($size > 0) { + $localBackupSucceeded = true; + } else { + throw new \Exception('Local backup file is empty or was not created'); + } + } catch (\Throwable $e) { + // Local backup failed + if ($this->backup_log) { + $this->backup_log->update([ + 'status' => 'failed', + 'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(), + 'size' => $size, + 'filename' => null, + 's3_uploaded' => null, + ]); + } + $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + + continue; + } + + // Step 2: Upload to S3 if enabled (independent of local backup) + $localStorageDeleted = false; + if ($this->backup->save_s3 && $localBackupSucceeded) { + try { $this->upload_to_s3(); // If local backup is disabled, delete the local file immediately after S3 upload if ($this->backup->disable_local_backup) { deleteBackupsLocally($this->backup_location, $this->server); + $localStorageDeleted = true; } + } catch (\Throwable $e) { + // S3 upload failed but local backup succeeded + $s3UploadError = $e->getMessage(); } + } - $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); + // Step 3: Update status and send notifications based on results + if ($localBackupSucceeded) { + $message = $this->backup_output; + + if ($s3UploadError) { + $message = $message + ? $message."\n\nWarning: S3 upload failed: ".$s3UploadError + : 'Warning: S3 upload failed: '.$s3UploadError; + } $this->backup_log->update([ 'status' => 'success', - 'message' => $this->backup_output, + 'message' => $message, 'size' => $size, + 's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null, + 'local_storage_deleted' => $localStorageDeleted, ]); - } catch (\Throwable $e) { - // Check if backup actually failed or if it's just a post-backup issue - $actualBackupFailed = ! $this->s3_uploaded && $this->backup->save_s3; - if ($actualBackupFailed || $size === 0) { - // Real backup failure - if ($this->backup_log) { - $this->backup_log->update([ - 'status' => 'failed', - 'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(), - 'size' => $size, - 'filename' => null, - ]); - } - $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + // Send appropriate notification + if ($s3UploadError) { + $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError)); } else { - // Backup succeeded but post-processing failed (cleanup, notification, etc.) - if ($this->backup_log) { - $this->backup_log->update([ - 'status' => 'success', - 'message' => $this->backup_output ? $this->backup_output."\nWarning: Post-backup cleanup encountered an issue: ".$e->getMessage() : 'Warning: '.$e->getMessage(), - 'size' => $size, - ]); - } - // Send success notification since the backup itself succeeded $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); - // Log the post-backup issue - ray('Post-backup operation failed but backup was successful: '.$e->getMessage()); } } } @@ -591,24 +621,24 @@ private function upload_to_s3(): void $fullImageName = $this->getFullImageName(); - $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup->uuid}"], $this->server, false); + $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false); if (filled($containerExists)) { - instant_remote_process(["docker rm -f backup-of-{$this->backup->uuid}"], $this->server, false); + instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false); } if (isDev()) { if ($this->database->name === 'coolify-db') { $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file; - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; } else { $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file; - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; } } else { - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; } - $commands[] = "docker exec backup-of-{$this->backup->uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\""; - $commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\""; + $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); $this->s3_uploaded = true; @@ -617,7 +647,7 @@ private function upload_to_s3(): void $this->add_to_error_output($e->getMessage()); throw $e; } finally { - $command = "docker rm -f backup-of-{$this->backup->uuid}"; + $command = "docker rm -f backup-of-{$this->backup_log_uuid}"; instant_remote_process([$command], $this->server); } } diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 15de5d838..679926738 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -28,12 +28,21 @@ class GlobalSearch extends Component public $allSearchableItems = []; + public $isCreateMode = false; + + public $creatableItems = []; + + public $autoOpenResource = null; + public function mount() { $this->searchQuery = ''; $this->isModalOpen = false; $this->searchResults = []; $this->allSearchableItems = []; + $this->isCreateMode = false; + $this->creatableItems = []; + $this->autoOpenResource = null; } public function openSearchModal() @@ -62,7 +71,63 @@ public static function clearTeamCache($teamId) public function updatedSearchQuery() { - $this->search(); + $query = strtolower(trim($this->searchQuery)); + + if (str_starts_with($query, 'new')) { + $this->isCreateMode = true; + $this->loadCreatableItems(); + $this->searchResults = []; + + // Check for sub-commands like "new project", "new server", etc. + // Use original query (not trimmed) to ensure exact match without trailing spaces + $this->autoOpenResource = $this->detectSpecificResource(strtolower($this->searchQuery)); + } else { + $this->isCreateMode = false; + $this->creatableItems = []; + $this->autoOpenResource = null; + $this->search(); + } + } + + private function detectSpecificResource(string $query): ?string + { + // Map of keywords to resource types - order matters for multi-word matches + $resourceMap = [ + 'new project' => 'project', + 'new server' => 'server', + 'new team' => 'team', + 'new storage' => 'storage', + 'new s3' => 'storage', + 'new private key' => 'private-key', + 'new privatekey' => 'private-key', + 'new key' => 'private-key', + 'new github' => 'source', + 'new source' => 'source', + 'new git' => 'source', + ]; + + foreach ($resourceMap as $command => $type) { + if ($query === $command) { + // Check if user has permission for this resource type + if ($this->canCreateResource($type)) { + return $type; + } + } + } + + return null; + } + + private function canCreateResource(string $type): bool + { + $user = auth()->user(); + + return match ($type) { + 'project', 'source' => $user->can('createAnyResource'), + 'server', 'storage', 'private-key' => $user->isAdmin() || $user->isOwner(), + 'team' => true, + default => false, + }; } private function loadSearchableItems() @@ -437,6 +502,72 @@ private function search() ->toArray(); } + private function loadCreatableItems() + { + $items = collect(); + $user = auth()->user(); + + // Project - can be created if user has createAnyResource permission + if ($user->can('createAnyResource')) { + $items->push([ + 'name' => 'Project', + 'description' => 'Create a new project to organize your resources', + 'type' => 'project', + 'component' => 'project.add-empty', + ]); + } + + // Server - can be created if user is admin or owner + if ($user->isAdmin() || $user->isOwner()) { + $items->push([ + 'name' => 'Server', + 'description' => 'Add a new server to deploy your applications', + 'type' => 'server', + 'component' => 'server.create', + ]); + } + + // Team - can be created by anyone (they become owner of new team) + $items->push([ + 'name' => 'Team', + 'description' => 'Create a new team to collaborate with others', + 'type' => 'team', + 'component' => 'team.create', + ]); + + // Storage - can be created if user is admin or owner + if ($user->isAdmin() || $user->isOwner()) { + $items->push([ + 'name' => 'S3 Storage', + 'description' => 'Add S3 storage for backups and file uploads', + 'type' => 'storage', + 'component' => 'storage.create', + ]); + } + + // Private Key - can be created if user is admin or owner + if ($user->isAdmin() || $user->isOwner()) { + $items->push([ + 'name' => 'Private Key', + 'description' => 'Add an SSH private key for server access', + 'type' => 'private-key', + 'component' => 'security.private-key.create', + ]); + } + + // GitHub Source - can be created if user has createAnyResource permission + if ($user->can('createAnyResource')) { + $items->push([ + 'name' => 'GitHub App', + 'description' => 'Connect a GitHub app for source control', + 'type' => 'source', + 'component' => 'source.github.create', + ]); + } + + $this->creatableItems = $items->toArray(); + } + public function render() { return view('livewire.global-search'); diff --git a/app/Livewire/Project/AddEmpty.php b/app/Livewire/Project/AddEmpty.php index 751b4945b..974f0608a 100644 --- a/app/Livewire/Project/AddEmpty.php +++ b/app/Livewire/Project/AddEmpty.php @@ -37,7 +37,12 @@ public function submit() 'uuid' => (string) new Cuid2, ]); - return redirect()->route('project.show', $project->uuid); + $productionEnvironment = $project->environments()->where('name', 'production')->first(); + + return redirect()->route('project.resource.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $productionEnvironment->uuid, + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 98d076ac0..b3df79008 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -208,7 +208,7 @@ private function customValidate() // Validate that disable_local_backup can only be true when S3 backup is enabled if ($this->backup->disable_local_backup && ! $this->backup->save_s3) { - throw new \Exception('Local backup can only be disabled when S3 backup is enabled.'); + $this->backup->disable_local_backup = $this->disableLocalBackup = false; } $isValid = validate_cron_expression($this->backup->frequency); diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 2f3aae8cf..0b6d8338b 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -202,11 +202,6 @@ public function server() public function render() { - return view('livewire.project.database.backup-executions', [ - 'checkboxes' => [ - ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'], - // ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently from SFTP Storage'], - ], - ]); + return view('livewire.project.database.backup-executions'); } } diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 5381fa78d..a27a3652f 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -21,6 +21,14 @@ public function mount() $this->projects = Project::ownedByCurrentTeam()->get()->map(function ($project) { $project->settingsRoute = route('project.edit', ['project_uuid' => $project->uuid]); $project->canUpdate = auth()->user()->can('update', $project); + $project->canCreateResource = auth()->user()->can('createAnyResource'); + $firstEnvironment = $project->environments->first(); + $project->addResourceRoute = $firstEnvironment + ? route('project.resource.create', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $firstEnvironment->uuid, + ]) + : null; return $project; }); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index a2071931e..27ecacb99 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -55,7 +55,7 @@ class GithubPrivateRepository extends Component public ?string $publish_directory = null; // In case of docker compose - public ?string $base_directory = null; + public ?string $base_directory = '/'; public ?string $docker_compose_location = '/docker-compose.yaml'; // End of docker compose @@ -198,6 +198,7 @@ public function submit() 'build_pack' => $this->build_pack, 'ports_exposes' => $this->port, 'publish_directory' => $this->publish_directory, + 'base_directory' => $this->base_directory, 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, @@ -212,7 +213,6 @@ public function submit() } if ($this->build_pack === 'dockercompose') { $application['docker_compose_location'] = $this->docker_compose_location; - $application['base_directory'] = $this->base_directory; } $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 639c025c7..07938d9d0 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -212,6 +212,12 @@ private function handleSingleSubmit($data) $environment = $this->createEnvironmentVariable($data); $environment->order = $maxOrder + 1; $environment->save(); + + // Clear computed property cache to force refresh + unset($this->environmentVariables); + unset($this->environmentVariablesPreview); + + $this->dispatch('success', 'Environment variable added.'); } private function createEnvironmentVariable($data) @@ -300,6 +306,9 @@ private function updateOrCreateVariables($isPreview, $variables) public function refreshEnvs() { $this->resource->refresh(); + // Clear computed property cache to force refresh + unset($this->environmentVariables); + unset($this->environmentVariablesPreview); $this->getDevView(); } } diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 41541f6b9..9438b7727 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -5,6 +5,7 @@ use App\Models\S3Storage; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Form extends Component @@ -91,9 +92,24 @@ public function submit() try { $this->authorize('update', $this->storage); - $this->validate(); - $this->testConnection(); + DB::transaction(function () { + $this->validate(); + $this->storage->save(); + + // Test connection with new values - if this fails, transaction will rollback + $this->storage->testConnection(shouldSave: false); + + // If we get here, the connection test succeeded + $this->storage->is_usable = true; + $this->storage->unusable_email_sent = false; + $this->storage->save(); + }); + + $this->dispatch('success', 'Storage settings updated and connection verified.'); } catch (\Throwable $e) { + // Refresh the model to revert UI to database values after rollback + $this->storage->refresh(); + return handleError($e, $this); } } diff --git a/app/Livewire/Team/Create.php b/app/Livewire/Team/Create.php index d3d27556c..cd15be67d 100644 --- a/app/Livewire/Team/Create.php +++ b/app/Livewire/Team/Create.php @@ -35,7 +35,7 @@ public function submit() 'personal_team' => false, ]); auth()->user()->teams()->attach($team, ['role' => 'admin']); - refreshSession(); + refreshSession($team); return redirect()->route('team.index'); } catch (\Throwable $e) { diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index b06dd5b45..c0298ecc8 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -8,6 +8,15 @@ class ScheduledDatabaseBackupExecution extends BaseModel { protected $guarded = []; + protected function casts(): array + { + return [ + 's3_uploaded' => 'boolean', + 'local_storage_deleted' => 'boolean', + 's3_storage_deleted' => 'boolean', + ]; + } + public function scheduledDatabaseBackup(): BelongsTo { return $this->belongsTo(ScheduledDatabaseBackup::class); diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php new file mode 100644 index 000000000..75ae2824c --- /dev/null +++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php @@ -0,0 +1,116 @@ +onQueue('high'); + + $this->name = $database->name; + $this->frequency = $backup->frequency; + + if ($backup->s3) { + $this->s3_storage_url = base_url().'/storages/'.$backup->s3->uuid; + } + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('backup_failure'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: Backup succeeded locally but S3 upload failed for {$this->database->name}"); + $mail->view('emails.backup-success-with-s3-warning', [ + 'name' => $this->name, + 'database_name' => $this->database_name, + 'frequency' => $this->frequency, + 's3_error' => $this->s3_error, + 's3_storage_url' => $this->s3_storage_url, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + $message = new DiscordMessage( + title: ':warning: Database backup succeeded locally, S3 upload failed', + description: "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.", + color: DiscordMessage::warningColor(), + ); + + $message->addField('Frequency', $this->frequency, true); + $message->addField('S3 Error', $this->s3_error); + + if ($this->s3_storage_url) { + $message->addField('S3 Storage', '[Check Configuration]('.$this->s3_storage_url.')'); + } + + return $message; + } + + public function toTelegram(): array + { + $message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} succeeded locally but failed to upload to S3.\n\nS3 Error:\n{$this->s3_error}"; + + if ($this->s3_storage_url) { + $message .= "\n\nCheck S3 Configuration: {$this->s3_storage_url}"; + } + + return [ + 'message' => $message, + ]; + } + + public function toPushover(): PushoverMessage + { + $message = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.

Frequency: {$this->frequency}.
S3 Error: {$this->s3_error}"; + + if ($this->s3_storage_url) { + $message .= "

s3_storage_url}\">Check S3 Configuration"; + } + + return new PushoverMessage( + title: 'Database backup succeeded locally, S3 upload failed', + level: 'warning', + message: $message, + ); + } + + public function toSlack(): SlackMessage + { + $title = 'Database backup succeeded locally, S3 upload failed'; + $description = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3."; + + $description .= "\n\n*Frequency:* {$this->frequency}"; + $description .= "\n\n*S3 Error:* {$this->s3_error}"; + + if ($this->s3_storage_url) { + $description .= "\n\n*S3 Storage:* <{$this->s3_storage_url}|Check Configuration>"; + } + + return new SlackMessage( + title: $title, + description: $description, + color: SlackMessage::warningColor() + ); + } +} diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 5dbd46b5e..aa7be3236 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -237,12 +237,11 @@ function removeOldBackups($backup): void { try { if ($backup->executions) { - // If local backup is disabled, mark all executions as having local storage deleted - if ($backup->disable_local_backup && $backup->save_s3) { - $backup->executions() - ->where('local_storage_deleted', false) - ->update(['local_storage_deleted' => true]); - } else { + // Delete old local backups (only if local backup is NOT disabled) + // Note: When disable_local_backup is enabled, each execution already marks its own + // local_storage_deleted status at the time of backup, so we don't need to retroactively + // update old executions + if (! $backup->disable_local_backup) { $localBackupsToDelete = deleteOldBackupsLocally($backup); if ($localBackupsToDelete->isNotEmpty()) { $backup->executions() @@ -261,18 +260,18 @@ function removeOldBackups($backup): void } } - // Delete executions where both local and S3 storage are marked as deleted - // or where only S3 is enabled and S3 storage is deleted - if ($backup->disable_local_backup && $backup->save_s3) { - $backup->executions() - ->where('s3_storage_deleted', true) - ->delete(); - } else { - $backup->executions() - ->where('local_storage_deleted', true) - ->where('s3_storage_deleted', true) - ->delete(); - } + // Delete execution records where all backup copies are gone + // Case 1: Both local and S3 backups are deleted + $backup->executions() + ->where('local_storage_deleted', true) + ->where('s3_storage_deleted', true) + ->delete(); + + // Case 2: Local backup is deleted and S3 was never used (s3_uploaded is null) + $backup->executions() + ->where('local_storage_deleted', true) + ->whereNull('s3_uploaded') + ->delete(); } catch (\Exception $e) { throw $e; diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 5bc1d005e..924bad307 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -108,7 +108,63 @@ function connectProxyToNetworks(Server $server) return $commands->flatten(); } -function generate_default_proxy_configuration(Server $server) +function extractCustomProxyCommands(Server $server, string $existing_config): array +{ + $custom_commands = []; + $proxy_type = $server->proxyType(); + + if ($proxy_type !== ProxyTypes::TRAEFIK->value || empty($existing_config)) { + return $custom_commands; + } + + try { + $yaml = Yaml::parse($existing_config); + $existing_commands = data_get($yaml, 'services.traefik.command', []); + + if (empty($existing_commands)) { + return $custom_commands; + } + + // Define default commands that Coolify generates + $default_command_prefixes = [ + '--ping=', + '--api.', + '--entrypoints.http.address=', + '--entrypoints.https.address=', + '--entrypoints.http.http.encodequerysemicolons=', + '--entryPoints.http.http2.maxConcurrentStreams=', + '--entrypoints.https.http.encodequerysemicolons=', + '--entryPoints.https.http2.maxConcurrentStreams=', + '--entrypoints.https.http3', + '--providers.file.', + '--certificatesresolvers.', + '--providers.docker', + '--providers.swarm', + '--log.level=', + '--accesslog.', + ]; + + // Extract commands that don't match default prefixes (these are custom) + foreach ($existing_commands as $command) { + $is_default = false; + foreach ($default_command_prefixes as $prefix) { + if (str_starts_with($command, $prefix)) { + $is_default = true; + break; + } + } + if (! $is_default) { + $custom_commands[] = $command; + } + } + } catch (\Exception $e) { + // If we can't parse the config, return empty array + // Silently fail to avoid breaking the proxy regeneration + } + + return $custom_commands; +} +function generateDefaultProxyConfiguration(Server $server, array $custom_commands = []) { $proxy_path = $server->proxyPath(); $proxy_type = $server->proxyType(); @@ -228,6 +284,13 @@ function generate_default_proxy_configuration(Server $server) $config['services']['traefik']['command'][] = '--providers.docker=true'; $config['services']['traefik']['command'][] = '--providers.docker.exposedbydefault=false'; } + + // Append custom commands (e.g., trustedIPs for Cloudflare) + if (! empty($custom_commands)) { + foreach ($custom_commands as $custom_command) { + $config['services']['traefik']['command'][] = $custom_command; + } + } } elseif ($proxy_type === 'CADDY') { $config = [ 'networks' => $array_of_networks->toArray(), diff --git a/database/migrations/2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table.php b/database/migrations/2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table.php new file mode 100644 index 000000000..d80f2621b --- /dev/null +++ b/database/migrations/2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table.php @@ -0,0 +1,28 @@ +boolean('s3_uploaded')->nullable()->after('filename'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('scheduled_database_backup_executions', function (Blueprint $table) { + $table->dropColumn('s3_uploaded'); + }); + } +}; diff --git a/resources/views/emails/backup-success-with-s3-warning.blade.php b/resources/views/emails/backup-success-with-s3-warning.blade.php new file mode 100644 index 000000000..5d2f25851 --- /dev/null +++ b/resources/views/emails/backup-success-with-s3-warning.blade.php @@ -0,0 +1,9 @@ + +Database backup for {{ $name }} @if($database_name)(db:{{ $database_name }})@endif with frequency of {{ $frequency }} succeeded locally but failed to upload to S3. + +S3 Error: {{ $s3_error }} + +@if($s3_storage_url) +Check S3 Configuration: {{ $s3_storage_url }} +@endif + diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 7a0cbec4a..2c7ed4076 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -14,8 +14,24 @@ @endif -
-

Projects

+
+
+

Projects

+ @if ($projects->count() > 0) + + + + + + + @endif +
@if ($projects->count() > 0)
@foreach ($projects as $project) @@ -65,7 +81,23 @@
-

Servers

+
+

Servers

+ @if ($servers->count() > 0 && $privateKeys->count() > 0) + + + + + + + @endif +
@if ($servers->count() > 0)
@foreach ($servers as $server) diff --git a/resources/views/livewire/deployments-indicator.blade.php b/resources/views/livewire/deployments-indicator.blade.php index 7f3cc23ed..746bc970c 100644 --- a/resources/views/livewire/deployments-indicator.blade.php +++ b/resources/views/livewire/deployments-indicator.blade.php @@ -1,6 +1,6 @@
+}" class="fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4"> @if ($this->deploymentCount > 0)
diff --git a/resources/views/livewire/destination/index.blade.php b/resources/views/livewire/destination/index.blade.php index d0a21c1f0..0e6f3a005 100644 --- a/resources/views/livewire/destination/index.blade.php +++ b/resources/views/livewire/destination/index.blade.php @@ -13,7 +13,7 @@ @endif
Network endpoints to deploy your resources.
-
+
@forelse ($servers as $server) @forelse ($server->destinations() as $destination) @if ($destination->getMorphClass() === 'App\Models\StandaloneDocker') diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index 6e60b7a2d..af46624d3 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -80,6 +80,20 @@ document.removeEventListener('keydown', escapeKeyHandler); document.removeEventListener('keydown', arrowKeyHandler); }); + + // Watch for auto-open resource + this.$watch('$wire.autoOpenResource', value => { + if (value) { + // Close search modal first + this.closeModal(); + // Open the specific resource modal after a short delay + setTimeout(() => { + this.$dispatch('open-create-modal-' + value); + // Reset the value so it can trigger again + @this.set('autoOpenResource', null); + }, 150); + } + }); } }"> @@ -106,8 +120,8 @@ class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen
+ @endforeach +
+ @elseif (strlen($searchQuery) >= 2 && count($searchResults) > 0)
@foreach ($searchResults as $index => $result) @endforeach
- @elseif (strlen($searchQuery) >= 2 && count($searchResults) === 0) + @elseif (strlen($searchQuery) >= 2 && count($searchResults) === 0 && !$autoOpenResource)

@@ -217,4 +295,257 @@ class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center"

+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 2888b82a8..11031b7f2 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -3,7 +3,7 @@ Profile | Coolify

Profile

-
Your user profile settings.
+
Your user profile settings.

General

diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index 94245643a..30eef5976 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -51,12 +51,14 @@ class="flex flex-col gap-4"> data_get($execution, 'status') === 'running', 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200 dark:shadow-red-900/5' => data_get($execution, 'status') === 'failed', + 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200 dark:shadow-amber-900/5' => + data_get($execution, 'status') === 'success' && data_get($execution, 's3_uploaded') === false, 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200 dark:shadow-green-900/5' => - data_get($execution, 'status') === 'success', + data_get($execution, 'status') === 'success' && data_get($execution, 's3_uploaded') !== false, ])> @php $statusText = match (data_get($execution, 'status')) { - 'success' => 'Success', + 'success' => data_get($execution, 's3_uploaded') === false ? 'Success (S3 Warning)' : 'Success', 'running' => 'In Progress', 'failed' => 'Failed', default => ucfirst(data_get($execution, 'status')), @@ -120,20 +122,15 @@ class="flex flex-col gap-4"> Local Storage - @if ($backup->save_s3) + @if (data_get($execution, 's3_uploaded') !== null) !data_get( - $execution, - 's3_storage_deleted', - false), - 'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get( - $execution, - 's3_storage_deleted', - false), + 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => data_get($execution, 's3_uploaded') === false && !data_get($execution, 's3_storage_deleted', false), + 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false), + 'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get($execution, 's3_storage_deleted', false), ])> - @if (!data_get($execution, 's3_storage_deleted', false)) + @if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false)) Download @endif + @php + $executionCheckboxes = []; + $deleteActions = []; + + if (!data_get($execution, 'local_storage_deleted', false)) { + $deleteActions[] = 'This backup will be permanently deleted from local storage.'; + } + + if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false)) { + $executionCheckboxes[] = ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage']; + } + + if (empty($deleteActions)) { + $deleteActions[] = 'This backup execution record will be deleted.'; + } + @endphp
diff --git a/resources/views/livewire/project/index.blade.php b/resources/views/livewire/project/index.blade.php index ceace9587..fd6e4c422 100644 --- a/resources/views/livewire/project/index.blade.php +++ b/resources/views/livewire/project/index.blade.php @@ -11,49 +11,29 @@ @endcan
All your projects are here.
-
- -
- - -
- -
diff --git a/resources/views/livewire/server/index.blade.php b/resources/views/livewire/server/index.blade.php index c244b3d40..3e258c2da 100644 --- a/resources/views/livewire/server/index.blade.php +++ b/resources/views/livewire/server/index.blade.php @@ -11,7 +11,7 @@ @endcan
All your servers are here.
-
+
@forelse ($servers as $server)
Set Team / Project / Environment wide variables.
-
+
Team wide
diff --git a/resources/views/livewire/storage/index.blade.php b/resources/views/livewire/storage/index.blade.php index 4ef39c256..32b74370d 100644 --- a/resources/views/livewire/storage/index.blade.php +++ b/resources/views/livewire/storage/index.blade.php @@ -11,7 +11,7 @@ @endcan
S3 storages for backups.
-
+
@forelse ($s3 as $storage)
diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index b37edba46..eed892fdc 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -11,7 +11,7 @@ @endcan
Git sources for your applications.
-
+
@forelse ($sources as $source) @if ($source->getMorphClass() === 'App\Models\GithubApp') toBeTrue(); +}); + +test('s3_uploaded column is nullable', function () { + $columns = Schema::getColumns('scheduled_database_backup_executions'); + $s3UploadedColumn = collect($columns)->firstWhere('name', 's3_uploaded'); + + expect($s3UploadedColumn)->not->toBeNull(); + expect($s3UploadedColumn['nullable'])->toBeTrue(); +}); + +test('scheduled database backup execution model casts s3_uploaded correctly', function () { + $model = new ScheduledDatabaseBackupExecution; + $casts = $model->getCasts(); + + expect($casts)->toHaveKey('s3_uploaded'); + expect($casts['s3_uploaded'])->toBe('boolean'); +}); + +test('scheduled database backup execution model casts storage deletion fields correctly', function () { + $model = new ScheduledDatabaseBackupExecution; + $casts = $model->getCasts(); + + expect($casts)->toHaveKey('local_storage_deleted'); + expect($casts['local_storage_deleted'])->toBe('boolean'); + expect($casts)->toHaveKey('s3_storage_deleted'); + expect($casts['s3_storage_deleted'])->toBe('boolean'); +}); diff --git a/tests/Unit/ProxyCustomCommandsTest.php b/tests/Unit/ProxyCustomCommandsTest.php new file mode 100644 index 000000000..d68ca6dc7 --- /dev/null +++ b/tests/Unit/ProxyCustomCommandsTest.php @@ -0,0 +1,137 @@ + [ + 'traefik' => [ + 'command' => [ + '--ping=true', + '--api.dashboard=true', + '--entrypoints.http.address=:80', + '--entrypoints.https.address=:443', + '--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22', + '--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22', + '--providers.docker=true', + '--providers.docker.exposedbydefault=false', + ], + ], + ], + ]; + + $yamlConfig = Yaml::dump($existingConfig); + + // Mock a server with Traefik proxy type + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value); + + $customCommands = extractCustomProxyCommands($server, $yamlConfig); + + expect($customCommands) + ->toBeArray() + ->toHaveCount(2) + ->toContain('--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22') + ->toContain('--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22'); +}); + +it('returns empty array when only default commands exist', function () { + // Config with only default commands + $existingConfig = [ + 'services' => [ + 'traefik' => [ + 'command' => [ + '--ping=true', + '--api.dashboard=true', + '--entrypoints.http.address=:80', + '--entrypoints.https.address=:443', + '--providers.docker=true', + '--providers.docker.exposedbydefault=false', + ], + ], + ], + ]; + + $yamlConfig = Yaml::dump($existingConfig); + + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value); + + $customCommands = extractCustomProxyCommands($server, $yamlConfig); + + expect($customCommands)->toBeArray()->toBeEmpty(); +}); + +it('handles invalid yaml gracefully', function () { + $invalidYaml = 'this is not: valid: yaml::: content'; + + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value); + + $customCommands = extractCustomProxyCommands($server, $invalidYaml); + + expect($customCommands)->toBeArray()->toBeEmpty(); +}); + +it('returns empty array for caddy proxy type', function () { + $existingConfig = [ + 'services' => [ + 'caddy' => [ + 'environment' => ['SOME_VAR=value'], + ], + ], + ]; + + $yamlConfig = Yaml::dump($existingConfig); + + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn(ProxyTypes::CADDY->value); + + $customCommands = extractCustomProxyCommands($server, $yamlConfig); + + expect($customCommands)->toBeArray()->toBeEmpty(); +}); + +it('returns empty array when config is empty', function () { + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value); + + $customCommands = extractCustomProxyCommands($server, ''); + + expect($customCommands)->toBeArray()->toBeEmpty(); +}); + +it('correctly identifies multiple custom command types', function () { + $existingConfig = [ + 'services' => [ + 'traefik' => [ + 'command' => [ + '--ping=true', + '--api.dashboard=true', + '--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20', + '--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20', + '--entrypoints.http.forwardedHeaders.insecure=true', + '--metrics.prometheus=true', + '--providers.docker=true', + ], + ], + ], + ]; + + $yamlConfig = Yaml::dump($existingConfig); + + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value); + + $customCommands = extractCustomProxyCommands($server, $yamlConfig); + + expect($customCommands) + ->toBeArray() + ->toHaveCount(4) + ->toContain('--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20') + ->toContain('--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20') + ->toContain('--entrypoints.http.forwardedHeaders.insecure=true') + ->toContain('--metrics.prometheus=true'); +});