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/CHANGELOG.md b/CHANGELOG.md index 3447b223b..aefabfd29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,78 @@ # Changelog All notable changes to this project will be documented in this file. -## [unreleased] +## [4.0.0-beta.434] - 2025-10-03 + +### 🚀 Features + +- *(deployments)* Enhance Docker build argument handling for multiline variables +- *(deployments)* Add log copying functionality to clipboard in dev +- *(deployments)* Generate SERVICE_NAME environment variables from Docker Compose services + +### 🐛 Bug Fixes + +- *(deployments)* Enhance builder container management and environment variable handling + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update version numbers for Coolify releases +- *(versions)* Bump Coolify stable version to 4.0.0-beta.434 + +## [4.0.0-beta.433] - 2025-10-01 + +### 🚀 Features + +- *(user-deletion)* Implement file locking to prevent concurrent user deletions and enhance error handling +- *(ui)* Enhance resource operations interface with dynamic selection for cloning and moving resources +- *(global-search)* Integrate projects and environments into global search functionality +- *(storage)* Consolidate storage management into a single component with enhanced UI +- *(deployments)* Add support for Coolify variables in Dockerfile + +### 🐛 Bug Fixes + +- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow +- *(ui)* Update docker registry image helper text for clarity +- *(ui)* Correct HTML structure and improve clarity in Docker cleanup options +- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow +- *(api)* Correct OpenAPI schema annotations for array items +- *(ui)* Improve queued deployment status readability in dark mode +- *(git)* Handle additional repository URL cases for 'tangled' and improve branch assignment logic +- *(git)* Enhance error handling for missing branch information during deployment +- *(git)* Trim whitespace from repository, branch, and commit SHA fields +- *(deployments)* Order deployments by ID for consistent retrieval + +### 💼 Other + +- *(storage)* Enhance file storage management with new properties and UI improvements +- *(core)* Update projects property type and enhance UI styling +- *(components)* Adjust SVG icon sizes for consistency across applications and services +- *(components)* Auto-focus first input in modal on open +- *(styles)* Enhance focus styles for buttons and links +- *(components)* Enhance close button accessibility in modal + +### 🚜 Refactor + +- *(global-search)* Change event listener to window level for global search modal +- *(dashboard)* Remove deployment loading logic and introduce DeploymentsIndicator component for better UI management +- *(dashboard)* Replace project navigation method with direct link in UI +- *(global-search)* Improve event handling and cleanup in global search component + +### 📚 Documentation + +- Update changelog +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update coolify version to 4.0.0-beta.433 and nightly version to 4.0.0-beta.434 in configuration files + +## [4.0.0-beta.432] - 2025-09-29 ### 🚀 Features @@ -188,6 +259,7 @@ ## [4.0.0-beta.427] - 2025-09-15 ### 🚀 Features +- Add Ente Photos service template - *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic - *(ui)* Display current version in settings dropdown and update UI accordingly - *(settings)* Add option to restrict PR deployments to repository members and contributors 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/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ce9e723d4..065d7f767 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1512,9 +1512,32 @@ private function create_application(Request $request, $type) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - if (! $request->docker_registry_image_tag) { - $request->offsetSet('docker_registry_image_tag', 'latest'); + // Process docker image name and tag for SHA256 digests + $dockerImageName = $request->docker_registry_image_name; + $dockerImageTag = $request->docker_registry_image_tag; + + // Strip 'sha256:' prefix if user provided it in the tag + if ($dockerImageTag) { + $dockerImageTag = preg_replace('/^sha256:/i', '', trim($dockerImageTag)); } + + // Remove @sha256 from image name if user added it + if ($dockerImageName) { + $dockerImageName = preg_replace('/@sha256$/i', '', trim($dockerImageName)); + } + + // Check if tag is a valid SHA256 hash (64 hex characters) + $isSha256Hash = $dockerImageTag && preg_match('/^[a-f0-9]{64}$/i', $dockerImageTag); + + // Append @sha256 to image name if using digest and not already present + if ($isSha256Hash && ! str_ends_with($dockerImageName, '@sha256')) { + $dockerImageName .= '@sha256'; + } + + // Set processed values back to request + $request->offsetSet('docker_registry_image_name', $dockerImageName); + $request->offsetSet('docker_registry_image_tag', $dockerImageTag ?: 'latest'); + $application = new Application; removeUnnecessaryFieldsFromRequest($request); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index fcdb472ee..8ffaabde5 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -88,8 +88,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $is_this_additional_server = false; - private bool $is_laravel_or_symfony = false; - private ?ApplicationPreview $preview = null; private ?string $git_type = null; @@ -118,16 +116,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private $env_args; - private $environment_variables; - private $env_nixpacks_args; private $docker_compose; private $docker_compose_base64; - private ?string $env_filename = null; - private ?string $nixpacks_plan = null; private Collection $nixpacks_plan_json; @@ -505,7 +499,12 @@ private function deploy_dockerimage_buildpack() } else { $this->dockerImageTag = $this->application->docker_registry_image_tag; } - $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}."); + + // Check if this is an image hash deployment + $isImageHash = str($this->dockerImageTag)->startsWith('sha256-'); + $displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}"; + + $this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}."); $this->generate_image_names(); $this->prepare_builder_image(); $this->generate_compose_file(); @@ -573,7 +572,6 @@ private function deploy_docker_compose_buildpack() if ($this->application->settings->is_raw_compose_deployment_enabled) { $this->application->oldRawParser(); $yaml = $composeFile = $this->application->docker_compose_raw; - $this->generate_runtime_environment_variables(); // For raw compose, we cannot automatically add secrets configuration // User must define it manually in their docker-compose file @@ -582,16 +580,14 @@ private function deploy_docker_compose_buildpack() } } else { $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); - $this->generate_runtime_environment_variables(); - if (filled($this->env_filename)) { - $services = collect(data_get($composeFile, 'services', [])); - $services = $services->map(function ($service, $name) { - $service['env_file'] = [$this->env_filename]; + // Always add .env file to services + $services = collect(data_get($composeFile, 'services', [])); + $services = $services->map(function ($service, $name) { + $service['env_file'] = ['.env']; - return $service; - }); - $composeFile['services'] = $services->toArray(); - } + return $service; + }); + $composeFile['services'] = $services->toArray(); if (empty($composeFile)) { $this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.'); $this->fail('Failed to parse docker-compose file.'); @@ -617,6 +613,9 @@ private function deploy_docker_compose_buildpack() // Build new container to limit downtime. $this->application_deployment_queue->addLogEntry('Pulling & building required images.'); + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + if ($this->docker_compose_custom_build_command) { // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported $build_command = $this->docker_compose_custom_build_command; @@ -632,9 +631,8 @@ private function deploy_docker_compose_buildpack() if ($this->dockerBuildkitSupported) { $command = "DOCKER_BUILDKIT=1 {$command}"; } - if (filled($this->env_filename)) { - $command .= " --env-file {$this->workdir}/{$this->env_filename}"; - } + // Use build-time .env file from /artifacts (outside Docker context to prevent it from being in the image) + $command .= ' --env-file /artifacts/build-time.env'; if ($this->force_rebuild) { $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache"; } else { @@ -654,6 +652,10 @@ private function deploy_docker_compose_buildpack() ); } + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->stop_running_container(force: true); $this->application_deployment_queue->addLogEntry('Starting new application.'); $networkId = $this->application->uuid; @@ -687,9 +689,8 @@ private function deploy_docker_compose_buildpack() $this->docker_compose_location = '/docker-compose.yaml'; $command = "{$this->coolify_variables} docker compose"; - if (filled($this->env_filename)) { - $command .= " --env-file {$server_workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$server_workdir}/.env"; $command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( ['command' => $command, 'hidden' => true], @@ -704,9 +705,8 @@ private function deploy_docker_compose_buildpack() } else { $command = "{$this->coolify_variables} docker compose"; if ($this->preserveRepository) { - if (filled($this->env_filename)) { - $command .= " --env-file {$server_workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$server_workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $this->write_deployment_configurations(); @@ -714,9 +714,8 @@ private function deploy_docker_compose_buildpack() ['command' => $command, 'hidden' => true], ); } else { - if (filled($this->env_filename)) { - $command .= " --env-file {$this->workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$this->workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $command), 'hidden' => true], @@ -750,9 +749,18 @@ private function deploy_dockerfile_buildpack() } $this->cleanup_git(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); $this->add_build_env_variables_to_dockerfile(); $this->build_image(); + + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); } @@ -773,15 +781,18 @@ private function deploy_nixpacks_buildpack() } } $this->clone_repository(); - $this->detect_laravel_symfony(); $this->cleanup_git(); $this->generate_nixpacks_confs(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build for Nixpacks + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); $this->build_image(); // For Nixpacks, save runtime environment variables AFTER the build - // to prevent them from being accessible during the build process + // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); @@ -805,7 +816,16 @@ private function deploy_static_buildpack() $this->clone_repository(); $this->cleanup_git(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->build_static_image(); + + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); } @@ -937,7 +957,13 @@ private function generate_image_names() $this->production_image_name = "{$this->application->uuid}:latest"; } } elseif ($this->application->build_pack === 'dockerimage') { - $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; + // Check if this is an image hash deployment + if (str($this->dockerImageTag)->startsWith('sha256-')) { + $hash = str($this->dockerImageTag)->after('sha256-'); + $this->production_image_name = "{$this->dockerImage}@sha256:{$hash}"; + } else { + $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; + } } elseif ($this->pull_request_id !== 0) { if ($this->application->docker_registry_image_name) { $this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build"; @@ -979,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(); @@ -988,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(); @@ -1052,8 +1086,6 @@ private function generate_runtime_environment_variables() $envs->push($key.'='.$item); }); if ($this->pull_request_id === 0) { - $this->env_filename = '.env'; - // Generate SERVICE_ variables first for dockercompose if ($this->build_pack === 'dockercompose') { $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); @@ -1112,8 +1144,6 @@ private function generate_runtime_environment_variables() $envs->push('HOST=0.0.0.0'); } } else { - $this->env_filename = '.env'; - // Generate SERVICE_ variables first for dockercompose preview if ($this->build_pack === 'dockercompose') { $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); @@ -1168,99 +1198,250 @@ private function generate_runtime_environment_variables() $envs->push('HOST=0.0.0.0'); } } - if ($envs->isEmpty()) { - if ($this->env_filename) { - if ($this->use_build_server) { - $this->server = $this->original_server; - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - $this->server = $this->build_server; - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - } else { - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - } - } - $this->env_filename = null; - } else { - // For Nixpacks builds, we save the .env file AFTER the build to prevent - // runtime-only variables from being accessible during the build process - if ($this->application->build_pack !== 'nixpacks' || $this->skip_build) { - $envs_base64 = base64_encode($envs->implode("\n")); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), - ], - ); - if ($this->use_build_server) { - $this->server = $this->original_server; - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - $this->server = $this->build_server; - } else { - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - } - } - } - $this->environment_variables = $envs; + // Return the generated environment variables instead of storing them globally + return $envs; } private function save_runtime_environment_variables() { - // This method saves the .env file with runtime variables - // It should be called AFTER the build for Nixpacks to prevent runtime-only variables - // from being accessible during the build process + // This method saves the .env file with ALL runtime variables + // For builds, it should be called AFTER the build to include runtime-only variables - if ($this->environment_variables && $this->environment_variables->isNotEmpty() && $this->env_filename) { - $envs_base64 = base64_encode($this->environment_variables->implode("\n")); + // Generate runtime environment variables locally + $environment_variables = $this->generate_runtime_environment_variables(); - // Write .env file to workdir (for container runtime) + // Handle empty environment variables + if ($environment_variables->isEmpty()) { + // For Docker Compose, we need to create an empty .env file + // because we always reference it in the compose file + if ($this->build_pack === 'dockercompose') { + $this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).'); + + // Create empty .env file + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "touch $this->workdir/.env"), + ] + ); + + // Also create in configuration directory + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + "touch $this->configuration_dir/.env", + ] + ); + $this->server = $this->build_server; + } else { + $this->execute_remote_command( + [ + "touch $this->configuration_dir/.env", + ] + ); + } + } else { + // For non-Docker Compose deployments, clean up any existing .env files + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/.env", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + $this->server = $this->build_server; + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/.env", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + } else { + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/.env", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + } + } + + return; + } + + // Write the environment variables to file + $envs_base64 = base64_encode($environment_variables->implode("\n")); + + // Write .env file to workdir (for container runtime) + $this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for build phase.', hidden: true); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"), + 'hidden' => true, + + ] + ); + + // Write .env file to configuration directory + if ($this->use_build_server) { + $this->server = $this->original_server; $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", + ] + ); + $this->server = $this->build_server; + } else { + $this->execute_remote_command( + [ + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", + ] + ); + } + } + + private function generate_buildtime_environment_variables() + { + $envs = collect([]); + $coolify_envs = $this->generate_coolify_env_variables(); + + // Add COOLIFY variables + $coolify_envs->each(function ($item, $key) use ($envs) { + $envs->push($key.'='.$item); + }); + + // Add SERVICE_NAME variables for Docker Compose builds + if ($this->build_pack === 'dockercompose') { + if ($this->pull_request_id === 0) { + // Generate SERVICE_NAME for dockercompose services from processed compose + if ($this->application->settings->is_raw_compose_deployment_enabled) { + $dockerCompose = Yaml::parse($this->application->docker_compose_raw); + } else { + $dockerCompose = Yaml::parse($this->application->docker_compose); + } + $services = data_get($dockerCompose, 'services', []); + foreach ($services as $serviceName => $_) { + $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName); + } + + // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments + $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); + } + } + } else { + // Generate SERVICE_NAME for preview deployments + $rawDockerCompose = Yaml::parse($this->application->docker_compose_raw); + $rawServices = data_get($rawDockerCompose, 'services', []); + foreach ($rawServices as $rawServiceName => $_) { + $envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)); + } + + // Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains + $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); + } + } + } + } + + // Add build-time user variables only + if ($this->pull_request_id === 0) { + $sorted_environment_variables = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) // ONLY build-time variables + ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') + ->get(); + + // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these + if ($this->build_pack === 'dockercompose') { + $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + }); + } + + foreach ($sorted_environment_variables as $env) { + $envs->push($env->key.'='.$env->real_value); + } + } else { + $sorted_environment_variables = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) // ONLY build-time variables + ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') + ->get(); + + // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values + if ($this->build_pack === 'dockercompose') { + $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + }); + } + + foreach ($sorted_environment_variables as $env) { + $envs->push($env->key.'='.$env->real_value); + } + } + + // Return the generated environment variables + return $envs; + } + + private function save_buildtime_environment_variables() + { + // Generate build-time environment variables locally + $environment_variables = $this->generate_buildtime_environment_variables(); + + // Save .env file for build phase in /artifacts to prevent it from being copied into Docker images + if ($environment_variables->isNotEmpty()) { + $envs_base64 = base64_encode($environment_variables->implode("\n")); + + $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true); + + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee /artifacts/build-time.env > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build-time.env'), + 'hidden' => true, ], ); + } elseif ($this->build_pack === 'dockercompose') { + // For Docker Compose, create an empty .env file even if there are no build-time variables + // This ensures the file exists when referenced in docker-compose commands + $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); - // Write .env file to configuration directory - if ($this->use_build_server) { - $this->server = $this->original_server; - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - $this->server = $this->build_server; - } else { - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - } + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, 'touch /artifacts/build-time.env'), + ] + ); } } @@ -1288,24 +1469,34 @@ private function elixir_finetunes() } } - private function symfony_finetunes(&$parsed) + private function laravel_finetunes() { - $installCmds = data_get($parsed, 'phases.install.cmds', []); - $variables = data_get($parsed, 'variables', []); + if ($this->pull_request_id === 0) { + $envType = 'environment_variables'; + } else { + $envType = 'environment_variables_preview'; + } + $nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first(); + $nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first(); - $envCommands = []; - foreach (array_keys($variables) as $key) { - $envCommands[] = "printf '%s=%s\\n' ".escapeshellarg($key)." \"\${$key}\" >> /app/.env"; + if (! $nixpacks_php_fallback_path) { + $nixpacks_php_fallback_path = new EnvironmentVariable; + $nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH'; + $nixpacks_php_fallback_path->value = '/index.php'; + $nixpacks_php_fallback_path->resourceable_id = $this->application->id; + $nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application'; + $nixpacks_php_fallback_path->save(); + } + if (! $nixpacks_php_root_dir) { + $nixpacks_php_root_dir = new EnvironmentVariable; + $nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR'; + $nixpacks_php_root_dir->value = '/app/public'; + $nixpacks_php_root_dir->resourceable_id = $this->application->id; + $nixpacks_php_root_dir->resourceable_type = 'App\Models\Application'; + $nixpacks_php_root_dir->save(); } - if (! empty($envCommands)) { - $createEnvCmd = 'touch /app/.env'; - - array_unshift($installCmds, $createEnvCmd); - array_splice($installCmds, 1, 0, $envCommands); - - data_set($parsed, 'phases.install.cmds', $installCmds); - } + return [$nixpacks_php_fallback_path, $nixpacks_php_root_dir]; } private function rolling_update() @@ -1460,21 +1651,23 @@ private function deploy_pull_request() $this->prepare_builder_image(); $this->check_git_if_build_needed(); $this->clone_repository(); - $this->detect_laravel_symfony(); $this->cleanup_git(); if ($this->application->build_pack === 'nixpacks') { $this->generate_nixpacks_confs(); } $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); if ($this->application->build_pack === 'dockerfile') { $this->add_build_env_variables_to_dockerfile(); } $this->build_image(); - // For Nixpacks, save runtime environment variables AFTER the build - if ($this->application->build_pack === 'nixpacks') { - $this->save_runtime_environment_variables(); - } + + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } @@ -1509,7 +1702,7 @@ private function create_workdir() } } - private function prepare_builder_image() + private function prepare_builder_image(bool $firstTry = true) { $this->checkForCancellation(); $settings = instanceSettings(); @@ -1520,7 +1713,6 @@ private function prepare_builder_image() $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); $env_flags = $this->generate_docker_env_flags_for_secrets(); - if ($this->use_build_server) { if ($this->dockerConfigFileExists === 'NOK') { throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); @@ -1533,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( [ @@ -1547,6 +1744,18 @@ private function prepare_builder_image() $this->run_pre_deployment_command(); } + private function restart_builder_container_with_actual_commit() + { + // Stop and remove the current helper container + $this->graceful_shutdown_container($this->deployment_uuid); + + // Clear cached env_args to force regeneration with actual SOURCE_COMMIT value + $this->env_args = null; + + // Restart the helper container with updated environment variables (including actual SOURCE_COMMIT) + $this->prepare_builder_image(firstTry: false); + } + private function deploy_to_additional_destinations() { if ($this->application->additional_networks->count() === 0) { @@ -1615,6 +1824,8 @@ private function set_coolify_variables() if (isset($this->application->git_branch)) { $this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} "; } + $this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} "; + $this->coolify_variables .= "COOLIFY_CONTAINER_NAME={$this->container_name} "; } private function check_git_if_build_needed() @@ -1677,27 +1888,17 @@ private function check_git_if_build_needed() ); } if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) { - $output = $this->saved_outputs->get('git_commit_sha'); - - if ($output->isEmpty() || ! str($output)->contains("\t")) { - $errorMessage = "Failed to find branch '{$local_branch}' in repository.\n\n"; - $errorMessage .= "Please verify:\n"; - $errorMessage .= "- The branch name is correct\n"; - $errorMessage .= "- The branch exists in the repository\n"; - $errorMessage .= "- You have access to the repository\n"; - - if ($this->pull_request_id !== 0) { - $errorMessage .= "- The pull request #{$this->pull_request_id} exists and is accessible\n"; - } - - throw new \RuntimeException($errorMessage); - } - - $this->commit = $output->before("\t"); + $this->commit = $this->saved_outputs->get('git_commit_sha')->before("\t"); $this->application_deployment_queue->commit = $this->commit; $this->application_deployment_queue->save(); } $this->set_coolify_variables(); + + // Restart helper container with actual SOURCE_COMMIT value + if ($this->application->settings->use_build_secrets && $this->commit !== 'HEAD') { + $this->application_deployment_queue->addLogEntry('Restarting helper container with actual SOURCE_COMMIT value.'); + $this->restart_builder_container_with_actual_commit(); + } } private function clone_repository() @@ -1743,78 +1944,6 @@ private function generate_git_import_commands() return $commands; } - private function detect_laravel_symfony() - { - if ($this->application->build_pack !== 'nixpacks') { - return; - } - - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/composer.json && echo 'exists' || echo 'not-exists'"), - 'save' => 'composer_json_exists', - 'hidden' => true, - ]); - - if ($this->saved_outputs->get('composer_json_exists') == 'exists') { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, 'grep -E -q "laravel/framework|symfony/dotenv|symfony/framework-bundle|symfony/flex" '.$this->workdir.'/composer.json 2>/dev/null && echo "true" || echo "false"'), - 'save' => 'is_laravel_or_symfony', - 'hidden' => true, - ]); - - $this->is_laravel_or_symfony = $this->saved_outputs->get('is_laravel_or_symfony') == 'true'; - - if ($this->is_laravel_or_symfony) { - $this->application_deployment_queue->addLogEntry('Laravel/Symfony framework detected. Setting NIXPACKS PHP variables.'); - $this->ensure_nixpacks_php_variables(); - } - } - } - - private function ensure_nixpacks_php_variables() - { - if ($this->pull_request_id === 0) { - $envType = 'environment_variables'; - } else { - $envType = 'environment_variables_preview'; - } - - $nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first(); - $nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first(); - - $created_new = false; - if (! $nixpacks_php_fallback_path) { - $nixpacks_php_fallback_path = new EnvironmentVariable; - $nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH'; - $nixpacks_php_fallback_path->value = '/index.php'; - $nixpacks_php_fallback_path->is_buildtime = true; - $nixpacks_php_fallback_path->is_preview = $this->pull_request_id !== 0; - $nixpacks_php_fallback_path->resourceable_id = $this->application->id; - $nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application'; - $nixpacks_php_fallback_path->save(); - $this->application_deployment_queue->addLogEntry('Created NIXPACKS_PHP_FALLBACK_PATH environment variable.'); - $created_new = true; - } - if (! $nixpacks_php_root_dir) { - $nixpacks_php_root_dir = new EnvironmentVariable; - $nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR'; - $nixpacks_php_root_dir->value = '/app/public'; - $nixpacks_php_root_dir->is_buildtime = true; - $nixpacks_php_root_dir->is_preview = $this->pull_request_id !== 0; - $nixpacks_php_root_dir->resourceable_id = $this->application->id; - $nixpacks_php_root_dir->resourceable_type = 'App\Models\Application'; - $nixpacks_php_root_dir->save(); - $this->application_deployment_queue->addLogEntry('Created NIXPACKS_PHP_ROOT_DIR environment variable.'); - $created_new = true; - } - - if ($this->pull_request_id === 0) { - $this->application->load(['nixpacks_environment_variables', 'environment_variables']); - } else { - $this->application->load(['nixpacks_environment_variables_preview', 'environment_variables_preview']); - } - } - private function cleanup_git() { $this->execute_remote_command( @@ -1824,51 +1953,30 @@ private function cleanup_git() private function generate_nixpacks_confs() { + $nixpacks_command = $this->nixpacks_build_cmd(); + $this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command"); + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true], [executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true], ); - if ($this->saved_outputs->get('nixpacks_type')) { $this->nixpacks_type = $this->saved_outputs->get('nixpacks_type'); if (str($this->nixpacks_type)->isEmpty()) { throw new RuntimeException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers'); } } - $nixpacks_command = $this->nixpacks_build_cmd(); - $this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command"); - - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true], - ); if ($this->saved_outputs->get('nixpacks_plan')) { $this->nixpacks_plan = $this->saved_outputs->get('nixpacks_plan'); if ($this->nixpacks_plan) { $this->application_deployment_queue->addLogEntry("Found application type: {$this->nixpacks_type}."); $this->application_deployment_queue->addLogEntry("If you need further customization, please check the documentation of Nixpacks: https://nixpacks.com/docs/providers/{$this->nixpacks_type}"); - $parsed = json_decode($this->nixpacks_plan); + $parsed = json_decode($this->nixpacks_plan, true); // Do any modifications here // We need to generate envs here because nixpacks need to know to generate a proper Dockerfile $this->generate_env_variables(); - - if ($this->is_laravel_or_symfony) { - if ($this->pull_request_id === 0) { - $envType = 'environment_variables'; - } else { - $envType = 'environment_variables_preview'; - } - $nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first(); - $nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first(); - - if ($nixpacks_php_fallback_path) { - data_set($parsed, 'variables.NIXPACKS_PHP_FALLBACK_PATH', $nixpacks_php_fallback_path->value); - } - if ($nixpacks_php_root_dir) { - data_set($parsed, 'variables.NIXPACKS_PHP_ROOT_DIR', $nixpacks_php_root_dir->value); - } - } - $merged_envs = collect(data_get($parsed, 'variables', []))->merge($this->env_args); $aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []); if (count($aptPkgs) === 0) { @@ -1884,23 +1992,32 @@ private function generate_nixpacks_confs() data_set($parsed, 'phases.setup.aptPkgs', $aptPkgs); } data_set($parsed, 'variables', $merged_envs->toArray()); - - if ($this->is_laravel_or_symfony) { - $this->symfony_finetunes($parsed); + $is_laravel = data_get($parsed, 'variables.IS_LARAVEL', false); + if ($is_laravel) { + $variables = $this->laravel_finetunes(); + data_set($parsed, 'variables.NIXPACKS_PHP_FALLBACK_PATH', $variables[0]->value); + data_set($parsed, 'variables.NIXPACKS_PHP_ROOT_DIR', $variables[1]->value); } - 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); if ($this->nixpacks_type === 'rust') { // temporary: disable healthcheck for rust because the start phase does not have curl/wget $this->application->health_check_enabled = false; $this->application->save(); } - $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); } } } @@ -2038,11 +2155,14 @@ private function generate_env_variables() { $this->env_args = collect([]); $this->env_args->put('SOURCE_COMMIT', $this->commit); + $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs->each(function ($value, $key) { + $this->env_args->put($key, $value); + }); // For build process, include only environment variables where is_buildtime = true if ($this->pull_request_id === 0) { - // Get environment variables that are marked as available during build $envs = $this->application->environment_variables() ->where('key', 'not like', 'NIXPACKS_%') ->where('is_buildtime', true) @@ -2051,24 +2171,9 @@ private function generate_env_variables() foreach ($envs as $env) { if (! is_null($env->real_value)) { $this->env_args->put($env->key, $env->real_value); - if (str($env->real_value)->startsWith('$')) { - $variable_key = str($env->real_value)->after('$'); - if ($variable_key->startsWith('COOLIFY_')) { - $variable = $coolify_envs->get($variable_key->value()); - if (filled($variable)) { - $this->env_args->prepend($variable, $variable_key->value()); - } - } else { - $variable = $this->application->environment_variables()->where('key', $variable_key)->first(); - if ($variable) { - $this->env_args->prepend($variable->real_value, $env->key); - } - } - } } } } else { - // Get preview environment variables that are marked as available during build $envs = $this->application->environment_variables_preview() ->where('key', 'not like', 'NIXPACKS_%') ->where('is_buildtime', true) @@ -2077,20 +2182,6 @@ private function generate_env_variables() foreach ($envs as $env) { if (! is_null($env->real_value)) { $this->env_args->put($env->key, $env->real_value); - if (str($env->real_value)->startsWith('$')) { - $variable_key = str($env->real_value)->after('$'); - if ($variable_key->startsWith('COOLIFY_')) { - $variable = $coolify_envs->get($variable_key->value()); - if (filled($variable)) { - $this->env_args->prepend($variable, $variable_key->value()); - } - } else { - $variable = $this->application->environment_variables_preview()->where('key', $variable_key)->first(); - if ($variable) { - $this->env_args->prepend($variable->real_value, $env->key); - } - } - } } } } @@ -2104,7 +2195,6 @@ private function generate_compose_file() $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->application->fileStorages()->get(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); - $this->generate_runtime_environment_variables(); if (data_get($this->application, 'custom_labels')) { $this->application->parseContainerLabels(); $labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels))); @@ -2173,9 +2263,8 @@ private function generate_compose_file() ], ], ]; - if (filled($this->env_filename)) { - $docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename]; - } + // Always use .env file + $docker_compose['services'][$this->container_name]['env_file'] = ['.env']; $docker_compose['services'][$this->container_name]['healthcheck'] = [ 'test' => [ 'CMD-SHELL', @@ -2460,6 +2549,18 @@ private function build_static_image() $this->application_deployment_queue->addLogEntry('Building docker image completed.'); } + /** + * Wrap a docker build command with environment export from /artifacts/build-time.env + * This enables shell interpolation of variables (e.g., APP_URL=$COOLIFY_URL) + * + * @param string $build_command The docker build command to wrap + * @return string The wrapped command with export statement + */ + private function wrap_build_command_with_env_export(string $build_command): string + { + return "cd {$this->workdir} && set -a && source /artifacts/build-time.env && set +a && {$build_command}"; + } + private function build_image() { // Add Coolify related variables to the build args/secrets @@ -2467,13 +2568,12 @@ private function build_image() // Coolify variables are already included in the secrets from generate_build_env_variables // build_secrets is already a string at this point } else { - // Traditional build args approach - $this->environment_variables->filter(function ($key, $value) { - return str($key)->startsWith('COOLIFY_'); - })->each(function ($key, $value) { + // Traditional build args approach - generate COOLIFY_ variables locally + // Generate COOLIFY_ variables locally for build args + $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs->each(function ($value, $key) { $this->build_args->push("--build-arg '{$key}'"); }); - $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection ? $this->build_args->implode(' ') : (string) $this->build_args; @@ -2510,12 +2610,13 @@ private function build_image() // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"); } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets - $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); + ray($build_command); } else { - $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } } else { $this->execute_remote_command([ @@ -2525,13 +2626,18 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"); + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } } @@ -2558,16 +2664,25 @@ private function build_image() $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { - $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } else { - $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + } + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = "docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"); } else { - $build_command = "docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -2600,7 +2715,7 @@ private function build_image() $nginx_config = base64_encode(defaultNginxConfiguration()); } } - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ @@ -2637,9 +2752,9 @@ private function build_image() } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); } else { - $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -2669,13 +2784,18 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } else { $this->execute_remote_command([ @@ -2685,13 +2805,18 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -2712,20 +2837,31 @@ private function build_image() $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); } else { // Dockerfile buildpack - if ($this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); // Use BuildKit with secrets $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { - $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); } else { - $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); } else { - $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -2867,7 +3003,6 @@ private function generate_build_env_variables() $this->generate_env_variables(); $variables = collect([])->merge($this->env_args); } - // Analyze build variables for potential issues if ($variables->isNotEmpty()) { $this->analyzeBuildTimeVariables($variables); @@ -2882,12 +3017,23 @@ private function generate_build_env_variables() $secrets_hash = $this->generate_secrets_hash($variables); } - $this->build_args = $variables->map(function ($value, $key) { - $value = escapeshellarg($value); + $env_vars = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); - return "--build-arg {$key}={$value}"; + // Map variables to include is_multiline flag + $vars_with_metadata = $variables->map(function ($value, $key) use ($env_vars) { + $env = $env_vars->firstWhere('key', $key); + + return [ + 'key' => $key, + 'value' => $value, + 'is_multiline' => $env ? $env->is_multiline : false, + ]; }); + $this->build_args = generateDockerBuildArgs($vars_with_metadata); + if ($secrets_hash) { $this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); } @@ -2901,23 +3047,37 @@ private function generate_docker_env_flags_for_secrets() return ''; } - $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; if ($variables->isEmpty()) { return ''; } $secrets_hash = $this->generate_secrets_hash($variables); - $env_flags = $variables - ->map(function ($env) { - $escaped_value = escapeshellarg($env->real_value); - return "-e {$env->key}={$escaped_value}"; - }) - ->implode(' '); + // Get database env vars to check for multiline flag + $env_vars = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); + // Map to simple array format for the helper function + $vars_array = $variables->map(function ($value, $key) use ($env_vars) { + $env = $env_vars->firstWhere('key', $key); + + return [ + 'key' => $key, + 'value' => $value, + 'is_multiline' => $env ? $env->is_multiline : false, + ]; + }); + + $env_flags = generateDockerEnvFlags($vars_array); $env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"; return $env_flags; @@ -2977,9 +3137,9 @@ private function add_build_env_variables_to_dockerfile() executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), 'hidden' => true, 'save' => 'dockerfile', + 'ignore_errors' => true, ]); $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); - if ($this->pull_request_id === 0) { // Only add environment variables that are available during build $envs = $this->application->environment_variables() @@ -2993,6 +3153,17 @@ private function add_build_env_variables_to_dockerfile() $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); } } + // Add Coolify variables as ARGs + if ($this->coolify_variables) { + $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) + ->filter() + ->map(function ($var) { + return "ARG {$var}"; + }); + foreach ($coolify_vars as $arg) { + $dockerfile->splice(1, 0, [$arg]); + } + } } else { // Only add preview environment variables that are available during build $envs = $this->application->environment_variables_preview() @@ -3006,6 +3177,17 @@ private function add_build_env_variables_to_dockerfile() $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); } } + // Add Coolify variables as ARGs + if ($this->coolify_variables) { + $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) + ->filter() + ->map(function ($var) { + return "ARG {$var}"; + }); + foreach ($coolify_vars as $arg) { + $dockerfile->splice(1, 0, [$arg]); + } + } } if ($envs->isNotEmpty()) { @@ -3014,10 +3196,17 @@ private function add_build_env_variables_to_dockerfile() } $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), - 'hidden' => true, - ]); + $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info'); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), + 'hidden' => true, + 'ignore_errors' => true, + ]); } } @@ -3042,16 +3231,19 @@ private function modify_dockerfile_for_secrets($dockerfile_path) $dockerfile->prepend('# syntax=docker/dockerfile:1'); } - // Get environment variables for secrets - $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; if ($variables->isEmpty()) { return; } // Generate mount strings for all secrets - $mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' '); + $mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' '); // Add mount for the secrets hash to ensure cache invalidation $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; @@ -3079,8 +3271,6 @@ private function modify_dockerfile_for_secrets($dockerfile_path) executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"), 'hidden' => true, ]); - - $this->application_deployment_queue->addLogEntry('Modified Dockerfile to use build secrets.'); } } @@ -3090,15 +3280,13 @@ private function modify_dockerfiles_for_compose($composeFile) return; } - $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables() - ->where('key', 'not like', 'NIXPACKS_%') - ->where('is_buildtime', true) - ->get() - : $this->application->environment_variables_preview() - ->where('key', 'not like', 'NIXPACKS_%') - ->where('is_buildtime', true) - ->get(); + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; if ($variables->isEmpty()) { $this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.'); @@ -3172,11 +3360,10 @@ private function modify_dockerfiles_for_compose($composeFile) $isMultiStage = count($fromIndices) > 1; $argsToAdd = collect([]); - foreach ($variables as $env) { - $argsToAdd->push("ARG {$env->key}"); + foreach ($variables as $key => $value) { + $argsToAdd->push("ARG {$key}"); } - ray($argsToAdd); if ($argsToAdd->isEmpty()) { $this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add."); @@ -3244,19 +3431,22 @@ private function modify_dockerfiles_for_compose($composeFile) private function add_build_secrets_to_compose($composeFile) { - // Get environment variables for secrets - $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; if ($variables->isEmpty()) { return $composeFile; } $secrets = []; - foreach ($variables as $env) { - $secrets[$env->key] = [ - 'environment' => $env->key, + foreach ($variables as $key => $value) { + $secrets[$key] = [ + 'environment' => $key, ]; } @@ -3271,9 +3461,9 @@ private function add_build_secrets_to_compose($composeFile) if (! isset($service['build']['secrets'])) { $service['build']['secrets'] = []; } - foreach ($variables as $env) { - if (! in_array($env->key, $service['build']['secrets'])) { - $service['build']['secrets'][] = $env->key; + foreach ($variables as $key => $value) { + if (! in_array($key, $service['build']['secrets'])) { + $service['build']['secrets'][] = $key; } } } @@ -3392,7 +3582,6 @@ private function next(string $status) queue_next_deployment($this->application); if ($status === ApplicationDeploymentStatus::FINISHED->value) { - ray($this->application->team()->id); event(new ApplicationConfigurationChanged($this->application->team()->id)); if (! $this->only_this_server) { 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/Dashboard.php b/app/Livewire/Dashboard.php index 45781af30..57ecaa8a2 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -10,7 +10,7 @@ class Dashboard extends Component { - public $projects = []; + public Collection $projects; public Collection $servers; @@ -23,11 +23,6 @@ public function mount() $this->projects = Project::ownedByCurrentTeam()->get(); } - public function navigateToProject($projectUuid) - { - return $this->redirect(collect($this->projects)->firstWhere('uuid', $projectUuid)->navigateTo(), navigate: false); - } - public function render() { return view('livewire.dashboard'); diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php index 34529a7e7..ac9cfd1c2 100644 --- a/app/Livewire/DeploymentsIndicator.php +++ b/app/Livewire/DeploymentsIndicator.php @@ -16,8 +16,10 @@ public function deployments() { $servers = Server::ownedByCurrentTeam()->get(); - return ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued']) + return ApplicationDeploymentQueue::with(['application.environment.project']) + ->whereIn('status', ['in_progress', 'queued']) ->whereIn('server_id', $servers->pluck('id')) + ->orderBy('id') ->get([ 'id', 'application_id', @@ -27,8 +29,7 @@ public function deployments() 'server_name', 'server_id', 'status', - ]) - ->sortBy('id'); + ]); } #[Computed] diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index dacc0d4db..87008e45e 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -3,6 +3,8 @@ namespace App\Livewire; use App\Models\Application; +use App\Models\Environment; +use App\Models\Project; use App\Models\Server; use App\Models\Service; use App\Models\StandaloneClickhouse; @@ -20,24 +22,66 @@ class GlobalSearch extends Component { public $searchQuery = ''; + private $previousTrimmedQuery = ''; + public $isModalOpen = false; public $searchResults = []; public $allSearchableItems = []; + public $isCreateMode = false; + + public $creatableItems = []; + + public $autoOpenResource = null; + + // Resource selection state + public $isSelectingResource = false; + + public $selectedResourceType = null; + + public $loadingServers = false; + + public $loadingProjects = false; + + public $loadingEnvironments = false; + + public $availableServers = []; + + public $availableProjects = []; + + public $availableEnvironments = []; + + public $selectedServerId = null; + + public $selectedDestinationUuid = null; + + public $selectedProjectUuid = null; + + public $selectedEnvironmentUuid = null; + + public $availableDestinations = []; + + public $loadingDestinations = false; + public function mount() { $this->searchQuery = ''; $this->isModalOpen = false; $this->searchResults = []; $this->allSearchableItems = []; + $this->isCreateMode = false; + $this->creatableItems = []; + $this->autoOpenResource = null; + $this->isSelectingResource = false; } public function openSearchModal() { $this->isModalOpen = true; $this->loadSearchableItems(); + $this->loadCreatableItems(); $this->dispatch('search-modal-opened'); } @@ -45,6 +89,7 @@ public function closeSearchModal() { $this->isModalOpen = false; $this->searchQuery = ''; + $this->previousTrimmedQuery = ''; $this->searchResults = []; } @@ -60,7 +105,144 @@ public static function clearTeamCache($teamId) public function updatedSearchQuery() { - $this->search(); + $trimmedQuery = trim($this->searchQuery); + + // If only spaces were added/removed, don't trigger a search + if ($trimmedQuery === $this->previousTrimmedQuery) { + return; + } + + $this->previousTrimmedQuery = $trimmedQuery; + + // If search query is empty, just clear results without processing + if (empty($trimmedQuery)) { + $this->searchResults = []; + $this->isCreateMode = false; + $this->creatableItems = []; + $this->autoOpenResource = null; + $this->isSelectingResource = false; + $this->cancelResourceSelection(); + + return; + } + + $query = strtolower($trimmedQuery); + + // Reset keyboard navigation index + $this->dispatch('reset-selected-index'); + + // Only enter create mode if query is exactly "new" or starts with "new " (space after) + if ($query === 'new' || str_starts_with($query, 'new ')) { + $this->isCreateMode = true; + $this->loadCreatableItems(); + + // Check for sub-commands like "new project", "new server", etc. + $detectedType = $this->detectSpecificResource($query); + if ($detectedType) { + $this->navigateToResource($detectedType); + } else { + // If no specific resource detected, reset selection state + $this->cancelResourceSelection(); + } + + // Also search for existing resources that match the query + // This allows users to find resources with "new" in their name + $this->search(); + } else { + $this->isCreateMode = false; + $this->creatableItems = []; + $this->autoOpenResource = null; + $this->isSelectingResource = false; + $this->search(); + } + } + + private function detectSpecificResource(string $query): ?string + { + // Map of keywords to resource types - order matters for multi-word matches + $resourceMap = [ + // Quick Actions + '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 app' => 'source', + 'new github' => 'source', + 'new source' => 'source', + + // Applications - Git-based + 'new public' => 'public', + 'new public git' => 'public', + 'new public repo' => 'public', + 'new public repository' => 'public', + 'new private github' => 'private-gh-app', + 'new private gh' => 'private-gh-app', + 'new private deploy' => 'private-deploy-key', + 'new deploy key' => 'private-deploy-key', + + // Applications - Docker-based + 'new dockerfile' => 'dockerfile', + 'new docker compose' => 'docker-compose-empty', + 'new compose' => 'docker-compose-empty', + 'new docker image' => 'docker-image', + 'new image' => 'docker-image', + + // Databases + 'new postgresql' => 'postgresql', + 'new postgres' => 'postgresql', + 'new mysql' => 'mysql', + 'new mariadb' => 'mariadb', + 'new redis' => 'redis', + 'new keydb' => 'keydb', + 'new dragonfly' => 'dragonfly', + 'new mongodb' => 'mongodb', + 'new mongo' => 'mongodb', + 'new clickhouse' => 'clickhouse', + ]; + + 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(); + + // Quick Actions + if (in_array($type, ['server', 'storage', 'private-key'])) { + return $user->isAdmin() || $user->isOwner(); + } + + if ($type === 'team') { + return true; + } + + // Applications, Databases, Services, and other resources + if (in_array($type, [ + 'project', 'source', + // Applications + 'public', 'private-gh-app', 'private-deploy-key', + 'dockerfile', 'docker-compose-empty', 'docker-image', + // Databases + 'postgresql', 'mysql', 'mariadb', 'redis', 'keydb', + 'dragonfly', 'mongodb', 'clickhouse', + ]) || str_starts_with($type, 'one-click-service-')) { + return $user->can('createAnyResource'); + } + + return false; } private function loadSearchableItems() @@ -114,7 +296,7 @@ private function loadSearchableItems() 'project' => $app->environment->project->name ?? null, 'environment' => $app->environment->name ?? null, 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI - 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString), + 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' application applications app apps'), ]; }); @@ -143,7 +325,7 @@ private function loadSearchableItems() 'project' => $service->environment->project->name ?? null, 'environment' => $service->environment->name ?? null, 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI - 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString), + 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' service services'), ]; }); @@ -166,7 +348,7 @@ private function loadSearchableItems() 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' postgresql '.$db->description), + 'search_text' => strtolower($db->name.' postgresql '.$db->description.' database databases db'), ]; }) ); @@ -187,7 +369,7 @@ private function loadSearchableItems() 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' mysql '.$db->description), + 'search_text' => strtolower($db->name.' mysql '.$db->description.' database databases db'), ]; }) ); @@ -208,7 +390,7 @@ private function loadSearchableItems() 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' mariadb '.$db->description), + 'search_text' => strtolower($db->name.' mariadb '.$db->description.' database databases db'), ]; }) ); @@ -229,7 +411,7 @@ private function loadSearchableItems() 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' mongodb '.$db->description), + 'search_text' => strtolower($db->name.' mongodb '.$db->description.' database databases db'), ]; }) ); @@ -250,7 +432,7 @@ private function loadSearchableItems() 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' redis '.$db->description), + 'search_text' => strtolower($db->name.' redis '.$db->description.' database databases db'), ]; }) ); @@ -271,7 +453,7 @@ private function loadSearchableItems() 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' keydb '.$db->description), + 'search_text' => strtolower($db->name.' keydb '.$db->description.' database databases db'), ]; }) ); @@ -292,7 +474,7 @@ private function loadSearchableItems() 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' dragonfly '.$db->description), + 'search_text' => strtolower($db->name.' dragonfly '.$db->description.' database databases db'), ]; }) ); @@ -313,7 +495,7 @@ private function loadSearchableItems() 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' clickhouse '.$db->description), + 'search_text' => strtolower($db->name.' clickhouse '.$db->description.' database databases db'), ]; }) ); @@ -331,15 +513,210 @@ private function loadSearchableItems() 'link' => $server->url(), 'project' => null, 'environment' => null, - 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description), + 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'), + ]; + }); + ray($servers); + // Get all projects + $projects = Project::ownedByCurrentTeam() + ->withCount(['environments', 'applications', 'services']) + ->get() + ->map(function ($project) { + $resourceCount = $project->applications_count + $project->services_count; + $resourceSummary = $resourceCount > 0 + ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') + : 'No resources'; + + return [ + 'id' => $project->id, + 'name' => $project->name, + 'type' => 'project', + 'uuid' => $project->uuid, + 'description' => $project->description, + 'link' => $project->navigateTo(), + 'project' => null, + 'environment' => null, + 'resource_count' => $resourceSummary, + 'environment_count' => $project->environments_count, + 'search_text' => strtolower($project->name.' '.$project->description.' project projects'), ]; }); + // Get all environments + $environments = Environment::ownedByCurrentTeam() + ->with('project') + ->withCount(['applications', 'services']) + ->get() + ->map(function ($environment) { + $resourceCount = $environment->applications_count + $environment->services_count; + $resourceSummary = $resourceCount > 0 + ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') + : 'No resources'; + + // Build description with project context + $descriptionParts = []; + if ($environment->project) { + $descriptionParts[] = "Project: {$environment->project->name}"; + } + if ($environment->description) { + $descriptionParts[] = $environment->description; + } + if (empty($descriptionParts)) { + $descriptionParts[] = $resourceSummary; + } + + return [ + 'id' => $environment->id, + 'name' => $environment->name, + 'type' => 'environment', + 'uuid' => $environment->uuid, + 'description' => implode(' • ', $descriptionParts), + 'link' => route('project.resource.index', [ + 'project_uuid' => $environment->project->uuid, + 'environment_uuid' => $environment->uuid, + ]), + 'project' => $environment->project->name ?? null, + 'environment' => null, + 'resource_count' => $resourceSummary, + 'search_text' => strtolower($environment->name.' '.$environment->description.' '.$environment->project->name.' environment'), + ]; + }); + + // Add navigation routes + $navigation = collect([ + [ + 'name' => 'Dashboard', + 'type' => 'navigation', + 'description' => 'Go to main dashboard', + 'link' => route('dashboard'), + 'search_text' => 'dashboard home main overview', + ], + [ + 'name' => 'Servers', + 'type' => 'navigation', + 'description' => 'View all servers', + 'link' => route('server.index'), + 'search_text' => 'servers all list view', + ], + [ + 'name' => 'Projects', + 'type' => 'navigation', + 'description' => 'View all projects', + 'link' => route('project.index'), + 'search_text' => 'projects all list view', + ], + [ + 'name' => 'Destinations', + 'type' => 'navigation', + 'description' => 'View all destinations', + 'link' => route('destination.index'), + 'search_text' => 'destinations docker networks', + ], + [ + 'name' => 'Security', + 'type' => 'navigation', + 'description' => 'Manage private keys and API tokens', + 'link' => route('security.private-key.index'), + 'search_text' => 'security private keys ssh api tokens', + ], + [ + 'name' => 'Sources', + 'type' => 'navigation', + 'description' => 'Manage GitHub apps and Git sources', + 'link' => route('source.all'), + 'search_text' => 'sources github apps git repositories', + ], + [ + 'name' => 'Storages', + 'type' => 'navigation', + 'description' => 'Manage S3 storage for backups', + 'link' => route('storage.index'), + 'search_text' => 'storages s3 backups', + ], + [ + 'name' => 'Shared Variables', + 'type' => 'navigation', + 'description' => 'View all shared variables', + 'link' => route('shared-variables.index'), + 'search_text' => 'shared variables environment all', + ], + [ + 'name' => 'Team Shared Variables', + 'type' => 'navigation', + 'description' => 'Manage team-wide shared variables', + 'link' => route('shared-variables.team.index'), + 'search_text' => 'shared variables team environment', + ], + [ + 'name' => 'Project Shared Variables', + 'type' => 'navigation', + 'description' => 'Manage project shared variables', + 'link' => route('shared-variables.project.index'), + 'search_text' => 'shared variables project environment', + ], + [ + 'name' => 'Environment Shared Variables', + 'type' => 'navigation', + 'description' => 'Manage environment shared variables', + 'link' => route('shared-variables.environment.index'), + 'search_text' => 'shared variables environment', + ], + [ + 'name' => 'Tags', + 'type' => 'navigation', + 'description' => 'View resources by tags', + 'link' => route('tags.show'), + 'search_text' => 'tags labels organize', + ], + [ + 'name' => 'Terminal', + 'type' => 'navigation', + 'description' => 'Access server terminal', + 'link' => route('terminal'), + 'search_text' => 'terminal ssh console shell command line', + ], + [ + 'name' => 'Profile', + 'type' => 'navigation', + 'description' => 'Manage your profile and preferences', + 'link' => route('profile'), + 'search_text' => 'profile account user settings preferences', + ], + [ + 'name' => 'Team', + 'type' => 'navigation', + 'description' => 'Manage team members and settings', + 'link' => route('team.index'), + 'search_text' => 'team settings members users invitations', + ], + [ + 'name' => 'Notifications', + 'type' => 'navigation', + 'description' => 'Configure email, Discord, Telegram notifications', + 'link' => route('notifications.email'), + 'search_text' => 'notifications alerts email discord telegram slack pushover', + ], + ]); + + // Add instance settings only for self-hosted and root team + if (! isCloud() && $team->id === 0) { + $navigation->push([ + 'name' => 'Settings', + 'type' => 'navigation', + 'description' => 'Instance settings and configuration', + 'link' => route('settings.index'), + 'search_text' => 'settings configuration instance', + ]); + } + // Merge all collections - $items = $items->merge($applications) + $items = $items->merge($navigation) + ->merge($applications) ->merge($services) ->merge($databases) - ->merge($servers); + ->merge($servers) + ->merge($projects) + ->merge($environments); return $items->toArray(); }); @@ -347,7 +724,7 @@ private function loadSearchableItems() private function search() { - if (strlen($this->searchQuery) < 2) { + if (strlen($this->searchQuery) < 1) { $this->searchResults = []; return; @@ -355,14 +732,720 @@ private function search() $query = strtolower($this->searchQuery); - // Case-insensitive search in the items - $this->searchResults = collect($this->allSearchableItems) + // Detect resource category queries + $categoryMapping = [ + 'server' => ['server', 'type' => 'server'], + 'servers' => ['server', 'type' => 'server'], + 'app' => ['application', 'type' => 'application'], + 'apps' => ['application', 'type' => 'application'], + 'application' => ['application', 'type' => 'application'], + 'applications' => ['application', 'type' => 'application'], + 'db' => ['database', 'type' => 'standalone-postgresql'], + 'database' => ['database', 'type' => 'standalone-postgresql'], + 'databases' => ['database', 'type' => 'standalone-postgresql'], + 'service' => ['service', 'category' => 'Services'], + 'services' => ['service', 'category' => 'Services'], + 'project' => ['project', 'type' => 'project'], + 'projects' => ['project', 'type' => 'project'], + ]; + + $priorityCreatableItem = null; + + // Check if query matches a resource category + if (isset($categoryMapping[$query])) { + $this->loadCreatableItems(); + $mapping = $categoryMapping[$query]; + + // Find the matching creatable item + $priorityCreatableItem = collect($this->creatableItems) + ->first(function ($item) use ($mapping) { + if (isset($mapping['type'])) { + return $item['type'] === $mapping['type']; + } + if (isset($mapping['category'])) { + return isset($item['category']) && $item['category'] === $mapping['category']; + } + + return false; + }); + + if ($priorityCreatableItem) { + $priorityCreatableItem['is_creatable_suggestion'] = true; + } + } + + // Search for matching creatable resources to show as suggestions (if no priority item) + if (! $priorityCreatableItem) { + $this->loadCreatableItems(); + + // Search in regular creatable items (apps, databases, quick actions) + $creatableSuggestions = collect($this->creatableItems) + ->filter(function ($item) use ($query) { + $searchText = strtolower($item['name'].' '.$item['description'].' '.($item['type'] ?? '')); + + // Use word boundary matching to avoid substring matches (e.g., "wordpress" shouldn't match "classicpress") + return preg_match('/\b'.preg_quote($query, '/').'/i', $searchText); + }) + ->map(function ($item) use ($query) { + // Calculate match priority: name > type > description + $name = strtolower($item['name']); + $type = strtolower($item['type'] ?? ''); + $description = strtolower($item['description']); + + if (preg_match('/\b'.preg_quote($query, '/').'/i', $name)) { + $item['match_priority'] = 1; + } elseif (preg_match('/\b'.preg_quote($query, '/').'/i', $type)) { + $item['match_priority'] = 2; + } else { + $item['match_priority'] = 3; + } + + $item['is_creatable_suggestion'] = true; + + return $item; + }); + + // Also search in services (loaded on-demand) + $serviceSuggestions = collect($this->services) + ->filter(function ($item) use ($query) { + $searchText = strtolower($item['name'].' '.$item['description'].' '.($item['type'] ?? '')); + + return preg_match('/\b'.preg_quote($query, '/').'/i', $searchText); + }) + ->map(function ($item) use ($query) { + // Calculate match priority: name > type > description + $name = strtolower($item['name']); + $type = strtolower($item['type'] ?? ''); + $description = strtolower($item['description']); + + if (preg_match('/\b'.preg_quote($query, '/').'/i', $name)) { + $item['match_priority'] = 1; + } elseif (preg_match('/\b'.preg_quote($query, '/').'/i', $type)) { + $item['match_priority'] = 2; + } else { + $item['match_priority'] = 3; + } + + $item['is_creatable_suggestion'] = true; + + return $item; + }); + + // Merge and sort all suggestions + $creatableSuggestions = $creatableSuggestions + ->merge($serviceSuggestions) + ->sortBy('match_priority') + ->take(10) + ->values() + ->toArray(); + } else { + $creatableSuggestions = []; + } + + // Case-insensitive search in existing resources + $existingResults = collect($this->allSearchableItems) ->filter(function ($item) use ($query) { - return str_contains($item['search_text'], $query); + // Use word boundary matching to avoid substring matches (e.g., "wordpress" shouldn't match "classicpress") + return preg_match('/\b'.preg_quote($query, '/').'/i', $item['search_text']); }) + ->map(function ($item) use ($query) { + // Calculate match priority: name > type > description + $name = strtolower($item['name'] ?? ''); + $type = strtolower($item['type'] ?? ''); + $description = strtolower($item['description'] ?? ''); + + if (preg_match('/\b'.preg_quote($query, '/').'/i', $name)) { + $item['match_priority'] = 1; + } elseif (preg_match('/\b'.preg_quote($query, '/').'/i', $type)) { + $item['match_priority'] = 2; + } else { + $item['match_priority'] = 3; + } + + return $item; + }) + ->sortBy('match_priority') ->take(20) ->values() ->toArray(); + + // Merge results: existing resources first, then priority create item, then other creatable suggestions + $results = []; + + // If we have existing results, show them first + $results = array_merge($results, $existingResults); + + // Then show the priority "Create New" item (if exists) + if ($priorityCreatableItem) { + $results[] = $priorityCreatableItem; + } + + // Finally show other creatable suggestions + $results = array_merge($results, $creatableSuggestions); + + $this->searchResults = $results; + } + + private function loadCreatableItems() + { + $items = collect(); + $user = auth()->user(); + + // === Quick Actions Category === + + // 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', + 'quickcommand' => '(type: new project)', + 'type' => 'project', + 'category' => 'Quick Actions', + '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', + 'quickcommand' => '(type: new server)', + 'type' => 'server', + 'category' => 'Quick Actions', + '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', + 'quickcommand' => '(type: new team)', + 'type' => 'team', + 'category' => 'Quick Actions', + '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', + 'quickcommand' => '(type: new storage)', + 'type' => 'storage', + 'category' => 'Quick Actions', + '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', + 'quickcommand' => '(type: new private key)', + 'type' => 'private-key', + 'category' => 'Quick Actions', + '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', + 'quickcommand' => '(type: new github)', + 'type' => 'source', + 'category' => 'Quick Actions', + 'component' => 'source.github.create', + ]); + } + + // === Applications Category === + + if ($user->can('createAnyResource')) { + // Git-based applications + $items->push([ + 'name' => 'Public Git Repository', + 'description' => 'Deploy from any public Git repository', + 'quickcommand' => '(type: new public)', + 'type' => 'public', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + + $items->push([ + 'name' => 'Private Repository (GitHub App)', + 'description' => 'Deploy private repositories through GitHub Apps', + 'quickcommand' => '(type: new private github)', + 'type' => 'private-gh-app', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + + $items->push([ + 'name' => 'Private Repository (Deploy Key)', + 'description' => 'Deploy private repositories with a deploy key', + 'quickcommand' => '(type: new private deploy)', + 'type' => 'private-deploy-key', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + + // Docker-based applications + $items->push([ + 'name' => 'Dockerfile', + 'description' => 'Deploy a simple Dockerfile without Git', + 'quickcommand' => '(type: new dockerfile)', + 'type' => 'dockerfile', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + + $items->push([ + 'name' => 'Docker Compose', + 'description' => 'Deploy complex applications with Docker Compose', + 'quickcommand' => '(type: new compose)', + 'type' => 'docker-compose-empty', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + + $items->push([ + 'name' => 'Docker Image', + 'description' => 'Deploy an existing Docker image from any registry', + 'quickcommand' => '(type: new image)', + 'type' => 'docker-image', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + } + + // === Databases Category === + + if ($user->can('createAnyResource')) { + $items->push([ + 'name' => 'PostgreSQL', + 'description' => 'Robust, advanced open-source database', + 'quickcommand' => '(type: new postgresql)', + 'type' => 'postgresql', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'MySQL', + 'description' => 'Popular open-source relational database', + 'quickcommand' => '(type: new mysql)', + 'type' => 'mysql', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'MariaDB', + 'description' => 'Community-developed fork of MySQL', + 'quickcommand' => '(type: new mariadb)', + 'type' => 'mariadb', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'Redis', + 'description' => 'In-memory data structure store', + 'quickcommand' => '(type: new redis)', + 'type' => 'redis', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'KeyDB', + 'description' => 'High-performance Redis alternative', + 'quickcommand' => '(type: new keydb)', + 'type' => 'keydb', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'Dragonfly', + 'description' => 'Modern in-memory datastore', + 'quickcommand' => '(type: new dragonfly)', + 'type' => 'dragonfly', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'MongoDB', + 'description' => 'Document-oriented NoSQL database', + 'quickcommand' => '(type: new mongodb)', + 'type' => 'mongodb', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + + $items->push([ + 'name' => 'Clickhouse', + 'description' => 'Column-oriented database for analytics', + 'quickcommand' => '(type: new clickhouse)', + 'type' => 'clickhouse', + 'category' => 'Databases', + 'resourceType' => 'database', + ]); + } + + // Merge with services + $items = $items->merge(collect($this->services)); + + $this->creatableItems = $items->toArray(); + } + + public function navigateToResource($type) + { + // Find the item by type - check regular items first, then services + $item = collect($this->creatableItems)->firstWhere('type', $type); + + if (! $item) { + $item = collect($this->services)->firstWhere('type', $type); + } + + if (! $item) { + return; + } + + // If it has a component, it's a modal-based resource + // Close search modal and open the appropriate creation modal + if (isset($item['component'])) { + $this->dispatch('closeSearchModal'); + $this->dispatch('open-create-modal-'.$type); + + return; + } + + // For applications, databases, and services, navigate to resource creation + // with smart defaults (auto-select if only 1 server/project/environment) + if (isset($item['resourceType'])) { + $this->navigateToResourceCreation($type); + } + } + + private function navigateToResourceCreation($type) + { + // Start the selection flow + $this->selectedResourceType = $type; + $this->isSelectingResource = true; + + // Reset selections + $this->selectedServerId = null; + $this->selectedDestinationUuid = null; + $this->selectedProjectUuid = null; + $this->selectedEnvironmentUuid = null; + + // Start loading servers first (in order: servers -> destinations -> projects -> environments) + $this->loadServers(); + } + + public function loadServers() + { + $this->loadingServers = true; + $servers = Server::isUsable()->get()->sortBy('name'); + $this->availableServers = $servers->map(fn ($s) => [ + 'id' => $s->id, + 'name' => $s->name, + 'description' => $s->description, + ])->toArray(); + $this->loadingServers = false; + + // Auto-select if only one server + if (count($this->availableServers) === 1) { + $this->selectServer($this->availableServers[0]['id']); + } + } + + public function selectServer($serverId, $shouldProgress = true) + { + $this->selectedServerId = $serverId; + + if ($shouldProgress) { + $this->loadDestinations(); + } + } + + public function loadDestinations() + { + $this->loadingDestinations = true; + $server = Server::find($this->selectedServerId); + + if (! $server) { + $this->loadingDestinations = false; + + return $this->dispatch('error', message: 'Server not found'); + } + + $destinations = $server->destinations(); + + if ($destinations->isEmpty()) { + $this->loadingDestinations = false; + + return $this->dispatch('error', message: 'No destinations found on this server'); + } + + $this->availableDestinations = $destinations->map(fn ($d) => [ + 'uuid' => $d->uuid, + 'name' => $d->name, + 'network' => $d->network ?? 'default', + ])->toArray(); + + $this->loadingDestinations = false; + + // Auto-select if only one destination + if (count($this->availableDestinations) === 1) { + $this->selectDestination($this->availableDestinations[0]['uuid']); + } + } + + public function selectDestination($destinationUuid, $shouldProgress = true) + { + $this->selectedDestinationUuid = $destinationUuid; + + if ($shouldProgress) { + $this->loadProjects(); + } + } + + public function loadProjects() + { + $this->loadingProjects = true; + $user = auth()->user(); + $team = $user->currentTeam(); + $projects = Project::where('team_id', $team->id)->get(); + + if ($projects->isEmpty()) { + $this->loadingProjects = false; + + return $this->dispatch('error', message: 'Please create a project first'); + } + + $this->availableProjects = $projects->map(fn ($p) => [ + 'uuid' => $p->uuid, + 'name' => $p->name, + 'description' => $p->description, + ])->toArray(); + $this->loadingProjects = false; + + // Auto-select if only one project + if (count($this->availableProjects) === 1) { + $this->selectProject($this->availableProjects[0]['uuid']); + } + } + + public function selectProject($projectUuid, $shouldProgress = true) + { + $this->selectedProjectUuid = $projectUuid; + + if ($shouldProgress) { + $this->loadEnvironments(); + } + } + + public function loadEnvironments() + { + $this->loadingEnvironments = true; + $project = Project::where('uuid', $this->selectedProjectUuid)->first(); + + if (! $project) { + $this->loadingEnvironments = false; + + return; + } + + $environments = $project->environments; + + if ($environments->isEmpty()) { + $this->loadingEnvironments = false; + + return $this->dispatch('error', message: 'No environments found in project'); + } + + $this->availableEnvironments = $environments->map(fn ($e) => [ + 'uuid' => $e->uuid, + 'name' => $e->name, + 'description' => $e->description, + ])->toArray(); + $this->loadingEnvironments = false; + + // Auto-select if only one environment + if (count($this->availableEnvironments) === 1) { + $this->selectEnvironment($this->availableEnvironments[0]['uuid']); + } + } + + public function selectEnvironment($environmentUuid, $shouldProgress = true) + { + $this->selectedEnvironmentUuid = $environmentUuid; + + if ($shouldProgress) { + $this->completeResourceCreation(); + } + } + + private function completeResourceCreation() + { + // All selections made - navigate to resource creation + if ($this->selectedProjectUuid && $this->selectedEnvironmentUuid && $this->selectedResourceType && $this->selectedServerId !== null && $this->selectedDestinationUuid) { + $queryParams = [ + 'type' => $this->selectedResourceType, + 'destination' => $this->selectedDestinationUuid, + 'server_id' => $this->selectedServerId, + ]; + + // PostgreSQL requires a database_image parameter + if ($this->selectedResourceType === 'postgresql') { + $queryParams['database_image'] = 'postgres:16-alpine'; + } + + return redirect()->route('project.resource.create', [ + 'project_uuid' => $this->selectedProjectUuid, + 'environment_uuid' => $this->selectedEnvironmentUuid, + ] + $queryParams); + } + } + + public function cancelResourceSelection() + { + $this->isSelectingResource = false; + $this->selectedResourceType = null; + $this->selectedServerId = null; + $this->selectedDestinationUuid = null; + $this->selectedProjectUuid = null; + $this->selectedEnvironmentUuid = null; + $this->availableServers = []; + $this->availableDestinations = []; + $this->availableProjects = []; + $this->availableEnvironments = []; + $this->autoOpenResource = null; + } + + public function getFilteredCreatableItemsProperty() + { + $query = strtolower(trim($this->searchQuery)); + + // Check if query matches a category keyword + $categoryKeywords = ['server', 'servers', 'app', 'apps', 'application', 'applications', 'db', 'database', 'databases', 'service', 'services', 'project', 'projects']; + if (in_array($query, $categoryKeywords)) { + return $this->filterCreatableItemsByCategory($query); + } + + // Extract search term - everything after "new " + if (str_starts_with($query, 'new ')) { + $searchTerm = trim(substr($query, strlen('new '))); + + if (empty($searchTerm)) { + return $this->creatableItems; + } + + // Filter items by name or description + return collect($this->creatableItems)->filter(function ($item) use ($searchTerm) { + $searchText = strtolower($item['name'].' '.$item['description'].' '.$item['category']); + + return str_contains($searchText, $searchTerm); + })->values()->toArray(); + } + + return $this->creatableItems; + } + + private function filterCreatableItemsByCategory($categoryKeyword) + { + // Map keywords to category names + $categoryMap = [ + 'server' => 'Quick Actions', + 'servers' => 'Quick Actions', + 'app' => 'Applications', + 'apps' => 'Applications', + 'application' => 'Applications', + 'applications' => 'Applications', + 'db' => 'Databases', + 'database' => 'Databases', + 'databases' => 'Databases', + 'service' => 'Services', + 'services' => 'Services', + 'project' => 'Applications', + 'projects' => 'Applications', + ]; + + $category = $categoryMap[$categoryKeyword] ?? null; + + if (! $category) { + return []; + } + + return collect($this->creatableItems) + ->filter(fn ($item) => $item['category'] === $category) + ->values() + ->toArray(); + } + + public function getSelectedResourceNameProperty() + { + if (! $this->selectedResourceType) { + return null; + } + + // Load creatable items if not loaded yet + if (empty($this->creatableItems)) { + $this->loadCreatableItems(); + } + + // Find the item by type - check regular items first, then services + $item = collect($this->creatableItems)->firstWhere('type', $this->selectedResourceType); + + if (! $item) { + $item = collect($this->services)->firstWhere('type', $this->selectedResourceType); + } + + return $item ? $item['name'] : null; + } + + public function getServicesProperty() + { + // Cache services in a static property to avoid reloading on every access + static $cachedServices = null; + + if ($cachedServices !== null) { + return $cachedServices; + } + + $user = auth()->user(); + + if (! $user->can('createAnyResource')) { + $cachedServices = []; + + return $cachedServices; + } + + // Load all services + $allServices = get_service_templates(); + $items = collect(); + + foreach ($allServices as $serviceKey => $service) { + $items->push([ + 'name' => str($serviceKey)->headline()->toString(), + 'description' => data_get($service, 'slogan', 'Deploy '.str($serviceKey)->headline()), + 'type' => 'one-click-service-'.$serviceKey, + 'category' => 'Services', + 'resourceType' => 'service', + ]); + } + + $cachedServices = $items->toArray(); + + return $cachedServices; } public function render() 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/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index dccd1e499..ebdc014ae 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -50,6 +50,28 @@ public function force_start() } } + public function copyLogsToClipboard(): string + { + $logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); + + if (! $logs) { + return ''; + } + + $markdown = "# Deployment Logs\n\n"; + $markdown .= "```\n"; + + foreach ($logs as $log) { + if (isset($log['output'])) { + $markdown .= $log['output']."\n"; + } + } + + $markdown .= "```\n"; + + return $markdown; + } + public function cancel() { $deployment_uuid = $this->application_deployment_queue->deployment_uuid; diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index ae9bd314b..b42f29fa5 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -544,6 +544,9 @@ public function submit($showToaster = true) { try { $this->authorize('update', $this->application); + + $this->validate(); + $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { @@ -584,7 +587,6 @@ public function submit($showToaster = true) return; } } - $this->validate(); if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) { $this->resetDefaultLabels(); 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/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index dbb223de2..e105c956a 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -12,7 +12,11 @@ class DockerImage extends Component { - public string $dockerImage = ''; + public string $imageName = ''; + + public string $imageTag = ''; + + public string $imageSha256 = ''; public array $parameters; @@ -26,12 +30,41 @@ public function mount() public function submit() { + // Strip 'sha256:' prefix if user pasted it + if ($this->imageSha256) { + $this->imageSha256 = preg_replace('/^sha256:/i', '', trim($this->imageSha256)); + } + + // Remove @sha256 from image name if user added it + if ($this->imageName) { + $this->imageName = preg_replace('/@sha256$/i', '', trim($this->imageName)); + } + $this->validate([ - 'dockerImage' => 'required', + 'imageName' => ['required', 'string'], + 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'], + 'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'], ]); + // Validate that either tag or sha256 is provided, but not both + if ($this->imageTag && $this->imageSha256) { + $this->addError('imageTag', 'Provide either a tag or SHA256 digest, not both.'); + $this->addError('imageSha256', 'Provide either a tag or SHA256 digest, not both.'); + + return; + } + + // Build the full Docker image string + if ($this->imageSha256) { + $dockerImage = $this->imageName.'@sha256:'.$this->imageSha256; + } elseif ($this->imageTag) { + $dockerImage = $this->imageName.':'.$this->imageTag; + } else { + $dockerImage = $this->imageName.':latest'; + } + $parser = new DockerImageParser; - $parser->parse($this->dockerImage); + $parser->parse($dockerImage); $destination_uuid = $this->query['destination']; $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); @@ -45,6 +78,16 @@ public function submit() $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + + // Determine the image tag based on whether it's a hash or regular tag + $imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag(); + + // Append @sha256 to image name if using digest and not already present + $imageName = $parser->getFullImageNameWithoutTag(); + if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) { + $imageName .= '@sha256'; + } + $application = Application::create([ 'name' => 'docker-image-'.new Cuid2, 'repository_project_id' => 0, @@ -52,7 +95,7 @@ public function submit() 'git_branch' => 'main', 'build_pack' => 'dockerimage', 'ports_exposes' => 80, - 'docker_registry_image_name' => $parser->getFullImageNameWithoutTag(), + 'docker_registry_image_name' => $imageName, 'docker_registry_image_tag' => $parser->getTag(), 'environment_id' => $environment->id, 'destination_id' => $destination->id, 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/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 2933a8cca..7f0caaba3 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -34,6 +34,8 @@ class FileStorage extends Component public bool $permanently_delete = true; + public bool $isReadOnly = false; + protected $rules = [ 'fileStorage.is_directory' => 'required', 'fileStorage.fs_path' => 'required', @@ -52,6 +54,8 @@ public function mount() $this->workdir = null; $this->fs_path = $this->fileStorage->fs_path; } + + $this->isReadOnly = $this->fileStorage->isReadOnlyVolume(); } public function convertToDirectory() diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 26cd54425..db171db24 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -14,6 +14,22 @@ class Storage extends Component public $fileStorage; + public $isSwarm = false; + + public string $name = ''; + + public string $mount_path = ''; + + public ?string $host_path = null; + + public string $file_storage_path = ''; + + public ?string $file_storage_content = null; + + public string $file_storage_directory_source = ''; + + public string $file_storage_directory_destination = ''; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -27,6 +43,18 @@ public function getListeners() public function mount() { + if (str($this->resource->getMorphClass())->contains('Standalone')) { + $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}"; + } else { + $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}"; + } + + if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->destination->server->isSwarm()) { + $this->isSwarm = true; + } + } + $this->refreshStorages(); } @@ -39,30 +67,151 @@ public function refreshStoragesFromEvent() public function refreshStorages() { $this->fileStorage = $this->resource->fileStorages()->get(); - $this->dispatch('$refresh'); + $this->resource->refresh(); } - public function addNewVolume($data) + public function getFilesProperty() + { + return $this->fileStorage->where('is_directory', false); + } + + public function getDirectoriesProperty() + { + return $this->fileStorage->where('is_directory', true); + } + + public function getVolumeCountProperty() + { + return $this->resource->persistentStorages()->count(); + } + + public function getFileCountProperty() + { + return $this->files->count(); + } + + public function getDirectoryCountProperty() + { + return $this->directories->count(); + } + + public function submitPersistentVolume() { try { $this->authorize('update', $this->resource); + $this->validate([ + 'name' => 'required|string', + 'mount_path' => 'required|string', + 'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable', + ]); + + $name = $this->resource->uuid.'-'.$this->name; + LocalPersistentVolume::create([ - 'name' => $data['name'], - 'mount_path' => $data['mount_path'], - 'host_path' => $data['host_path'], + 'name' => $name, + 'mount_path' => $this->mount_path, + 'host_path' => $this->host_path, 'resource_id' => $this->resource->id, 'resource_type' => $this->resource->getMorphClass(), ]); $this->resource->refresh(); - $this->dispatch('success', 'Storage added successfully'); - $this->dispatch('clearAddStorage'); - $this->dispatch('refreshStorages'); + $this->dispatch('success', 'Volume added successfully'); + $this->dispatch('closeStorageModal', 'volume'); + $this->clearForm(); + $this->refreshStorages(); } catch (\Throwable $e) { return handleError($e, $this); } } + public function submitFileStorage() + { + try { + $this->authorize('update', $this->resource); + + $this->validate([ + 'file_storage_path' => 'required|string', + 'file_storage_content' => 'nullable|string', + ]); + + $this->file_storage_path = trim($this->file_storage_path); + $this->file_storage_path = str($this->file_storage_path)->start('/')->value(); + + if ($this->resource->getMorphClass() === \App\Models\Application::class) { + $fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; + } elseif (str($this->resource->getMorphClass())->contains('Standalone')) { + $fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; + } else { + throw new \Exception('No valid resource type for file mount storage type!'); + } + + \App\Models\LocalFileVolume::create([ + 'fs_path' => $fs_path, + 'mount_path' => $this->file_storage_path, + 'content' => $this->file_storage_content, + 'is_directory' => false, + 'resource_id' => $this->resource->id, + 'resource_type' => get_class($this->resource), + ]); + + $this->dispatch('success', 'File mount added successfully'); + $this->dispatch('closeStorageModal', 'file'); + $this->clearForm(); + $this->refreshStorages(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function submitFileStorageDirectory() + { + try { + $this->authorize('update', $this->resource); + + $this->validate([ + 'file_storage_directory_source' => 'required|string', + 'file_storage_directory_destination' => 'required|string', + ]); + + $this->file_storage_directory_source = trim($this->file_storage_directory_source); + $this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value(); + $this->file_storage_directory_destination = trim($this->file_storage_directory_destination); + $this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value(); + + \App\Models\LocalFileVolume::create([ + 'fs_path' => $this->file_storage_directory_source, + 'mount_path' => $this->file_storage_directory_destination, + 'is_directory' => true, + 'resource_id' => $this->resource->id, + 'resource_type' => get_class($this->resource), + ]); + + $this->dispatch('success', 'Directory mount added successfully'); + $this->dispatch('closeStorageModal', 'directory'); + $this->clearForm(); + $this->refreshStorages(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function clearForm() + { + $this->name = ''; + $this->mount_path = ''; + $this->host_path = null; + $this->file_storage_path = ''; + $this->file_storage_content = null; + $this->file_storage_directory_destination = ''; + + if (str($this->resource->getMorphClass())->contains('Standalone')) { + $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}"; + } else { + $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}"; + } + } + public function render() { return view('livewire.project.service.storage'); 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/Project/Shared/Storages/Add.php b/app/Livewire/Project/Shared/Storages/Add.php deleted file mode 100644 index 006d41c14..000000000 --- a/app/Livewire/Project/Shared/Storages/Add.php +++ /dev/null @@ -1,174 +0,0 @@ - 'required|string', - 'mount_path' => 'required|string', - 'host_path' => 'string|nullable', - 'file_storage_path' => 'string', - 'file_storage_content' => 'nullable|string', - 'file_storage_directory_source' => 'string', - 'file_storage_directory_destination' => 'string', - ]; - - protected $listeners = ['clearAddStorage' => 'clear']; - - protected $validationAttributes = [ - 'name' => 'name', - 'mount_path' => 'mount', - 'host_path' => 'host', - 'file_storage_path' => 'file storage path', - 'file_storage_content' => 'file storage content', - 'file_storage_directory_source' => 'file storage directory source', - 'file_storage_directory_destination' => 'file storage directory destination', - ]; - - public function mount() - { - if (str($this->resource->getMorphClass())->contains('Standalone')) { - $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}"; - } else { - $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}"; - } - $this->uuid = $this->resource->uuid; - $this->parameters = get_route_parameters(); - if (data_get($this->parameters, 'application_uuid')) { - $applicationUuid = $this->parameters['application_uuid']; - $application = Application::where('uuid', $applicationUuid)->first(); - if (! $application) { - abort(404); - } - if ($application->destination->server->isSwarm()) { - $this->isSwarm = true; - $this->rules['host_path'] = 'required|string'; - } - } - } - - public function submitFileStorage() - { - try { - $this->authorize('update', $this->resource); - - $this->validate([ - 'file_storage_path' => 'string', - 'file_storage_content' => 'nullable|string', - ]); - - $this->file_storage_path = trim($this->file_storage_path); - $this->file_storage_path = str($this->file_storage_path)->start('/')->value(); - - if ($this->resource->getMorphClass() === \App\Models\Application::class) { - $fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; - } elseif (str($this->resource->getMorphClass())->contains('Standalone')) { - $fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; - } else { - throw new \Exception('No valid resource type for file mount storage type!'); - } - - LocalFileVolume::create( - [ - 'fs_path' => $fs_path, - 'mount_path' => $this->file_storage_path, - 'content' => $this->file_storage_content, - 'is_directory' => false, - 'resource_id' => $this->resource->id, - 'resource_type' => get_class($this->resource), - ], - ); - $this->dispatch('refreshStorages'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function submitFileStorageDirectory() - { - try { - $this->authorize('update', $this->resource); - - $this->validate([ - 'file_storage_directory_source' => 'string', - 'file_storage_directory_destination' => 'string', - ]); - - $this->file_storage_directory_source = trim($this->file_storage_directory_source); - $this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value(); - $this->file_storage_directory_destination = trim($this->file_storage_directory_destination); - $this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value(); - - LocalFileVolume::create( - [ - 'fs_path' => $this->file_storage_directory_source, - 'mount_path' => $this->file_storage_directory_destination, - 'is_directory' => true, - 'resource_id' => $this->resource->id, - 'resource_type' => get_class($this->resource), - ], - ); - $this->dispatch('refreshStorages'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function submitPersistentVolume() - { - try { - $this->authorize('update', $this->resource); - - $this->validate([ - 'name' => 'required|string', - 'mount_path' => 'required|string', - 'host_path' => 'string|nullable', - ]); - $name = $this->uuid.'-'.$this->name; - $this->dispatch('addNewVolume', [ - 'name' => $name, - 'mount_path' => $this->mount_path, - 'host_path' => $this->host_path, - ]); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function clear() - { - $this->name = ''; - $this->mount_path = ''; - $this->host_path = null; - } -} diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 3928ee1d4..4f57cbfa6 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -37,6 +37,11 @@ class Show extends Component 'host_path' => 'host', ]; + public function mount() + { + $this->isReadOnly = $this->storage->isReadOnlyVolume(); + } + public function submit() { $this->authorize('update', $this->resource); diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index 57cb79fca..84f5c6081 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -120,6 +120,8 @@ public function addCoolifyDatabase() public function submit() { + $this->validate(); + $this->database->update([ 'name' => $this->name, 'description' => $this->description, 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/Application.php b/app/Models/Application.php index 4f1796790..595ba1cde 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -182,6 +182,21 @@ protected static function booted() ]); $application->compose_parsing_version = self::$parserVersion; $application->save(); + + // Add default NIXPACKS_NODE_VERSION environment variable for Nixpacks applications + if ($application->build_pack === 'nixpacks') { + EnvironmentVariable::create([ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_multiline' => false, + 'is_literal' => false, + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + } }); static::forceDeleting(function ($application) { $application->update(['fqdn' => null]); @@ -739,9 +754,9 @@ public function environment_variables() return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) ->orderByRaw(" - CASE - WHEN LOWER(key) LIKE 'service_%' THEN 1 - WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + CASE + WHEN is_required = true THEN 1 + WHEN LOWER(key) LIKE 'service_%' THEN 2 ELSE 3 END, LOWER(key) ASC @@ -767,9 +782,9 @@ public function environment_variables_preview() return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) ->orderByRaw(" - CASE - WHEN LOWER(key) LIKE 'service_%' THEN 1 - WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + CASE + WHEN is_required = true THEN 1 + WHEN LOWER(key) LIKE 'service_%' THEN 2 ELSE 3 END, LOWER(key) ASC diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 8df6877ab..4e8eee10f 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -41,11 +41,9 @@ class ApplicationDeploymentQueue extends Model { protected $guarded = []; - public function application(): Attribute + public function application() { - return Attribute::make( - get: fn () => Application::find($this->application_id), - ); + return $this->belongsTo(Application::class); } public function server(): Attribute diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 437be7d87..c2ad9d2cb 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use OpenApi\Attributes as OA; @@ -19,6 +20,7 @@ )] class Environment extends BaseModel { + use ClearsGlobalSearchCache; use HasSafeStringAttribute; protected $guarded = []; @@ -33,6 +35,11 @@ protected static function booted() }); } + public static function ownedByCurrentTeam() + { + return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name'); + } + public function isEmpty() { return $this->applications()->count() == 0 && diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index b3e71d75d..376ea9c5e 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -5,6 +5,7 @@ use App\Events\FileStorageChanged; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Symfony\Component\Yaml\Yaml; class LocalFileVolume extends BaseModel { @@ -192,4 +193,61 @@ public function scopeWherePlainMountPath($query, $path) { return $query->get()->where('plain_mount_path', $path); } + + // Check if this volume is read-only by parsing the docker-compose content + public function isReadOnlyVolume(): bool + { + try { + // Only check for services + $service = $this->service; + if (! $service || ! method_exists($service, 'service')) { + return false; + } + + $actualService = $service->service; + if (! $actualService || ! $actualService->docker_compose_raw) { + return false; + } + + // Parse the docker-compose content + $compose = Yaml::parse($actualService->docker_compose_raw); + if (! isset($compose['services'])) { + return false; + } + + // Find the service that this volume belongs to + $serviceName = $service->name; + if (! isset($compose['services'][$serviceName]['volumes'])) { + return false; + } + + $volumes = $compose['services'][$serviceName]['volumes']; + + // Check each volume to find a match + foreach ($volumes as $volume) { + // Volume can be string like "host:container:ro" or "host:container" + if (is_string($volume)) { + $parts = explode(':', $volume); + + // Check if this volume matches our fs_path and mount_path + if (count($parts) >= 2) { + $hostPath = $parts[0]; + $containerPath = $parts[1]; + $options = $parts[2] ?? null; + + // Match based on fs_path and mount_path + if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) { + return $options === 'ro'; + } + } + } + } + + return false; + } catch (\Throwable $e) { + ray($e->getMessage(), 'Error checking read-only volume'); + + return false; + } + } } diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 00dc15fea..e7862478b 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; +use Symfony\Component\Yaml\Yaml; class LocalPersistentVolume extends Model { @@ -48,4 +49,69 @@ protected function hostPath(): Attribute } ); } + + // Check if this volume is read-only by parsing the docker-compose content + public function isReadOnlyVolume(): bool + { + try { + // Get the resource (can be application, service, or database) + $resource = $this->resource; + if (! $resource) { + return false; + } + + // Only check for services + if (! method_exists($resource, 'service')) { + return false; + } + + $actualService = $resource->service; + if (! $actualService || ! $actualService->docker_compose_raw) { + return false; + } + + // Parse the docker-compose content + $compose = Yaml::parse($actualService->docker_compose_raw); + if (! isset($compose['services'])) { + return false; + } + + // Find the service that this volume belongs to + $serviceName = $resource->name; + if (! isset($compose['services'][$serviceName]['volumes'])) { + return false; + } + + $volumes = $compose['services'][$serviceName]['volumes']; + + // Check each volume to find a match + foreach ($volumes as $volume) { + // Volume can be string like "host:container:ro" or "host:container" + if (is_string($volume)) { + $parts = explode(':', $volume); + + // Check if this volume matches our mount_path + if (count($parts) >= 2) { + $containerPath = $parts[1]; + $options = $parts[2] ?? null; + + // Match based on mount_path + // Remove leading slash from mount_path if present for comparison + $mountPath = str($this->mount_path)->ltrim('/')->toString(); + $containerPathClean = str($containerPath)->ltrim('/')->toString(); + + if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) { + return $options === 'ro'; + } + } + } + } + + return false; + } catch (\Throwable $e) { + ray($e->getMessage(), 'Error checking read-only persistent volume'); + + return false; + } + } } diff --git a/app/Models/Project.php b/app/Models/Project.php index 1c46042e3..a9bf76803 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use OpenApi\Attributes as OA; use Visus\Cuid2\Cuid2; @@ -24,6 +25,7 @@ )] class Project extends BaseModel { + use ClearsGlobalSearchCache; use HasSafeStringAttribute; protected $guarded = []; 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/Models/Service.php b/app/Models/Service.php index d42d471c6..c4b8623e0 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -547,6 +547,21 @@ public function extraFields() } $fields->put('Grafana', $data->toArray()); break; + case $image->contains('elasticsearch'): + $data = collect([]); + $elastic_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ELASTICSEARCH')->first(); + if ($elastic_password) { + $data = $data->merge([ + 'Password (default user: elastic)' => [ + 'key' => data_get($elastic_password, 'key'), + 'value' => data_get($elastic_password, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + $fields->put('Elasticsearch', $data->toArray()); + break; case $image->contains('directus'): $data = collect([]); $admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first(); @@ -1231,9 +1246,9 @@ public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->orderByRaw(" - CASE - WHEN LOWER(key) LIKE 'service_%' THEN 1 - WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + CASE + WHEN is_required = true THEN 1 + WHEN LOWER(key) LIKE 'service_%' THEN 2 ELSE 3 END, LOWER(key) ASC @@ -1263,6 +1278,21 @@ public function saveComposeConfigs() $commands[] = "cd $workdir"; $commands[] = 'rm -f .env || true'; + $envs = collect([]); + + // Generate SERVICE_NAME_* environment variables from docker-compose services + if ($this->docker_compose) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose); + $services = data_get($dockerCompose, 'services', []); + foreach ($services as $serviceName => $_) { + $envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName); + } + } catch (\Exception $e) { + ray($e->getMessage()); + } + } + $envs_from_coolify = $this->environment_variables()->get(); $sorted = $envs_from_coolify->sortBy(function ($env) { if (str($env->key)->startsWith('SERVICE_')) { @@ -1274,7 +1304,6 @@ public function saveComposeConfigs() return 3; }); - $envs = collect([]); foreach ($sorted as $env) { $envs->push("{$env->key}={$env->real_value}"); } 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/app/Rules/DockerImageFormat.php b/app/Rules/DockerImageFormat.php new file mode 100644 index 000000000..a6a78a76c --- /dev/null +++ b/app/Rules/DockerImageFormat.php @@ -0,0 +1,41 @@ + strrpos($imageString, '/'))) { - $mainPart = substr($imageString, 0, $lastColon); - $this->tag = substr($imageString, $lastColon + 1); + // Check for @sha256: format first (e.g., nginx@sha256:abc123...) + if (preg_match('/^(.+)@sha256:([a-f0-9]{64})$/i', $imageString, $matches)) { + $mainPart = $matches[1]; + $this->tag = $matches[2]; + $this->isImageHash = true; } else { - $mainPart = $imageString; - $this->tag = 'latest'; + // Split by : to handle the tag, but be careful with registry ports + $lastColon = strrpos($imageString, ':'); + $hasSlash = str_contains($imageString, '/'); + + // If the last colon appears after the last slash, it's a tag + // Otherwise it might be a port in the registry URL + if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) { + $mainPart = substr($imageString, 0, $lastColon); + $this->tag = substr($imageString, $lastColon + 1); + + // Check if the tag is a SHA256 hash + $this->isImageHash = $this->isSha256Hash($this->tag); + } else { + $mainPart = $imageString; + $this->tag = 'latest'; + $this->isImageHash = false; + } } // Split the main part by / to handle registry and image name @@ -41,6 +54,37 @@ public function parse(string $imageString): self return $this; } + /** + * Check if the given string is a SHA256 hash + */ + private function isSha256Hash(string $hash): bool + { + // SHA256 hashes are 64 characters long and contain only hexadecimal characters + return preg_match('/^[a-f0-9]{64}$/i', $hash) === 1; + } + + /** + * Check if the current tag is an image hash + */ + public function isImageHash(): bool + { + return $this->isImageHash; + } + + /** + * Get the full image name with hash if present + */ + public function getFullImageNameWithHash(): string + { + $imageName = $this->getFullImageNameWithoutTag(); + + if ($this->isImageHash) { + return $imageName.'@sha256:'.$this->tag; + } + + return $imageName.':'.$this->tag; + } + public function getFullImageNameWithoutTag(): string { if ($this->registryUrl) { @@ -73,6 +117,10 @@ public function toString(): string } $parts[] = $this->imageName; + if ($this->isImageHash) { + return implode('/', $parts).'@sha256:'.$this->tag; + } + return implode('/', $parts).':'.$this->tag; } } diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php index ae587aa87..b9af70aba 100644 --- a/app/Traits/ClearsGlobalSearchCache.php +++ b/app/Traits/ClearsGlobalSearchCache.php @@ -10,77 +10,119 @@ trait ClearsGlobalSearchCache protected static function bootClearsGlobalSearchCache() { static::saving(function ($model) { - // Only clear cache if searchable fields are being changed - if ($model->hasSearchableChanges()) { - $teamId = $model->getTeamIdForCache(); - if (filled($teamId)) { - GlobalSearch::clearTeamCache($teamId); + try { + // Only clear cache if searchable fields are being changed + if ($model->hasSearchableChanges()) { + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } } + } catch (\Throwable $e) { + // Silently fail cache clearing - don't break the save operation + ray('Failed to clear global search cache on saving: '.$e->getMessage()); } }); static::created(function ($model) { - // Always clear cache when model is created - $teamId = $model->getTeamIdForCache(); - if (filled($teamId)) { - GlobalSearch::clearTeamCache($teamId); + try { + // Always clear cache when model is created + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + } catch (\Throwable $e) { + // Silently fail cache clearing - don't break the create operation + ray('Failed to clear global search cache on creation: '.$e->getMessage()); } }); static::deleted(function ($model) { - // Always clear cache when model is deleted - $teamId = $model->getTeamIdForCache(); - if (filled($teamId)) { - GlobalSearch::clearTeamCache($teamId); + try { + // Always clear cache when model is deleted + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + } catch (\Throwable $e) { + // Silently fail cache clearing - don't break the delete operation + ray('Failed to clear global search cache on deletion: '.$e->getMessage()); } }); } private function hasSearchableChanges(): bool { - // Define searchable fields based on model type - $searchableFields = ['name', 'description']; + try { + // Define searchable fields based on model type + $searchableFields = ['name', 'description']; - // Add model-specific searchable fields - if ($this instanceof \App\Models\Application) { - $searchableFields[] = 'fqdn'; - $searchableFields[] = 'docker_compose_domains'; - } elseif ($this instanceof \App\Models\Server) { - $searchableFields[] = 'ip'; - } elseif ($this instanceof \App\Models\Service) { - // Services don't have direct fqdn, but name and description are covered - } - // Database models only have name and description as searchable - - // Check if any searchable field is dirty - foreach ($searchableFields as $field) { - if ($this->isDirty($field)) { - return true; + // Add model-specific searchable fields + if ($this instanceof \App\Models\Application) { + $searchableFields[] = 'fqdn'; + $searchableFields[] = 'docker_compose_domains'; + } elseif ($this instanceof \App\Models\Server) { + $searchableFields[] = 'ip'; + } elseif ($this instanceof \App\Models\Service) { + // Services don't have direct fqdn, but name and description are covered + } elseif ($this instanceof \App\Models\Project || $this instanceof \App\Models\Environment) { + // Projects and environments only have name and description as searchable } - } + // Database models only have name and description as searchable - return false; + // Check if any searchable field is dirty + foreach ($searchableFields as $field) { + // Check if attribute exists before checking if dirty + if (array_key_exists($field, $this->getAttributes()) && $this->isDirty($field)) { + return true; + } + } + + return false; + } catch (\Throwable $e) { + // If checking changes fails, assume changes exist to be safe + ray('Failed to check searchable changes: '.$e->getMessage()); + + return true; + } } private function getTeamIdForCache() { - // For database models, team is accessed through environment.project.team - if (method_exists($this, 'team')) { - if ($this instanceof \App\Models\Server) { - $team = $this->team; - } else { - $team = $this->team(); + try { + // For Project models (has direct team_id) + if ($this instanceof \App\Models\Project) { + return $this->team_id ?? null; } - if (filled($team)) { - return is_object($team) ? $team->id : null; + + // For Environment models (get team_id through project) + if ($this instanceof \App\Models\Environment) { + return $this->project?->team_id; } - } - // For models with direct team_id property - if (property_exists($this, 'team_id') || isset($this->team_id)) { - return $this->team_id; - } + // For database models, team is accessed through environment.project.team + if (method_exists($this, 'team')) { + if ($this instanceof \App\Models\Server) { + $team = $this->team; + } else { + $team = $this->team(); + } + if (filled($team)) { + return is_object($team) ? $team->id : null; + } + } - return null; + // For models with direct team_id property + if (property_exists($this, 'team_id') || isset($this->team_id)) { + return $this->team_id ?? null; + } + + return null; + } catch (\Throwable $e) { + // If we can't determine team ID, return null + ray('Failed to get team ID for cache: '.$e->getMessage()); + + return null; + } } } diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index b568e090c..36243e119 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -21,13 +21,23 @@ 'bitnami/mariadb', 'bitnami/mongodb', 'bitnami/redis', + 'bitnamilegacy/mariadb', + 'bitnamilegacy/mongodb', + 'bitnamilegacy/redis', + 'bitnamisecure/mariadb', + 'bitnamisecure/mongodb', + 'bitnamisecure/redis', 'mysql', 'bitnami/mysql', + 'bitnamilegacy/mysql', + 'bitnamisecure/mysql', 'mysql/mysql-server', 'mariadb', 'postgis/postgis', 'postgres', 'bitnami/postgresql', + 'bitnamilegacy/postgresql', + 'bitnamisecure/postgresql', 'supabase/postgres', 'elestio/postgres', 'mongo', 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/docker.php b/bootstrap/helpers/docker.php index 1491e4712..b63c3fc3b 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1119,3 +1119,53 @@ function escapeDollarSign($value) return str_replace($search, $replace, $value); } + +/** + * Generate Docker build arguments from environment variables collection + * Returns only keys (no values) since values are sourced from environment via export + * + * @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' + * @return \Illuminate\Support\Collection Collection of formatted --build-arg strings (keys only) + */ +function generateDockerBuildArgs($variables): \Illuminate\Support\Collection +{ + $variables = collect($variables); + + return $variables->map(function ($var) { + $key = is_array($var) ? data_get($var, 'key') : $var->key; + + // Only return the key - Docker will get the value from the environment + return "--build-arg {$key}"; + }); +} + +/** + * Generate Docker environment flags from environment variables collection + * + * @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' + * @return string Space-separated environment flags + */ +function generateDockerEnvFlags($variables): string +{ + $variables = collect($variables); + + return $variables + ->map(function ($var) { + $key = is_array($var) ? data_get($var, 'key') : $var->key; + $value = is_array($var) ? data_get($var, 'value') : $var->value; + $isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false); + + if ($isMultiline) { + // For multiline variables, strip surrounding quotes and escape for bash + $raw_value = trim($value, "'"); + $escaped_value = str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $raw_value); + + return "-e {$key}=\"{$escaped_value}\""; + } + + $escaped_value = escapeshellarg($value); + + return "-e {$key}={$escaped_value}"; + }) + ->implode(' '); +} diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 25cc5d0a6..a588ed882 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1172,6 +1172,9 @@ function serviceParser(Service $resource): Collection $parsedServices = collect([]); + // Generate SERVICE_NAME variables for docker compose services + $serviceNameEnvironments = generateDockerComposeServiceName($services); + $allMagicEnvironments = collect([]); // Presave services foreach ($services as $serviceName => $service) { @@ -1988,7 +1991,7 @@ function serviceParser(Service $resource): Collection $payload['volumes'] = $volumesParsed; } if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) { - $payload['environment'] = $environment->merge($coolifyEnvironments); + $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments); } if ($logging) { $payload['logging'] = $logging; 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/conductor.json b/conductor.json new file mode 100644 index 000000000..eaae44500 --- /dev/null +++ b/conductor.json @@ -0,0 +1,7 @@ +{ + "scripts": { + "setup": "./scripts/conductor-setup.sh", + "run": "spin up; spin down" + }, + "runScriptMode": "nonconcurrent" +} \ No newline at end of file diff --git a/config/constants.php b/config/constants.php index 749d6435b..01eaa7fa1 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.433', + 'version' => '4.0.0-beta.435', 'helper_version' => '1.0.11', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/database/migrations/2025_10_03_154100_update_clickhouse_image.php b/database/migrations/2025_10_03_154100_update_clickhouse_image.php new file mode 100644 index 000000000..e52bbcc16 --- /dev/null +++ b/database/migrations/2025_10_03_154100_update_clickhouse_image.php @@ -0,0 +1,32 @@ +string('image')->default('bitnamilegacy/clickhouse')->change(); + }); + // Optionally, update any existing rows with the old default to the new one + DB::table('standalone_clickhouses') + ->where('image', 'bitnami/clickhouse') + ->update(['image' => 'bitnamilegacy/clickhouse']); + } + + public function down() + { + Schema::table('standalone_clickhouses', function (Blueprint $table) { + $table->string('image')->default('bitnami/clickhouse')->change(); + }); + // Optionally, revert any changed values + DB::table('standalone_clickhouses') + ->where('image', 'bitnamilegacy/clickhouse') + ->update(['image' => 'bitnami/clickhouse']); + } +}; \ No newline at end of file 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/docker-compose.dev.yml b/docker-compose.dev.yml index e8402b7af..9992b70ba 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,5 +1,6 @@ services: coolify: + image: coolify:dev build: context: . dockerfile: ./docker/development/Dockerfile @@ -41,6 +42,7 @@ services: volumes: - dev_redis_data:/data soketi: + image: coolify-realtime:dev build: context: . dockerfile: ./docker/coolify-realtime/Dockerfile @@ -73,6 +75,7 @@ services: networks: - coolify testing-host: + image: coolify-testing-host:dev build: context: . dockerfile: ./docker/testing-host/Dockerfile diff --git a/other/nightly/versions.json b/other/nightly/versions.json index b5cf3360a..2e5cc5e84 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.433" + "version": "4.0.0-beta.435" }, "nightly": { - "version": "4.0.0-beta.434" + "version": "4.0.0-beta.436" }, "helper": { "version": "1.0.11" diff --git a/public/ente-photos-icon-green.png b/public/ente-photos-icon-green.png new file mode 100644 index 000000000..b74aa472d Binary files /dev/null and b/public/ente-photos-icon-green.png differ diff --git a/public/svgs/ente-photos.svg b/public/svgs/ente-photos.svg new file mode 100644 index 000000000..e6a469e91 --- /dev/null +++ b/public/svgs/ente-photos.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/svgs/ente.png b/public/svgs/ente.png new file mode 100644 index 000000000..f510a7bf7 Binary files /dev/null and b/public/svgs/ente.png differ diff --git a/resources/css/app.css b/resources/css/app.css index 77fa2d66b..c1dc7e56d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -20,8 +20,11 @@ @theme { --color-warning: #fcd452; --color-success: #16a34a; --color-error: #dc2626; + --color-coollabs-50: #f5f0ff; --color-coollabs: #6b16ed; --color-coollabs-100: #7317ff; + --color-coollabs-200: #5a12c7; + --color-coollabs-300: #4a0fa3; --color-coolgray-100: #181818; --color-coolgray-200: #202020; --color-coolgray-300: #242424; @@ -91,11 +94,11 @@ option { } button[isError]:not(:disabled) { - @apply text-white bg-red-600 hover:bg-red-700; + @apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white dark:hover:bg-red-800 dark:hover:text-white; } button[isHighlighted]:not(:disabled) { - @apply text-white bg-coollabs hover:bg-coollabs-100; + @apply text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white dark:hover:bg-coollabs-100 dark:hover:text-white; } h1 { @@ -118,6 +121,11 @@ a { @apply hover:text-black dark:hover:text-white; } +button:focus-visible, +a:focus-visible { + @apply outline-none ring-2 ring-coollabs dark:ring-warning ring-offset-2 dark:ring-offset-coolgray-100; +} + label { @apply dark:text-neutral-400; } diff --git a/resources/css/utilities.css b/resources/css/utilities.css index cbbe2ef8e..bedfb51bc 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -63,7 +63,7 @@ @utility select { } @utility button { - @apply flex gap-2 justify-center items-center px-2 py-1 text-sm text-black normal-case rounded-sm border outline-0 cursor-pointer bg-neutral-200/50 border-neutral-300 hover:bg-neutral-300 dark:bg-coolgray-200 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-500 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300; + @apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100; } @utility alert-success { @@ -83,11 +83,11 @@ @utility add-tag { } @utility dropdown-item { - @apply flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50; + @apply flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs; } @utility dropdown-item-no-padding { - @apply flex relative gap-2 justify-start items-center py-1 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50; + @apply flex relative gap-2 justify-start items-center py-1 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs; } @utility badge { @@ -155,15 +155,15 @@ @utility kbd-custom { } @utility box { - @apply relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-black hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline; + @apply relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm; } @utility box-boarding { - @apply flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 dark:text-white bg-neutral-50 border border-neutral-200 dark:border-black hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:text-black hover:no-underline text-black; + @apply flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 dark:text-white bg-neutral-50 border border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:text-black hover:no-underline text-black rounded-sm; } @utility box-without-bg { - @apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem] border border-neutral-200 dark:border-black; + @apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem] border border-neutral-200 dark:border-coolgray-300 rounded-sm; } @utility box-without-bg-without-border { diff --git a/resources/views/components/applications/advanced.blade.php b/resources/views/components/applications/advanced.blade.php index 46ea54e99..e36583741 100644 --- a/resources/views/components/applications/advanced.blade.php +++ b/resources/views/components/applications/advanced.blade.php @@ -19,7 +19,7 @@ @else diff --git a/resources/views/livewire/notifications/email.blade.php b/resources/views/livewire/notifications/email.blade.php index a3b46fc89..345d6bc58 100644 --- a/resources/views/livewire/notifications/email.blade.php +++ b/resources/views/livewire/notifications/email.blade.php @@ -57,7 +57,7 @@ @if (!$useInstanceEmailSettings)
+ class="p-4 border dark:border-coolgray-300 border-neutral-200 rounded-lg flex flex-col gap-2">

SMTP Server

@@ -89,7 +89,7 @@ class="p-4 border dark:border-coolgray-300 border-neutral-200 flex flex-col gap-
+ class="p-4 border dark:border-coolgray-300 border-neutral-200 rounded-lg flex flex-col gap-2">

Resend

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/application/deployment-navbar.blade.php b/resources/views/livewire/project/application/deployment-navbar.blade.php index effb6b6fe..60c660bf7 100644 --- a/resources/views/livewire/project/application/deployment-navbar.blade.php +++ b/resources/views/livewire/project/application/deployment-navbar.blade.php @@ -5,6 +5,9 @@ @else Show Debug Logs @endif + @if (isDev()) + Copy Logs + @endif @if (data_get($application_deployment_queue, 'status') === 'queued') Force Start @endif diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 8587b2ab5..e7e26c134 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -166,12 +166,14 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @if ($application->destination->server->isSwarm()) - @else - @endif @else diff --git a/resources/views/livewire/project/application/rollback.blade.php b/resources/views/livewire/project/application/rollback.blade.php index 1844316f9..e0b1465dc 100644 --- a/resources/views/livewire/project/application/rollback.blade.php +++ b/resources/views/livewire/project/application/rollback.blade.php @@ -11,7 +11,7 @@
@forelse ($images as $image)
-
+
@if (data_get($image, 'is_current')) 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 89da9117e..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/project/new/docker-image.blade.php b/resources/views/livewire/project/new/docker-image.blade.php index 4cc86710a..54c175b82 100644 --- a/resources/views/livewire/project/new/docker-image.blade.php +++ b/resources/views/livewire/project/new/docker-image.blade.php @@ -1,4 +1,4 @@ -
+

Create a new Application

You can deploy an existing Docker Image from any Registry.
@@ -6,6 +6,24 @@

Docker Image

Save
- +
+ +
+ + + +
+
diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index aa0ce66a3..dc8f949fa 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -1,73 +1,101 @@ -
-
-
- - -
-
-
- @can('update', $resource) -
+
+
+ @if ($isReadOnly) +
@if ($fileStorage->is_directory) - - + This directory is mounted as read-only and cannot be modified from the UI. @else - @if (!$fileStorage->is_binary) - - @endif - Load from server - + This file is mounted as read-only and cannot be modified from the UI. @endif
- @endcan - @if (!$fileStorage->is_directory) - @can('update', $resource) - @if (data_get($resource, 'settings.is_preserve_repository_enabled')) -
- + @endif +
+
+ + +
+
+ + @if (!$isReadOnly) + @can('update', $resource) +
+ @if ($fileStorage->is_directory) + + + @else + @if (!$fileStorage->is_binary) + + @endif + Load from + server + + @endif
- @endif - - @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary) - Save + @endcan + @if (!$fileStorage->is_directory) + @can('update', $resource) + @if (data_get($resource, 'settings.is_preserve_repository_enabled')) +
+ +
+ @endif + + @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary) + Save + @endif + @else + @if (data_get($resource, 'settings.is_preserve_repository_enabled')) +
+ +
+ @endif + + @endcan @endif @else - @if (data_get($resource, 'settings.is_preserve_repository_enabled')) -
- -
+ {{-- Read-only view --}} + @if (!$fileStorage->is_directory) + @if (data_get($resource, 'settings.is_preserve_repository_enabled')) +
+ +
+ @endif + @endif - - @endcan - @endif - + @endif + +
diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 41d48f386..d55bd801a 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -1,4 +1,4 @@ -
+
@if ( $resource->getMorphClass() == 'App\Models\Application' || $resource->getMorphClass() == 'App\Models\StandalonePostgresql' || @@ -9,55 +9,451 @@ $resource->getMorphClass() == 'App\Models\StandaloneClickhouse' || $resource->getMorphClass() == 'App\Models\StandaloneMongodb' || $resource->getMorphClass() == 'App\Models\StandaloneMysql') -
-

Storages

- - @if ($resource?->build_pack !== 'dockercompose') - @can('update', $resource) - - - - @endcan - @endif +
+
+

Storages

+ + @if ($resource?->build_pack !== 'dockercompose') + @can('update', $resource) +
+ + + {{-- Volume Modal --}} + + + {{-- File Modal --}} + + + {{-- Directory Modal --}} + +
+ @endcan + @endif +
+
Persistent storage to preserve data between deployments.
-
Persistent storage to preserve data between deployments.
@if ($resource?->build_pack === 'dockercompose') - Please modify storage layout in your Docker Compose - file or reload the compose file to reread the storage layout. +
Please modify storage layout in your Docker Compose + file or reload the compose file to reread the storage layout.
@else @if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0) -
No storage found.
+
No storage found.
@endif @endif - @if ($resource->persistentStorages()->get()->count() > 0) -

Volumes

- - @endif - @if ($fileStorage->count() > 0) -
- @foreach ($fileStorage as $fs) - - @endforeach + @php + $hasVolumes = $this->volumeCount > 0; + $hasFiles = $this->fileCount > 0; + $hasDirectories = $this->directoryCount > 0; + $defaultTab = $hasVolumes ? 'volumes' : ($hasFiles ? 'files' : 'directories'); + @endphp + + @if ($hasVolumes || $hasFiles || $hasDirectories) +
+ {{-- Tabs Navigation --}} +
+ + + +
+ + {{-- Tab Content --}} +
+ {{-- Volumes Tab --}} +
+ @if ($hasVolumes) + + @else +
+ No volumes configured. +
+ @endif +
+ + {{-- Files Tab --}} +
+ @if ($hasFiles) + @foreach ($this->files as $fs) + + @endforeach + @else +
+ No file mounts configured. +
+ @endif +
+ + {{-- Directories Tab --}} +
+ @if ($hasDirectories) + @foreach ($this->directories as $fs) + + @endforeach + @else +
+ No directory mounts configured. +
+ @endif +
+
@endif @else - @if ($resource->persistentStorages()->get()->count() > 0) -

{{ Str::headline($resource->name) }}

- @endif - @if ($resource->persistentStorages()->get()->count() > 0) - - @endif - @if ($fileStorage->count() > 0) -
- @foreach ($fileStorage->sort() as $fileStorage) - - @endforeach +
+
+
+

{{ Str::headline($resource->name) }}

+
- @endif + + @if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0) +
No storage found.
+ @endif + + @php + $hasVolumes = $this->volumeCount > 0; + $hasFiles = $this->fileCount > 0; + $hasDirectories = $this->directoryCount > 0; + $defaultTab = $hasVolumes ? 'volumes' : ($hasFiles ? 'files' : 'directories'); + @endphp + + @if ($hasVolumes || $hasFiles || $hasDirectories) +
+ {{-- Tabs Navigation --}} +
+ + + +
+ + {{-- Tab Content --}} +
+ {{-- Volumes Tab --}} +
+ @if ($hasVolumes) + + @else +
+ No volumes configured. +
+ @endif +
+ + {{-- Files Tab --}} +
+ @if ($hasFiles) + @foreach ($this->files as $fs) + + @endforeach + @else +
+ No file mounts configured. +
+ @endif +
+ + {{-- Directories Tab --}} +
+ @if ($hasDirectories) + @foreach ($this->directories as $fs) + + @endforeach + @else +
+ No directory mounts configured. +
+ @endif +
+
+
+ @endif +
@endif
diff --git a/resources/views/livewire/project/shared/destination.blade.php b/resources/views/livewire/project/shared/destination.blade.php index 68ae44dc0..c9124f82b 100644 --- a/resources/views/livewire/project/shared/destination.blade.php +++ b/resources/views/livewire/project/shared/destination.blade.php @@ -5,7 +5,7 @@

Primary Server

+ class="relative flex flex-col bg-white border cursor-default dark:text-white box-without-bg dark:bg-coolgray-100 dark:border-coolgray-300"> @if (str($resource->realStatus())->startsWith('running'))
@@ -36,7 +36,7 @@ class="relative flex flex-col bg-white border cursor-default dark:text-white box @foreach ($resource->additional_networks as $destination)
+ class="relative flex flex-col bg-white border cursor-default dark:text-white box-without-bg dark:bg-coolgray-100 dark:border-coolgray-300"> @if (str(data_get($destination, 'pivot.status'))->startsWith('running'))
diff --git a/resources/views/livewire/project/shared/storages/add.blade.php b/resources/views/livewire/project/shared/storages/add.blade.php deleted file mode 100644 index 20155e661..000000000 --- a/resources/views/livewire/project/shared/storages/add.blade.php +++ /dev/null @@ -1,59 +0,0 @@ -
-
-
-

Volume Mount

-
Docker Volumes mounted to the container.
-
- @if ($isSwarm) -
Swarm Mode detected: You need to set a shared volume (EFS/NFS/etc) on all the worker nodes if you - would - like to use a persistent volumes.
- @endif -
- - @if ($isSwarm) - - @else - - @endif - - - Add - -
- -
-
-
-

File Mount

-
Actual file mounted from the host system to the container.
-
-
- - - - Add - -
-
-
-
-

Directory Mount

-
Directory mounted from the host system to the container.
-
-
- - - - Add - -
-
-
diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index 569df0c4b..798a97d94 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -1,6 +1,9 @@
-
+ @if ($isReadOnly) +
+ This volume is mounted as read-only and cannot be modified from the UI. +
@if ($isFirst)
@if ( diff --git a/resources/views/livewire/project/show.blade.php b/resources/views/livewire/project/show.blade.php index 3d034b8f3..1e2183ff1 100644 --- a/resources/views/livewire/project/show.blade.php +++ b/resources/views/livewire/project/show.blade.php @@ -21,7 +21,7 @@
{{ $project->name }}.
@forelse ($project->environments->sortBy('created_at') as $environment) -
+
All your servers are here.
-
+
@forelse ($servers as $server)
+ class="p-1 bg-white border rounded-sm shadow-lg dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
@if ($unreadCount > 0) diff --git a/resources/views/livewire/shared-variables/index.blade.php b/resources/views/livewire/shared-variables/index.blade.php index 531ebc034..8b85b11cf 100644 --- a/resources/views/livewire/shared-variables/index.blade.php +++ b/resources/views/livewire/shared-variables/index.blade.php @@ -7,7 +7,7 @@
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/livewire/switch-team.blade.php b/resources/views/livewire/switch-team.blade.php index 52500087e..b46c1ecf6 100644 --- a/resources/views/livewire/switch-team.blade.php +++ b/resources/views/livewire/switch-team.blade.php @@ -1,4 +1,4 @@ - + @foreach (auth()->user()->teams as $team) 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/Feature/MultilineEnvironmentVariableTest.php b/tests/Feature/MultilineEnvironmentVariableTest.php new file mode 100644 index 000000000..e32a2ce99 --- /dev/null +++ b/tests/Feature/MultilineEnvironmentVariableTest.php @@ -0,0 +1,208 @@ + 'SSH_PRIVATE_KEY', 'value' => "'{$sshKey}'", 'is_multiline' => true], + ['key' => 'REGULAR_VAR', 'value' => 'simple value', 'is_multiline' => false], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + + // SSH key should use double quotes and have proper escaping + $sshArg = $buildArgs->first(); + expect($sshArg)->toStartWith('--build-arg SSH_PRIVATE_KEY="'); + expect($sshArg)->toEndWith('"'); + expect($sshArg)->toContain('BEGIN OPENSSH PRIVATE KEY'); + expect($sshArg)->not->toContain("'BEGIN"); // Should not have the wrapper single quotes + + // Regular var should use escapeshellarg (single quotes) + $regularArg = $buildArgs->last(); + expect($regularArg)->toBe("--build-arg REGULAR_VAR='simple value'"); +}); + +test('multiline variables with special bash characters are escaped correctly', function () { + $valueWithSpecialChars = "line1\nline2 with \"quotes\"\nline3 with \$variables\nline4 with `backticks`"; + + $variables = [ + ['key' => 'SPECIAL_VALUE', 'value' => "'{$valueWithSpecialChars}'", 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // Verify double quotes are escaped + expect($arg)->toContain('\\"quotes\\"'); + // Verify dollar signs are escaped + expect($arg)->toContain('\\$variables'); + // Verify backticks are escaped + expect($arg)->toContain('\\`backticks\\`'); +}); + +test('single-line environment variables use escapeshellarg', function () { + $variables = [ + ['key' => 'SIMPLE_VAR', 'value' => 'simple value with spaces', 'is_multiline' => false], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // Should use single quotes from escapeshellarg + expect($arg)->toBe("--build-arg SIMPLE_VAR='simple value with spaces'"); +}); + +test('multiline certificate with newlines is preserved', function () { + $certificate = '-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAKL0UG+mRkSvMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTkwOTE3MDUzMzI5WhcNMjkwOTE0MDUzMzI5WjBF +-----END CERTIFICATE-----'; + + $variables = [ + ['key' => 'TLS_CERT', 'value' => "'{$certificate}'", 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // Newlines should be preserved in the output + expect($arg)->toContain("\n"); + expect($arg)->toContain('BEGIN CERTIFICATE'); + expect($arg)->toContain('END CERTIFICATE'); + expect(substr_count($arg, "\n"))->toBeGreaterThan(0); +}); + +test('multiline JSON configuration is properly escaped', function () { + $jsonConfig = '{ + "key": "value", + "nested": { + "array": [1, 2, 3] + } +}'; + + $variables = [ + ['key' => 'JSON_CONFIG', 'value' => "'{$jsonConfig}'", 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // All double quotes in JSON should be escaped + expect($arg)->toContain('\\"key\\"'); + expect($arg)->toContain('\\"value\\"'); + expect($arg)->toContain('\\"nested\\"'); +}); + +test('empty multiline variable is handled correctly', function () { + $variables = [ + ['key' => 'EMPTY_VAR', 'value' => "''", 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + expect($arg)->toBe('--build-arg EMPTY_VAR=""'); +}); + +test('multiline variable with only newlines', function () { + $onlyNewlines = "\n\n\n"; + + $variables = [ + ['key' => 'NEWLINES_ONLY', 'value' => "'{$onlyNewlines}'", 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + expect($arg)->toContain("\n"); + // Should have 3 newlines preserved + expect(substr_count($arg, "\n"))->toBe(3); +}); + +test('multiline variable with backslashes is escaped correctly', function () { + $valueWithBackslashes = "path\\to\\file\nC:\\Windows\\System32"; + + $variables = [ + ['key' => 'PATH_VAR', 'value' => "'{$valueWithBackslashes}'", 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // Backslashes should be doubled + expect($arg)->toContain('path\\\\to\\\\file'); + expect($arg)->toContain('C:\\\\Windows\\\\System32'); +}); + +test('generateDockerEnvFlags produces correct format', function () { + $variables = [ + ['key' => 'NORMAL_VAR', 'value' => 'value', 'is_multiline' => false], + ['key' => 'MULTILINE_VAR', 'value' => "'line1\nline2'", 'is_multiline' => true], + ]; + + $envFlags = generateDockerEnvFlags($variables); + + expect($envFlags)->toContain('-e NORMAL_VAR='); + expect($envFlags)->toContain('-e MULTILINE_VAR="'); + expect($envFlags)->toContain('line1'); + expect($envFlags)->toContain('line2'); +}); + +test('helper functions work with collection input', function () { + $variables = collect([ + (object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false], + (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true], + ]); + + $buildArgs = generateDockerBuildArgs($variables); + expect($buildArgs)->toHaveCount(2); + + $envFlags = generateDockerEnvFlags($variables); + expect($envFlags)->toBeString(); + expect($envFlags)->toContain('-e VAR1='); + expect($envFlags)->toContain('-e VAR2="'); +}); + +test('variables without is_multiline default to false', function () { + $variables = [ + ['key' => 'NO_FLAG_VAR', 'value' => 'some value'], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // Should use escapeshellarg (single quotes) since is_multiline defaults to false + expect($arg)->toBe("--build-arg NO_FLAG_VAR='some value'"); +}); + +test('real world SSH key example', function () { + // Simulate what real_value returns (wrapped in single quotes) + $sshKey = "'-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----'"; + + $variables = [ + ['key' => 'KEY', 'value' => $sshKey, 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // Should produce clean output without wrapper quotes + expect($arg)->toStartWith('--build-arg KEY="-----BEGIN OPENSSH PRIVATE KEY-----'); + expect($arg)->toEndWith('-----END OPENSSH PRIVATE KEY-----"'); + // Should NOT have the escaped quote sequence that was in the bug + expect($arg)->not->toContain("''"); + expect($arg)->not->toContain("'\\''"); +}); diff --git a/tests/Unit/DockerImageParserTest.php b/tests/Unit/DockerImageParserTest.php index 35dffbab4..6102a90b2 100644 --- a/tests/Unit/DockerImageParserTest.php +++ b/tests/Unit/DockerImageParserTest.php @@ -1,94 +1,109 @@ parse('nginx:latest'); - protected function setUp(): void - { - parent::setUp(); - $this->parser = new DockerImageParser; + expect($parser->getImageName())->toBe('nginx') + ->and($parser->getTag())->toBe('latest') + ->and($parser->isImageHash())->toBeFalse() + ->and($parser->toString())->toBe('nginx:latest'); +}); + +it('parses image with sha256 hash using colon format', function () { + $parser = new DockerImageParser; + $hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0'; + $parser->parse("ghcr.io/benjaminehowe/rail-disruptions:{$hash}"); + + expect($parser->getFullImageNameWithoutTag())->toBe('ghcr.io/benjaminehowe/rail-disruptions') + ->and($parser->getTag())->toBe($hash) + ->and($parser->isImageHash())->toBeTrue() + ->and($parser->toString())->toBe("ghcr.io/benjaminehowe/rail-disruptions@sha256:{$hash}") + ->and($parser->getFullImageNameWithHash())->toBe("ghcr.io/benjaminehowe/rail-disruptions@sha256:{$hash}"); +}); + +it('parses image with sha256 hash using at sign format', function () { + $parser = new DockerImageParser; + $hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0'; + $parser->parse("nginx@sha256:{$hash}"); + + expect($parser->getImageName())->toBe('nginx') + ->and($parser->getTag())->toBe($hash) + ->and($parser->isImageHash())->toBeTrue() + ->and($parser->toString())->toBe("nginx@sha256:{$hash}") + ->and($parser->getFullImageNameWithHash())->toBe("nginx@sha256:{$hash}"); +}); + +it('parses registry image with hash', function () { + $parser = new DockerImageParser; + $hash = 'abc123def456789abcdef123456789abcdef123456789abcdef123456789abc1'; + $parser->parse("docker.io/library/nginx:{$hash}"); + + expect($parser->getFullImageNameWithoutTag())->toBe('docker.io/library/nginx') + ->and($parser->getTag())->toBe($hash) + ->and($parser->isImageHash())->toBeTrue() + ->and($parser->toString())->toBe("docker.io/library/nginx@sha256:{$hash}"); +}); + +it('parses image without tag defaults to latest', function () { + $parser = new DockerImageParser; + $parser->parse('nginx'); + + expect($parser->getImageName())->toBe('nginx') + ->and($parser->getTag())->toBe('latest') + ->and($parser->isImageHash())->toBeFalse() + ->and($parser->toString())->toBe('nginx:latest'); +}); + +it('parses registry with port', function () { + $parser = new DockerImageParser; + $parser->parse('registry.example.com:5000/myapp:latest'); + + expect($parser->getFullImageNameWithoutTag())->toBe('registry.example.com:5000/myapp') + ->and($parser->getTag())->toBe('latest') + ->and($parser->isImageHash())->toBeFalse(); +}); + +it('parses registry with port and hash', function () { + $parser = new DockerImageParser; + $hash = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + $parser->parse("registry.example.com:5000/myapp:{$hash}"); + + expect($parser->getFullImageNameWithoutTag())->toBe('registry.example.com:5000/myapp') + ->and($parser->getTag())->toBe($hash) + ->and($parser->isImageHash())->toBeTrue() + ->and($parser->toString())->toBe("registry.example.com:5000/myapp@sha256:{$hash}"); +}); + +it('identifies valid sha256 hashes', function () { + $validHashes = [ + '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0', + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + ]; + + foreach ($validHashes as $hash) { + $parser = new DockerImageParser; + $parser->parse("image:{$hash}"); + expect($parser->isImageHash())->toBeTrue("Hash {$hash} should be recognized as valid SHA256"); } +}); - #[Test] - public function it_parses_simple_image_name() - { - $this->parser->parse('nginx'); +it('identifies invalid sha256 hashes', function () { + $invalidHashes = [ + 'latest', + 'v1.2.3', + 'abc123', // too short + '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf', // too short + '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf00', // too long + '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cfg0', // invalid char + ]; - $this->assertEquals('', $this->parser->getRegistryUrl()); - $this->assertEquals('nginx', $this->parser->getImageName()); - $this->assertEquals('latest', $this->parser->getTag()); + foreach ($invalidHashes as $hash) { + $parser = new DockerImageParser; + $parser->parse("image:{$hash}"); + expect($parser->isImageHash())->toBeFalse("Hash {$hash} should not be recognized as valid SHA256"); } - - #[Test] - public function it_parses_image_with_tag() - { - $this->parser->parse('nginx:1.19'); - - $this->assertEquals('', $this->parser->getRegistryUrl()); - $this->assertEquals('nginx', $this->parser->getImageName()); - $this->assertEquals('1.19', $this->parser->getTag()); - } - - #[Test] - public function it_parses_image_with_organization() - { - $this->parser->parse('coollabs/coolify:latest'); - - $this->assertEquals('', $this->parser->getRegistryUrl()); - $this->assertEquals('coollabs/coolify', $this->parser->getImageName()); - $this->assertEquals('latest', $this->parser->getTag()); - } - - #[Test] - public function it_parses_image_with_registry_url() - { - $this->parser->parse('ghcr.io/coollabs/coolify:v4'); - - $this->assertEquals('ghcr.io', $this->parser->getRegistryUrl()); - $this->assertEquals('coollabs/coolify', $this->parser->getImageName()); - $this->assertEquals('v4', $this->parser->getTag()); - } - - #[Test] - public function it_parses_image_with_port_in_registry() - { - $this->parser->parse('localhost:5000/my-app:dev'); - - $this->assertEquals('localhost:5000', $this->parser->getRegistryUrl()); - $this->assertEquals('my-app', $this->parser->getImageName()); - $this->assertEquals('dev', $this->parser->getTag()); - } - - #[Test] - public function it_parses_image_without_tag() - { - $this->parser->parse('ghcr.io/coollabs/coolify'); - - $this->assertEquals('ghcr.io', $this->parser->getRegistryUrl()); - $this->assertEquals('coollabs/coolify', $this->parser->getImageName()); - $this->assertEquals('latest', $this->parser->getTag()); - } - - #[Test] - public function it_converts_back_to_string() - { - $originalString = 'ghcr.io/coollabs/coolify:v4'; - $this->parser->parse($originalString); - - $this->assertEquals($originalString, $this->parser->toString()); - } - - #[Test] - public function it_converts_to_string_with_default_tag() - { - $this->parser->parse('nginx'); - $this->assertEquals('nginx:latest', $this->parser->toString()); - } -} +}); 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'); +}); diff --git a/versions.json b/versions.json index b5cf3360a..2e5cc5e84 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.433" + "version": "4.0.0-beta.435" }, "nightly": { - "version": "4.0.0-beta.434" + "version": "4.0.0-beta.436" }, "helper": { "version": "1.0.11"