diff --git a/.ai/core/application-architecture.md b/.ai/core/application-architecture.md index 64038d139..c1fe7c470 100644 --- a/.ai/core/application-architecture.md +++ b/.ai/core/application-architecture.md @@ -283,14 +283,22 @@ ### **Polymorphic Relationships** ### **Team-Based Soft Scoping** -All major resources include team-based query scoping: +All major resources include team-based query scoping with request-level caching: ```php -// Automatic team filtering -$applications = Application::ownedByCurrentTeam()->get(); -$servers = Server::ownedByCurrentTeam()->get(); +// ✅ CORRECT - Use cached methods (request-level cache via once()) +$applications = Application::ownedByCurrentTeamCached(); +$servers = Server::ownedByCurrentTeamCached(); + +// ✅ CORRECT - Filter cached collection in memory +$activeServers = Server::ownedByCurrentTeamCached()->where('is_active', true); + +// Only use query builder when you need eager loading or fresh data +$projects = Project::ownedByCurrentTeam()->with('environments')->get(); ``` +See [Database Patterns](.ai/patterns/database-patterns.md#request-level-caching-with-ownedbycurrentteamcached) for full documentation. + ### **Configuration Inheritance** Environment variables cascade from: diff --git a/.ai/core/deployment-architecture.md b/.ai/core/deployment-architecture.md index 272f00e4c..927bdc8de 100644 --- a/.ai/core/deployment-architecture.md +++ b/.ai/core/deployment-architecture.md @@ -270,6 +270,84 @@ ### Build Optimization - **Build artifact** reuse - **Parallel build** processing +### Docker Build Cache Preservation + +Coolify provides settings to preserve Docker build cache across deployments, addressing cache invalidation issues. + +#### The Problem + +By default, Coolify injects `ARG` statements into user Dockerfiles for build-time variables. This breaks Docker's cache mechanism because: +1. **ARG declarations invalidate cache** - Any change in ARG values after the `ARG` instruction invalidates all subsequent layers +2. **SOURCE_COMMIT changes every commit** - Causes full rebuilds even when code changes are minimal + +#### Application Settings + +Two toggles in **Advanced Settings** control this behavior: + +| Setting | Default | Description | +|---------|---------|-------------| +| `inject_build_args_to_dockerfile` | `true` | Controls whether Coolify adds `ARG` statements to Dockerfile | +| `include_source_commit_in_build` | `false` | Controls whether `SOURCE_COMMIT` is included in build context | + +**Database columns:** `application_settings.inject_build_args_to_dockerfile`, `application_settings.include_source_commit_in_build` + +#### Buildpack Coverage + +| Build Pack | ARG Injection | Method | +|------------|---------------|--------| +| **Dockerfile** | ✅ Yes | `add_build_env_variables_to_dockerfile()` | +| **Docker Compose** (with `build:`) | ✅ Yes | `modify_dockerfiles_for_compose()` | +| **PR Deployments** (Dockerfile only) | ✅ Yes | `add_build_env_variables_to_dockerfile()` | +| **Nixpacks** | ❌ No | Generates its own Dockerfile internally | +| **Static** | ❌ No | Uses internal Dockerfile | +| **Docker Image** | ❌ No | No build phase | + +#### How It Works + +**When `inject_build_args_to_dockerfile` is enabled (default):** +```dockerfile +# Coolify modifies your Dockerfile to add: +FROM node:20 +ARG MY_VAR=value +ARG COOLIFY_URL=... +ARG SOURCE_COMMIT=abc123 # (if include_source_commit_in_build is true) +# ... rest of your Dockerfile +``` + +**When `inject_build_args_to_dockerfile` is disabled:** +- Coolify does NOT modify the Dockerfile +- `--build-arg` flags are still passed (harmless without matching `ARG` in Dockerfile) +- User must manually add `ARG` statements for any build-time variables they need + +**When `include_source_commit_in_build` is disabled (default):** +- `SOURCE_COMMIT` is NOT included in build-time variables +- `SOURCE_COMMIT` is still available at **runtime** (in container environment) +- Docker cache preserved across different commits + +#### Recommended Configuration + +| Use Case | inject_build_args | include_source_commit | Cache Behavior | +|----------|-------------------|----------------------|----------------| +| Maximum cache preservation | `false` | `false` | Best cache retention | +| Need build-time vars, no commit | `true` | `false` | Cache breaks on var changes | +| Need commit at build-time | `true` | `true` | Cache breaks every commit | +| Manual ARG management | `false` | `true` | Cache preserved (no ARG in Dockerfile) | + +#### Implementation Details + +**Files:** +- `app/Jobs/ApplicationDeploymentJob.php`: + - `set_coolify_variables()` - Conditionally adds SOURCE_COMMIT to Docker build context based on `include_source_commit_in_build` setting + - `generate_coolify_env_variables(bool $forBuildTime)` - Distinguishes build-time vs. runtime variables; excludes cache-busting variables like SOURCE_COMMIT from build context unless explicitly enabled + - `generate_env_variables()` - Populates `$this->env_args` with build-time ARG values, respecting `include_source_commit_in_build` toggle + - `add_build_env_variables_to_dockerfile()` - Injects ARG statements into Dockerfiles after FROM instructions; skips injection if `inject_build_args_to_dockerfile` is disabled + - `modify_dockerfiles_for_compose()` - Applies ARG injection to Docker Compose service Dockerfiles; respects `inject_build_args_to_dockerfile` toggle +- `app/Models/ApplicationSetting.php` - Defines `inject_build_args_to_dockerfile` and `include_source_commit_in_build` boolean properties +- `app/Livewire/Project/Application/Advanced.php` - Livewire component providing UI bindings for cache preservation toggles +- `resources/views/livewire/project/application/advanced.blade.php` - Checkbox UI elements for user-facing toggles + +**Note:** Docker Compose services without a `build:` section (image-only) are automatically skipped. + ### Runtime Optimization - **Container resource** limits - **Auto-scaling** based on metrics @@ -428,7 +506,7 @@ #### `content` - `templates/compose/chaskiq.yaml` - Entrypoint script **Implementation:** -- Parsed: `bootstrap/helpers/parsers.php` (line 717) +- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `content` field extraction) - Storage: `app/Models/LocalFileVolume.php` - Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php` @@ -481,7 +559,7 @@ #### `is_directory` / `isDirectory` - Pre-creating mount points before container starts **Implementation:** -- Parsed: `bootstrap/helpers/parsers.php` (line 718) +- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `is_directory`/`isDirectory` field extraction) - Storage: `app/Models/LocalFileVolume.php` (`is_directory` column) - Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php` diff --git a/.ai/patterns/database-patterns.md b/.ai/patterns/database-patterns.md index 1e40ea152..5a9d16f71 100644 --- a/.ai/patterns/database-patterns.md +++ b/.ai/patterns/database-patterns.md @@ -243,6 +243,59 @@ ### Database Indexes - **Composite indexes** for common queries - **Unique constraints** for business rules +### Request-Level Caching with ownedByCurrentTeamCached() + +Many models have both `ownedByCurrentTeam()` (returns query builder) and `ownedByCurrentTeamCached()` (returns cached collection). **Always prefer the cached version** to avoid duplicate database queries within the same request. + +**Models with cached methods available:** +- `Server`, `PrivateKey`, `Project` +- `Application` +- `StandalonePostgresql`, `StandaloneMysql`, `StandaloneRedis`, `StandaloneMariadb`, `StandaloneMongodb`, `StandaloneKeydb`, `StandaloneDragonfly`, `StandaloneClickhouse` +- `Service`, `ServiceApplication`, `ServiceDatabase` + +**Usage patterns:** +```php +// ✅ CORRECT - Uses request-level cache (via Laravel's once() helper) +$servers = Server::ownedByCurrentTeamCached(); + +// ❌ AVOID - Makes a new database query each time +$servers = Server::ownedByCurrentTeam()->get(); + +// ✅ CORRECT - Filter cached collection in memory +$activeServers = Server::ownedByCurrentTeamCached()->where('is_active', true); +$server = Server::ownedByCurrentTeamCached()->firstWhere('id', $serverId); +$serverIds = Server::ownedByCurrentTeamCached()->pluck('id'); + +// ❌ AVOID - Making filtered database queries when data is already cached +$activeServers = Server::ownedByCurrentTeam()->where('is_active', true)->get(); +``` + +**When to use which:** +- `ownedByCurrentTeamCached()` - **Default choice** for reading team data +- `ownedByCurrentTeam()` - Only when you need to chain query builder methods that can't be done on collections (like `with()` for eager loading), or when you explicitly need a fresh database query + +**Implementation pattern for new models:** +```php +/** + * Get query builder for resources owned by current team. + * If you need all resources without further query chaining, use ownedByCurrentTeamCached() instead. + */ +public static function ownedByCurrentTeam() +{ + return self::whereTeamId(currentTeam()->id); +} + +/** + * Get all resources owned by current team (cached for request duration). + */ +public static function ownedByCurrentTeamCached() +{ + return once(function () { + return self::ownedByCurrentTeam()->get(); + }); +} +``` + ## Data Consistency Patterns ### Database Transactions diff --git a/.github/workflows/coolify-helper-next.yml b/.github/workflows/coolify-helper-next.yml index fec54d54a..2e50abbe7 100644 --- a/.github/workflows/coolify-helper-next.yml +++ b/.github/workflows/coolify-helper-next.yml @@ -44,8 +44,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -86,8 +86,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml index 0c9996ec8..ed6fc3bcb 100644 --- a/.github/workflows/coolify-helper.yml +++ b/.github/workflows/coolify-helper.yml @@ -44,8 +44,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -85,8 +85,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-production-build.yml b/.github/workflows/coolify-production-build.yml index 21871b103..477274751 100644 --- a/.github/workflows/coolify-production-build.yml +++ b/.github/workflows/coolify-production-build.yml @@ -51,8 +51,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -91,8 +91,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-realtime-next.yml b/.github/workflows/coolify-realtime-next.yml index 7ab4dcc42..8937ea27d 100644 --- a/.github/workflows/coolify-realtime-next.yml +++ b/.github/workflows/coolify-realtime-next.yml @@ -48,8 +48,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -90,8 +90,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-realtime.yml b/.github/workflows/coolify-realtime.yml index 5efe445c5..d8784dd50 100644 --- a/.github/workflows/coolify-realtime.yml +++ b/.github/workflows/coolify-realtime.yml @@ -48,8 +48,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -90,8 +90,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index 67b7b03e8..494ef6939 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -64,8 +64,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and Push Image (${{ matrix.arch }}) uses: docker/build-push-action@v6 @@ -110,8 +110,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} run: | diff --git a/.github/workflows/coolify-testing-host.yml b/.github/workflows/coolify-testing-host.yml index 24133887a..0c1371573 100644 --- a/.github/workflows/coolify-testing-host.yml +++ b/.github/workflows/coolify-testing-host.yml @@ -44,8 +44,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and Push Image (${{ matrix.arch }}) uses: docker/build-push-action@v6 @@ -81,8 +81,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 321e9b367..46769f34e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8452 @@ # Changelog ## [unreleased] +### 🐛 Bug Fixes + +- Update syncData method to use data_get for safer property access +- Update version numbers to 4.0.0-beta.441 and 4.0.0-beta.442 +- Enhance menu item styles and update theme color meta tag +- Clean up input attributes for PostgreSQL settings in general.blade.php +- Update docker stop command to use --time instead of --timeout +- Clean up utility classes and improve readability in Blade templates +- Enhance styling for page width component in Blade template +- Remove debugging output from StartPostgresql command handling + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.440] - 2025-11-04 + +### 🐛 Bug Fixes + +- Fix SPA toggle nginx regeneration and add confirmation modal + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.439] - 2025-11-03 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.438] - 2025-10-29 + +### 🚀 Features + +- Display service logos in original colors with consistent sizing +- Add warnings for system-wide GitHub Apps +- Show message when no resources use GitHub App +- Add dynamic viewport-based height for compose editor +- Add funding information for Coollabs including sponsorship plans and channels +- Update Evolution API slogan to better reflect its capabilities +- *(templates)* Update plane compose to v1.0.0 +- Add token validation functionality for Hetzner and DigitalOcean providers +- Add dev_helper_version to instance settings and update related functionality +- Add RestoreDatabase command for PostgreSQL dump restoration +- Update ApplicationSetting model to include additional boolean casts +- Enhance General component with additional properties and validation rules +- Update version numbers to 4.0.0-beta.440 and 4.0.0-beta.441 + +### 🐛 Bug Fixes + +- Handle redis_password in API database creation +- Make modals scrollable on small screens +- Resolve Livewire wire:model binding error in domains input +- Make environment variable forms responsive +- Make proxy logs page responsive +- Improve proxy logs form layout for better responsive behavior +- Prevent horizontal overflow in log text +- Use break-all to force line wrapping in logs +- Ensure deployment failure notifications are sent reliably +- GitHub source creation and configuration issues +- Make system-wide warning reactive in Create view +- Prevent system-wide warning callout from making modal too wide +- Constrain callout width with max-w-2xl and wrap text properly +- Center system-wide warning callout in modal +- Left-align callout on regular view, keep centered in modal +- Allow callout to take full width in regular view +- Change app_id and installation_id to integer values in createGithubAppManually method +- Use x-cloak instead of inline style to prevent FOUC +- Clarify warning message for allowed IPs configuration +- Server URL generation in ServerPatchCheck notification +- Monaco editor empty for docker compose applications +- Update sponsor link from Darweb to Dade2 in README +- *(database)* Prevent malformed URLs when server IP is empty +- Optimize caching in Dockerfile and GitHub Actions workflow +- Remove wire:ignore from modal and add wire:key to EditCompose component +- Add wire:ignore directive to modal component for improved functionality +- Clean up formatting and remove unnecessary key binding in stack form component +- Add null checks and validation to OAuth bulk update method +- *(docs)* Update documentation URL to version 2 in evolution-api.yaml +- *(templates)* Remove volumes from Plane's compose +- *(templates)* Add redis env to live service in Plane +- *(templates)* Update minio image to use coollabsio fork in Plane +- Prevent login rate limit bypass via spoofed headers +- Correct login rate limiter key format to include IP address +- Change SMTP port input type to number for better validation +- Remove unnecessary step attribute from maximum storage input fields +- Update boarding flow logic to complete onboarding when server is created +- Convert network aliases to string for display +- Improve custom_network_aliases handling and testing +- Remove duplicate custom_labels from config hash calculation +- Improve run script and enhance sticky header style + +### 💼 Other + +- *(deps-dev)* Bump vite from 6.3.6 to 6.4.1 + +### 🚜 Refactor + +- Remove deprecated next() method +- Replace allowed IPs validation logic with regex +- Remove redundant +- Streamline allowed IPs validation and enhance UI warnings for API access +- Remove staging URL logic from ServerPatchCheck constructor +- Streamline Docker build process with matrix strategy for multi-architecture support +- Simplify project data retrieval and enhance OAuth settings handling +- Improve handling of custom network aliases +- Remove unused submodules +- Update subproject commit hashes +- Remove SynchronizesModelData trait and implement syncData method for model synchronization + +### 📚 Documentation + +- Update changelog +- Update changelog +- Update changelog +- Update changelog +- Update changelog +- Update changelog +- Update changelog +- Add service & database deployment logging plan + +### 🧪 Testing + +- Add unit tests for ServerPatchCheck notification URL generation +- Fix ServerPatchCheckNotification tests to avoid global state pollution + +### ⚙️ Miscellaneous Tasks + +- Add category field to siyuan.yaml +- Update siyuan category in service templates +- Add spacing and format callout text in modal +- Update version numbers to 4.0.0-beta.439 and 4.0.0-beta.440 +- Add .workspaces to .gitignore + +## [4.0.0-beta.437] - 2025-10-21 + +### 🚀 Features + +- *(templates)* Add sparkyfitness compose template and logo +- *(servide)* Add siyuan template +- Add onboarding guide link to global search no results state +- Add category filter dropdown to service selection + +### 🐛 Bug Fixes + +- *(service)* Update image version & healthcheck start period +- Filter deprecated server types for Hetzner +- Eliminate dark mode white screen flicker on page transitions + +### 💼 Other + +- Preserve clean docker_compose_raw without Coolify additions + +### 📚 Documentation + +- Update changelog +- Update changelog + +## [4.0.0-beta.435] - 2025-10-15 + +### 🚀 Features + +- *(docker)* Enhance Docker image handling with new validation and parsing logic +- *(docker)* Improve Docker image submission logic with enhanced parsing +- *(docker)* Refine Docker image processing in application creation +- Add Ente Photos service template +- *(storage)* Add read-only volume handling and UI notifications +- *(service)* Add Elasticsearch password handling in extraFields method +- *(application)* Add default NIXPACKS_NODE_VERSION environment variable for Nixpacks applications +- *(proxy)* Enhance proxy configuration regeneration by extracting custom commands +- *(backup)* Enhance backup job with S3 upload handling and notifications +- *(storage)* Implement transaction handling in storage settings submission +- *(project)* Enhance project index with resource creation capabilities +- *(dashboard)* Enhance project and server sections with modal input for resource creation +- *(global-search)* Enhance resource creation functionality in search modal +- *(global-search)* Add navigation routes and enhance search functionality +- *(conductor)* Add setup script and configuration file +- *(conductor)* Add run script and update runScriptMode configuration +- *(docker-compose)* Add image specifications for coolify, soketi, and testing-host services +- *(cleanup)* Add force deletion of stuck servers and orphaned SSL certificates +- *(deployment)* Save build-time .env file before build and enhance logging for Dockerfile +- Implement Hetzner deletion failure notification system with email and messaging support +- Enhance proxy status notifications with detailed messages for various states +- Add retry functionality for server validation process +- Add retry mechanism with rate limit handling to API requests in HetznerService +- Implement ValidHostname validation rule and integrate it into server creation process +- Add support for selecting additional SSH keys from Hetzner in server creation form +- Enhance datalist component with unified input container and improved option handling +- Add modal support for creating private keys in server creation form and enhance UI for private key selection +- Add IPv4/IPv6 network configuration for Hetzner server creation +- Add pricing display to Hetzner server creation button +- Add cloud-init script support for Hetzner server creation +- Add cloud-init scripts management UI in Security section +- Add cloud-init scripts to global search +- Add artisan command to clear global search cache +- Add YAML validation for cloud-init scripts +- Add clear button for cloud-init script dropdown +- Add custom webhook notification support +- Add webhook placeholder to Test notification +- Add WebhookChannel placeholder implementation +- Implement actual webhook delivery +- Implement actual webhook delivery with Ray debugging +- Improve webhook URL field UI +- Add UUIDs and URLs to webhook notifications +- *(onboarding)* Redesign user onboarding flow with modern UI/UX +- Replace terminal dropdown with searchable datalist component +- *(onboarding)* Add Hetzner integration and fix navigation issues +- Use new homarr image +- *(templates)* Actually use the new image now +- *(templates)* Pin homarr image version to v1.40.0 +- *(template)* Added newapi +- Add mail environment variables to docmost.yaml +- Add Email Envs, Install more required packages by pdsadmin +- Make an empty pds.env file to trick pdsadmin into working correctly +- Not many know how to setup this without reading pds docs +- Make the other email env also required +- *(templates)* Added Lobe Chat service +- *(service)* Add Gramps Web template +- *(campfire)* Add Docker Compose configuration for Once Campfire service +- Add Hetzner affiliate link to token form +- Update Hetzner affiliate link text and URL +- Add CPU vendor information to server types in Hetzner integration +- Implement TrustHosts middleware to handle FQDN and IP address trust logic +- Implement TrustHosts middleware to handle FQDN and IP address trust logic +- Allow safe environment variable defaults in array-format volumes +- Add signoz template +- *(signoz)* Replace png icon by svg icon +- *(signoz)* Remove explicit 'networks' setting +- *(signoz)* Add predefined environment variables to configure Telemetry, SMTP and email sending for Alert Manager +- *(signoz)* Generate URLs for `otel-collector` service +- *(signoz)* Update documentation link +- *(signoz)* Add healthcheck to otel-collector service +- *(signoz)* Use latest tag instead of hardcoded versions +- *(signoz)* Remove redundant users.xml volume from clickhouse container +- *(signoz)* Replace clickhouse' config.xml volume with simpler configuration +- *(signoz)* Remove deprecated parameters of signoz container +- *(signoz)* Remove volumes from signoz.yaml +- *(signoz)* Assume there is a single zookeeper container +- *(signoz)* Update Clickhouse config to include all settings required by Signoz +- *(signoz)* Update config.xml and users.xml to ensure clickhouse boots correctly +- *(signoz)* Update otel-collector configuration to match upstream +- *(signoz)* Fix otel-collector config for version v0.128.0 +- *(signoz)* Remove unecessary port mapping for otel-collector +- *(signoz)* Add SIGNOZ_JWT_SECRET env var generation +- *(signoz)* Upgrade clickhouse image to 25.5.6 +- *(signoz)* Use latest tag for signoz/zookeeper +- *(signoz)* Update variables for SMTP configuration +- *(signoz)* Replace deprecated `TELEMETRY_ENABLED` by `SIGNOZ_STATSREPORTER_ENABLED` +- *(signoz)* Pin service image tags and `exclude_from_hc` flag to services excluded from health checks +- *(templates)* Add SMTP configuration to ente-photos compose templates +- *(templates)* Add SMTP encryption configuration to ente-photos compose templates + +### 🐛 Bug Fixes + +- Region env variable +- Ente photos +- *(elasticsearch)* Update Elasticsearch and Kibana configuration for enhanced security and setup +- *(ui)* Make the deployments indicator toast in the bottom-left above the sidebar +- *(environment)* Clear computed property cache after adding environment variables +- *(backup)* Update backup job to use backup_log_uuid for container naming +- *(core)* Set default base_directory and include in submit method +- *(deployment)* Add warning for NIXPACKS_NODE_VERSION in node configurations +- *(deployment)* Save runtime environment variables when skipping build +- *(job)* Correct build logs URL structure in ApplicationPullRequestUpdateJob +- *(tests)* Update Docker command for running feature tests without `-it` flag +- On team creation, redirect to the new team instantly +- *(project)* Update redirect logic after resource creation to include environment UUID +- *(dashboard)* Add cursor pointer to modal input buttons for better UX +- *(modal-confirmation)* Refine escape key handling to ensure modal closes only when open +- *(conductor-setup)* Update script permissions for execution +- *(conductor)* Update run script command to 'spin up' +- *(conductor)* Update run script to include 'spin down' command +- *(docker-compose)* Set pull_policy to 'never' for coolify, soketi, and testing-host services +- *(migration)* Disable transaction for concurrent index creation +- Properly handle transaction for concurrent index operations +- Use correct property declaration for withinTransaction +- *(api-tokens)* Update settings link for API enablement message +- *(css)* Update success color to match design specifications +- *(css)* Update focus styles for input and button utilities to improve accessibility +- *(css)* Remove unnecessary tracking classes from status components for consistency +- *(css)* Update focus styles for Checkbox and modal input components to enhance accessibility +- Refresh server data before showing notification to ensure accurate proxy status +- Update Hetzner server status handling to prevent unnecessary database updates and improve UI responsiveness +- Improve error logging and handling in ServerConnectionCheckJob for Hetzner server status +- Correct dispatch logic for Hetzner server status refresh in checkHetznerServerStatus method +- Streamline proxy status handling in StartProxy and Navbar components +- Improve placeholder text for token name input in cloud provider token form +- Update cloud provider token form with improved placeholder and guidance for API token creation +- *(ci)* Sanitize branch names for Docker tag compatibility +- Set cloud-init script dropdown to empty by default +- Reset cloud-init fields when closing server creation modal +- Improve cloud-init scripts UI styling and behavior +- Allow typing in global search while data loads +- Hide 'No results found' message while data is loading +- Populate webhook notification settings for existing teams +- Register WebhookNotificationSettings with NotificationPolicy +- Add missing server_patch_webhook_notifications field +- Move POST badge before input field +- Use btn-primary for POST badge background +- *(onboarding)* Auto-select first SSH key for better UX +- Prevent container name conflict when updating database port mappings +- Missing 422 error code in openapi spec +- Allow all environment variable fields in API endpoints +- Fixed version +- Fix documentation url +- Bluesky PDS template +- Bluesky PDS template finally works normally +- Add back template info +- Now it automatically generates the JWT secret and the PLC rotation key +- Syntax error on vars +- Remove the SERVICE_EMAIL_ADMIN and make it normal +- Both email envs are needed in order for the PDS to start, so set the other one as required +- Add back template info +- Healthcheck doesn’t need to be 5s +- Make email envs not required +- Domain on coolify +- *(templates)* Update Lobe-chat openai base_url env + required envs +- *(templates)* Lobechat environnement variable +- *(lobe-chat)* Update Docker image tag to a specific version 1.135.5 +- Enable docker network connection for pgadmin service +- *(template/filebrowser)* Correct routing and healthcheck for Filebrowser +- *(template/filebrowser)* Correct healthcheck for Filebrowser +- *(campfire)* Update port configuration from 80 to 3000 in Docker Compose file +- *(campfire)* Correct port comment from 3000 to 80 in Docker Compose file +- *(campfire)* Update service definition to use image instead of build in Docker Compose file +- *(templates)* Remove mattermost healthcheck command according to lack of shell in new version +- Prevent duplicate services on image change and enable real-time UI refresh +- Enhance run script to remove existing containers before starting +- Prevent TypeError in database General components with null server +- Add authorization checks to database Livewire components +- Add missing save_runtime_environment_variables() in deploy_simple_dockerfile +- *(git)* Handle Git redirects and improve URL parsing for tangled.sh and other Git hosts +- Improve logging and add shell escaping for git ls-remote +- Update run script to use bun for development +- Restore original run script functionality in conductor.json +- Use computed imageTag variable for digest-based Docker images +- Improve Docker image digest handling and add auto-parse feature +- 'new image' quick action not progressing to resource selection +- Use wasChanged() instead of isDirty() in updated hooks +- Prevent command injection in git ls-remote operations +- Handle null environment variable values in bash escaping +- Critical privilege escalation in team invitation system +- Add authentication context to TeamPolicyTest +- Ensure negative cache results are stored in TrustHosts middleware +- Use wasChanged() instead of isDirty() in updated hook +- Prevent command injection in Docker Compose parsing - add pre-save validation +- Use canonical parser for Windows path validation +- Correct variable name typo in generateGitLsRemoteCommands method +- Update version numbers to 4.0.0-beta.436 and 4.0.0-beta.437 +- Ensure authorization checks are in place for viewing and updating the application +- Ensure authorization check is performed during component mount +- *(signoz)* Remove example secrets to avoid triggering GitGuardian +- *(signoz)* Remove hardcoded container names +- *(signoz)* Remove HTTP collector FQDN in otel-collector +- *(n8n)* Add DB_SQLITE_POOL_SIZE environment variable for configuration +- *(template)* Remove default values for environment variables +- Update metamcp image version and clean up environment variable syntax + +### 💼 Other + +- Ente config +- Cofig variables +- Lean Config +- Env +- Services & Env variables +- Product hunt Ente Logo +- Remove volumes +- Add ray logging for Hetzner createServer API request/response +- Escape all shell directory paths in Git deployment commands +- Remove content from docker_compose_raw to prevent file overwrites +- *(templates)* Metamcp app + +### 🚜 Refactor + +- *(environment-variables)* Adjust ordering logic for environment variables +- Update ente photos configuration for improved service management +- *(deployment)* Streamline environment variable generation in ApplicationDeploymentJob +- *(deployment)* Enhance deployment data retrieval and relationships +- *(deployment)* Standardize environment variable handling in ApplicationDeploymentJob +- *(deployment)* Update environment variable handling for Docker builds +- *(navbar, app)* Improve layout and styling for better responsiveness +- *(switch-team)* Remove label from team selection component for cleaner UI +- *(global-search, environment)* Streamline environment retrieval with new query method +- *(backup)* Make backup_log_uuid initialization lazy +- *(checkbox, utilities, global-search)* Enhance focus styles for better accessibility +- *(forms)* Simplify wire:dirty class bindings for input, select, and textarea components +- Replace direct SslCertificate queries with server relationship methods for consistency +- *(ui)* Improve cloud-init script save checkbox visibility and styling +- Enable cloud-init save checkbox at all times with backend validation +- Improve cloud-init script UX and remove description field +- Improve cloud-init script management UI and cache control +- Remove debug sleep from global search modal +- Reduce cloud-init label width for better layout +- Remove SendsWebhook interface +- Reposition POST badge as button +- Migrate database components from legacy model binding to explicit properties +- Volumes set back to ./pds-data:/pds +- *(campfire)* Streamline environment variable definitions in Docker Compose file +- Improve validation error handling and coding standards +- Preserve exception chain in validation error handling +- Harden and deduplicate validateShellSafePath +- Replace random ID generation with Cuid2 for unique HTML IDs in form components + +### 📚 Documentation + +- Update changelog +- Update changelog +- Update changelog +- Update changelog +- Update changelog +- *(tests)* Update testing guidelines for unit and feature tests +- *(sync)* Create AI Instructions Synchronization Guide and update CLAUDE.md references +- *(database-patterns)* Add critical note on mass assignment protection for new columns +- Clarify cloud-init script compatibility +- Update changelog +- Update changelog + +### 🎨 Styling + +- *(campfire)* Format environment variables for better readability in Docker Compose file +- *(campfire)* Update comment for DISABLE_SSL environment variable for clarity + +### 🧪 Testing + +- Improve Git ls-remote parsing tests with uppercase SHA and negative cases +- Add coverage for newline and tab rejection in volume strings + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update Coolify version numbers to 4.0.0-beta.435 and 4.0.0-beta.436 +- Update package-lock.json +- *(service)* Update convex template and image +- *(signoz)* Remove unused ports +- *(signoz)* Bump version to 0.77.0 +- *(signoz)* Bump version to 0.78.1 + +## [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 + +- *(application)* Implement order-based pattern matching for watch paths with negation support +- *(github)* Enhance Docker Compose input fields for better user experience +- *(dev-seeders)* Add PersonalAccessTokenSeeder to create development API tokens +- *(application)* Add conditional .env file creation for Symfony apps during PHP deployment +- *(application)* Enhance watch path parsing to support negation syntax +- *(application)* Add normalizeWatchPaths method to improve watch path handling +- *(validation)* Enhance ValidGitRepositoryUrl to support additional safe characters and add comprehensive unit tests for various Git repository URL formats +- *(deployment)* Implement detection for Laravel/Symfony frameworks and configure NIXPACKS PHP environment variables accordingly + +### 🐛 Bug Fixes + +- *(application)* Restrict GitHub-based application settings to non-public repositories +- *(traits)* Update saved_outputs handling in ExecuteRemoteCommand to use collection methods for better performance +- *(application)* Enhance domain handling by replacing both dots and dashes with underscores for HTML form binding +- *(constants)* Reduce command timeout from 7200 to 3600 seconds for improved performance +- *(github)* Update repository URL to point to the v4.x branch for development +- *(models)* Update sorting of scheduled database backups to order by creation date instead of name +- *(socialite)* Add custom base URL support for GitLab provider in OAuth settings +- *(configuration-checker)* Update message to clarify redeployment requirement for configuration changes +- *(application)* Reduce docker stop timeout from 30 to 10 seconds for improved application shutdown efficiency +- *(application)* Increase docker stop timeout from 10 to 30 seconds for better application shutdown handling +- *(validation)* Update git:// URL validation to support port numbers and tilde characters in paths +- Resolve scroll lock issue after closing quick search modal with escape key +- Prevent quick search modal duplication from keyboard shortcuts + +### 🚜 Refactor + +- *(tests)* Simplify matchWatchPaths tests and update implementation for better clarity +- *(deployment)* Improve environment variable handling in ApplicationDeploymentJob +- *(deployment)* Remove commented-out code and streamline environment variable handling in ApplicationDeploymentJob +- *(application)* Improve handling of docker compose domains by normalizing keys and ensuring valid JSON structure +- *(forms)* Update wire:model bindings to use 'blur' instead of 'blur-sm' for input fields across multiple views + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(application)* Remove debugging statement from loadComposeFile method +- *(workflows)* Update Claude GitHub Action configuration to support new event types and improve permissions + +## [4.0.0-beta.431] - 2025-09-24 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.430] - 2025-09-24 + +### 🚀 Features + +- *(add-watch-paths-for-services)* Show watch paths field for docker compose applications + +### 🐛 Bug Fixes + +- *(PreviewCompose)* Adds port to preview urls +- *(deployment-job)* Enhance build time variable analysis +- *(docker)* Adjust openssh-client installation in Dockerfile to avoid version bug +- *(docker)* Streamline openssh-client installation in Dockerfile +- *(team)* Normalize email case in invite link generation +- *(README)* Update Juxtdigital description to reflect current services +- *(environment-variable-warning)* Enhance warning logic to check for problematic variable values +- *(install)* Ensure proper quoting of environment file paths to prevent issues with spaces +- *(security)* Implement authorization checks for terminal access management +- *(ui)* Improve mobile sidebar close behavior + +### 🚜 Refactor + +- *(installer)* Improve install script +- *(upgrade)* Improve upgrade script +- *(installer, upgrade)* Enhance environment variable management +- *(upgrade)* Enhance logging and quoting in upgrade scripts +- *(upgrade)* Replace warning div with a callout component for better UI consistency +- *(ui)* Replace warning and error divs with callout components for improved consistency and readability +- *(ui)* Improve styling and consistency in environment variable warning and docker cleanup components +- *(security)* Streamline update check functionality and improve UI button interactions in patches view + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Increment coolify version numbers to 4.0.0-beta.431 and 4.0.0-beta.432 in configuration files +- *(versions)* Update coolify version numbers to 4.0.0-beta.432 and 4.0.0-beta.433 in configuration files +- Remove unused files +- Adjust wording +- *(workflow)* Update pull request trigger to pull_request_target and refine permissions for enhanced security + +## [4.0.0-beta.429] - 2025-09-23 + +### 🚀 Features + +- *(environment)* Replace is_buildtime_only with is_runtime and is_buildtime flags for environment variables, updating related logic and views +- *(deployment)* Handle buildtime and runtime variables during deployment +- *(search)* Implement global search functionality with caching and modal interface +- *(search)* Enable query logging for global search caching +- *(environment)* Add dynamic checkbox options for environment variable settings based on user permissions and variable types +- *(redaction)* Implement sensitive information redaction in logs and commands +- Improve detection of special network modes +- *(api)* Add endpoint to update backup configuration by UUID and backup ID; modify response to include backup id +- *(databases)* Enhance backup management API with new endpoints and improved data handling +- *(github)* Add GitHub app management endpoints +- *(github)* Add update and delete endpoints for GitHub apps +- *(databases)* Enhance backup update and deletion logic with validation +- *(environment-variables)* Implement environment variable analysis for build-time issues +- *(databases)* Implement unique UUID generation for backup execution +- *(cloud-check)* Enhance subscription reporting in CloudCheckSubscription command +- *(cloud-check)* Enhance CloudCheckSubscription command with fix options +- *(stripe)* Enhance subscription handling and verification process +- *(private-key-refresh)* Add refresh dispatch on private key update and connection check +- *(comments)* Add automated comments for labeled pull requests to guide documentation updates +- *(comments)* Ping PR author + +### 🐛 Bug Fixes + +- *(docker)* Enhance container status aggregation to include restarting and exited states +- *(environment)* Correct grammatical errors in helper text for environment variable sorting checkbox +- *(ui)* Change order and fix ui on small screens +- Order for git deploy types +- *(deployment)* Enhance Dockerfile modification for build-time variables and secrets during deployment in case of docker compose buildpack +- Hide sensitive email change fields in team member responses +- *(domains)* Trim whitespace from domains before validation +- *(databases)* Update backup retrieval logic to include team context +- *(environment-variables)* Update affected services in environment variable analysis +- *(team)* Clear stripe_subscription_id on subscription end +- *(github)* Update authentication method for GitHub app operations +- *(databases)* Restrict database updates to allowed fields only +- *(cache)* Add Model import to ClearsGlobalSearchCache trait for improved functionality +- *(environment-variables)* Correct method call syntax in analyzeBuildVariable function +- *(clears-global-search-cache)* Refine team retrieval logic in getTeamIdForCache method +- *(subscription-job)* Enhance retry logic for VerifyStripeSubscriptionStatusJob +- *(environment-variable)* Update checkbox visibility and helper text for build and runtime options +- *(deployment-job)* Escape single quotes in build arguments for Docker Compose command + +### 🚜 Refactor + +- *(environment)* Conditionally render Docker Build Secrets checkbox based on build pack type +- *(search)* Optimize cache clearing logic to only trigger on searchable field changes +- *(environment)* Streamline rendering of Docker Build Secrets checkbox and adjust layout for environment variable settings +- *(proxy)* Streamline proxy configuration form layout and improve button placements +- *(remoteProcess)* Remove redundant file transfer functions for improved clarity +- *(github)* Enhance API request handling and validation +- *(databases)* Remove deprecated backup parameters from API documentation +- *(databases)* Streamline backup queries to use team context +- *(databases)* Update backup queries to use team-specific method +- *(server)* Update dispatch messages and streamline data synchronization +- *(cache)* Update team retrieval method in ClearsGlobalSearchCache trait +- *(database-backup)* Move unique UUID generation for backup execution to database loop +- *(cloud-commands)* Consolidate and enhance subscription management commands +- *(toast-component)* Improve layout and icon handling in toast notifications +- *(private-key-update)* Implement transaction for private key association and connection validation + +### 📚 Documentation + +- Update changelog +- Update changelog +- *(claude)* Update testing guidelines and add note on Application::team relationship + +### 🎨 Styling + +- *(environment-variable)* Adjust SVG icon margin for improved layout in locked state +- *(proxy)* Adjust padding in proxy configuration form for better visual alignment + +### ⚙️ Miscellaneous Tasks + +- Change order of runtime and buildtime +- *(docker-compose)* Update soketi image version to 1.0.10 in production and Windows configurations +- *(versions)* Update coolify version numbers to 4.0.0-beta.430 and 4.0.0-beta.431 in configuration files + +## [4.0.0-beta.428] - 2025-09-15 + +### 📚 Documentation + +- Update changelog + +## [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 +- *(command)* Implement SSH command retry logic with exponential backoff and logging for better error handling +- *(ssh)* Add Sentry tracking for SSH retry events to enhance error monitoring +- *(exceptions)* Introduce NonReportableException to handle known errors and update Handler for selective reporting +- *(sudo-helper)* Add helper functions for command parsing and ownership management with sudo +- *(dev-command)* Dispatch CheckHelperImageJob during instance initialization to enhance setup process +- *(ssh-multiplexing)* Enhance multiplexed connection management with health checks and metadata caching +- *(ssh-multiplexing)* Add connection age metadata handling to improve multiplexed connection management +- *(database-backup)* Enhance error handling and output management in DatabaseBackupJob +- *(application)* Display parsing version in development mode and clean up domain conflict modal markup +- *(deployment)* Add SERVICE_NAME variables for service discovery +- *(storages)* Add method to retrieve the first storage ID for improved stability in storage display +- *(environment)* Add 'is_literal' attribute to environment variable for enhanced configuration options +- *(pre-commit)* Automate generation of service templates and OpenAPI documentation during pre-commit hook +- *(execute-container)* Enhance container command form with auto-connect feature for single container scenarios +- *(environment)* Introduce 'is_buildtime_only' attribute to environment variables for improved build-time configuration +- *(templates)* Add n8n service with PostgreSQL and worker support for enhanced workflow automation +- *(user-management)* Implement user deletion command with phased resource and subscription cancellation, including dry run option +- *(sentinel)* Add support for custom Docker images in StartSentinel and related methods +- *(sentinel)* Add slide-over for viewing Sentinel logs and custom Docker image input for development +- *(executions)* Add 'Load All' button to view all logs and implement loadAllLogs method for complete log retrieval +- *(auth)* Enhance user login flow to handle team invitations, attaching users to invited teams upon first login and maintaining personal team logic for regular logins +- *(laravel-boost)* Add Laravel Boost guidelines and MCP server configuration to enhance development experience +- *(deployment)* Enhance deployment status reporting with detailed information on active deployments and team members +- *(deployment)* Implement cancellation checks during deployment process to enhance user control and prevent unnecessary execution +- *(deployment)* Introduce 'use_build_secrets' setting for enhanced security during Docker builds and update related logic in deployment process + +### 🐛 Bug Fixes + +- *(ui)* Transactional email settings link on members page (#6491) +- *(api)* Add custom labels generation for applications with readonly container label setting enabled +- *(ui)* Add cursor pointer to upgrade button for better user interaction +- *(templates)* Update SECRET_KEY environment variable in getoutline.yaml to use SERVICE_HEX_32_OUTLINE +- *(command)* Enhance database deletion command to support multiple database types +- *(command)* Enhance cleanup process for stuck application previews by adding force delete for trashed records +- *(user)* Ensure email attributes are stored in lowercase for consistency and prevent case-related issues +- *(webhook)* Replace delete with forceDelete for application previews to ensure immediate removal +- *(ssh)* Introduce SshRetryHandler and SshRetryable trait for enhanced SSH command retry logic with exponential backoff and error handling +- Appwrite template - 500 errors, missing env vars etc. +- *(LocalFileVolume)* Add missing directory creation command for workdir in saveStorageOnServer method +- *(ScheduledTaskJob)* Replace generic Exception with NonReportableException for better error handling +- *(web-routes)* Enhance backup response messages to clarify local and S3 availability +- *(proxy)* Replace CheckConfiguration with GetProxyConfiguration and SaveConfiguration with SaveProxyConfiguration for improved clarity and consistency in proxy management +- *(private-key)* Implement transaction handling and error verification for private key storage operations +- *(deployment)* Add COOLIFY_* environment variables to Nixpacks build context for enhanced deployment configuration +- *(application)* Add functionality to stop and remove Docker containers on server +- *(templates)* Update 'compose' configuration for Appwrite service to enhance compatibility and streamline deployment +- *(security)* Update contact email for reporting vulnerabilities to enhance privacy +- *(feedback)* Update feedback email address to improve communication with users +- *(security)* Update contact email for vulnerability reports to improve security communication +- *(navbar)* Restrict subscription link visibility to admin users in cloud environment +- *(docker)* Enhance container status aggregation for multi-container applications, including exclusion handling based on docker-compose configuration +- *(application)* Improve watch paths handling by trimming and filtering empty paths to prevent unnecessary triggers +- *(server)* Update server usability check to reflect actual Docker availability status +- *(server)* Add build server check to disable Sentinel and update related logic +- *(server)* Implement refreshServer method and update navbar event listener for improved server state management +- *(deployment)* Prevent removal of running containers for pull request deployments in case of failure +- *(docker)* Redirect stderr to stdout for container log retrieval to capture error messages +- *(clone)* Update destinations method call to ensure correct retrieval of selected destination + +### 🚜 Refactor + +- *(jobs)* Pull github changelogs from cdn instead of github +- *(command)* Streamline database deletion process to handle multiple database types and improve user experience +- *(command)* Improve database collection logic for deletion command by using unique identifiers and enhancing user experience +- *(command)* Remove InitChangelog command as it is no longer needed +- *(command)* Streamline Init command by removing unnecessary options and enhancing error handling for various operations +- *(webhook)* Replace direct forceDelete calls with DeleteResourceJob dispatch for application previews +- *(command)* Replace forceDelete calls with DeleteResourceJob dispatch for all stuck resources in cleanup process +- *(command)* Simplify SSH command retry logic by removing unnecessary logging and improving delay calculation +- *(ssh)* Enhance error handling in SSH command execution and improve connection validation logging +- *(backlog)* Remove outdated guidelines and project manager agent files to streamline task management documentation +- *(error-handling)* Remove ray debugging statements from CheckUpdates and shared helper functions to clean up error reporting +- *(file-transfer)* Replace base64 encoding with direct file transfer method across multiple database actions for improved clarity and efficiency +- *(remoteProcess)* Remove debugging statement from transfer_file_to_server function to clean up code +- *(dns-validation)* Rename DNS validation functions for consistency and clarity, and remove unused code +- *(file-transfer)* Replace base64 encoding with direct file transfer method in various components for improved clarity and efficiency +- *(private-key)* Remove debugging statement from storeInFileSystem method for cleaner code +- *(github-webhook)* Restructure application processing by grouping applications by server for improved deployment handling +- *(deployment)* Enhance queuing logic to support concurrent deployments by including pull request ID in checks +- *(remoteProcess)* Remove debugging statement from transfer_file_to_container function for cleaner code +- *(deployment)* Streamline next deployment queuing logic by repositioning queue_next_deployment call +- *(deployment)* Add validation for pull request existence in deployment process to enhance error handling +- *(database)* Remove volume_configuration_dir and streamline configuration directory usage in MongoDB and PostgreSQL handlers +- *(application-source)* Improve layout and accessibility of Git repository links in the application source view +- *(models)* Remove 'is_readonly' attribute from multiple database models for consistency +- *(webhook)* Remove Webhook model and related logic; add migrations to drop webhooks and kubernetes tables +- *(clone)* Consolidate application cloning logic into a dedicated function for improved maintainability and readability +- *(clone)* Integrate preview cloning logic directly into application cloning function for improved clarity and maintainability +- *(application)* Enhance environment variable retrieval in configuration change check for improved accuracy +- *(clone)* Enhance application cloning by separating production and preview environment variable handling +- *(deployment)* Add environment variable copying logic to Docker build commands for pull requests +- *(environment)* Standardize service name formatting by replacing '-' and '.' with '_' in environment variable keys +- *(deployment)* Update environment file handling in Docker commands to use '/artifacts/' path and streamline variable management +- *(openapi)* Remove 'is_build_time' attribute from environment variable definitions to streamline configuration +- *(environment)* Remove 'is_build_time' attribute from environment variable handling across the application to simplify configuration +- *(environment)* Streamline environment variable handling by replacing sorting methods with direct property access and enhancing query ordering for improved performance +- *(stripe-jobs)* Comment out internal notification calls and add subscription status verification before sending failure notifications +- *(deployment)* Streamline environment variable handling for dockercompose and improve sorting of runtime variables +- *(remoteProcess)* Remove command log comments for file transfers to simplify code +- *(remoteProcess)* Remove file transfer handling from remote_process and instant_remote_process functions to simplify code +- *(deployment)* Update environment file paths in docker compose commands to use working directory for improved consistency +- *(server)* Remove debugging ray call from validateConnection method for cleaner code +- *(deployment)* Conditionally cleanup build secrets based on Docker BuildKit support and remove redundant calls for improved efficiency +- *(deployment)* Remove redundant environment variable documentation from Dockerfile comments to streamline the deployment process +- *(deployment)* Streamline Docker BuildKit detection and environment variable handling for enhanced security during application deployment +- *(deployment)* Optimize BuildKit capabilities detection and remove unnecessary comments for cleaner deployment logic +- *(deployment)* Rename method for modifying Dockerfile to improve clarity and streamline build secrets integration + +### 📚 Documentation + +- Update changelog +- *(testing-patterns)* Add important note to always run tests inside the `coolify` container for clarity + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.427 and nightly version to 4.0.0-beta.428 +- Use main value then fallback to service_ values +- Remove webhooks table cleanup +- *(cleanup)* Remove deprecated ServerCheck and related job classes to streamline codebase +- *(versions)* Update sentinel version from 0.0.15 to 0.0.16 in versions.json files +- *(constants)* Update realtime_version from 1.0.10 to 1.0.11 +- *(versions)* Increment coolify version to 4.0.0-beta.428 and update realtime_version to 1.0.10 +- *(docker)* Add a blank line for improved readability in Dockerfile +- *(versions)* Bump coolify version to 4.0.0-beta.429 and nightly version to 4.0.0-beta.430 + +## [4.0.0-beta.426] - 2025-08-28 + +### 🚜 Refactor + +- *(policy)* Simplify ServiceDatabasePolicy methods to always return true and add manageBackups method + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.426 and nightly version to 4.0.0-beta.427 + +## [4.0.0-beta.425] - 2025-08-28 + +### 🚀 Features + +- *(domains)* Implement domain conflict detection and user confirmation modal across application components +- *(domains)* Add force_domain_override option and enhance domain conflict detection responses + +### 🐛 Bug Fixes + +- *(previews)* Simplify FQDN generation logic by removing unnecessary empty check +- *(templates)* Update Matrix service compose configuration for improved compatibility and clarity + +### 🚜 Refactor + +- *(urls)* Replace generateFqdn with generateUrl for consistent URL generation across applications +- *(domains)* Rename check_domain_usage to checkDomainUsage and update references across the application +- *(auth)* Simplify access control logic in CanAccessTerminal and ServerPolicy by allowing all users to perform actions + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.425 and nightly version to 4.0.0-beta.426 + +## [4.0.0-beta.424] - 2025-08-27 + +### 💼 Other + +- Allow deploy from container image hash + +### 📚 Documentation + +- Update changelog +- Update changelog + +## [4.0.0-beta.423] - 2025-08-27 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.422] - 2025-08-27 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.421] - 2025-08-26 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.420.9] - 2025-08-26 + +### 🚀 Features + +- *(policies)* Add EnvironmentVariablePolicy for managing environment variables ( it was missing ) + +### 🐛 Bug Fixes + +- *(backups)* S3 backup upload is failing +- *(backups)* Rollback helper update for now +- *(parsers)* Replace hyphens with underscores in service names for consistency. this allows to properly parse custom domains in docker compose based applications +- *(parsers)* Implement parseDockerVolumeString function to handle various Docker volume formats and modes, including environment variables and Windows paths. Add unit tests for comprehensive coverage. +- *(git)* Submodule update command uses an unsupported option (#6454) +- *(service)* Swap URL for FQDN on matrix template (#6466) +- *(parsers)* Enhance volume string handling by preserving mode in application and service parsers. Update related unit tests for validation. +- *(docker)* Update parser version in FQDN generation for service-specific URLs +- *(parsers)* Do not modify service names, only for getting fqdns and related envs +- *(compose)* Temporary allow to edit volumes in apps (compose based) and services + +### 🚜 Refactor + +- *(git)* Improve submodule cloning +- *(parsers)* Remove unnecessary hyphen-to-underscore replacement for service names in serviceParser function + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(core)* Update version +- *(core)* Update version +- *(versions)* Update coolify version to 4.0.0-beta.421 and nightly version to 4.0.0-beta.422 +- Update version +- Update development node version +- Update coolify version to 4.0.0-beta.423 and nightly version to 4.0.0-beta.424 +- Update coolify version to 4.0.0-beta.424 and nightly version to 4.0.0-beta.425 + +## [4.0.0-beta.420.8] - 2025-08-26 + +### 🚜 Refactor + +- *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.420.7] - 2025-08-26 + +### 🚀 Features + +- *(service)* Add TriliumNext service (#5970) +- *(service)* Add Matrix service (#6029) +- *(service)* Add GitHub Action runner service (#6209) +- *(terminal)* Dispatch focus event for terminal after connection and enhance focus handling in JavaScript +- *(lang)* Add Polish language & improve forgot_password translation (#6306) +- *(service)* Update Authentik template (#6264) +- *(service)* Add sequin template (#6105) +- *(service)* Add pi-hole template (#6020) +- *(services)* Add Chroma service (#6201) +- *(service)* Add OpenPanel template (#5310) +- *(service)* Add librechat template (#5654) +- *(service)* Add Homebox service (#6116) +- *(service)* Add pterodactyl & wings services (#5537) +- *(service)* Add Bluesky PDS template (#6302) +- *(input)* Add autofocus attribute to input component for improved accessibility +- *(core)* Finally fqdn is fqdn and url is url. haha +- *(user)* Add changelog read tracking and unread count method +- *(templates)* Add new service templates and update existing compose files for various applications +- *(changelog)* Implement automated changelog fetching from GitHub and enhance changelog read tracking +- *(drizzle-gateway)* Add new drizzle-gateway service with configuration and logo +- *(drizzle-gateway)* Enhance service configuration by adding Master Password field and updating compose file path +- *(templates)* Add new service templates for Homebox, LibreChat, Pterodactyl, and Wings with corresponding configurations and logos +- *(templates)* Add Bluesky PDS service template and update compose file with new environment variable +- *(readme)* Add CubePath as a big sponsor and include new small sponsors with logos +- *(api)* Add create_environment endpoint to ProjectController for environment creation in projects +- *(api)* Add endpoints for managing environments in projects, including listing, creating, and deleting environments +- *(backup)* Add disable local backup option and related logic for S3 uploads +- *(dev patches)* Add functionality to send test email with patch data in development mode +- *(templates)* Added category per service +- *(email)* Implement email change request and verification process +- Generate category for services +- *(service)* Add elasticsearch template (#6300) +- *(sanitization)* Integrate DOMPurify for HTML sanitization across components +- *(cleanup)* Add command for sanitizing name fields across models +- *(sanitization)* Enhance HTML sanitization with improved DOMPurify configuration +- *(validation)* Centralize validation patterns for names and descriptions +- *(git-settings)* Add support for shallow cloning in application settings +- *(auth)* Implement authorization checks for server updates across multiple components +- *(auth)* Implement authorization for PrivateKey management +- *(auth)* Implement authorization for Docker and server management +- *(validation)* Add custom validation rules for Git repository URLs and branches +- *(security)* Add authorization checks for package updates in Livewire components +- *(auth)* Implement authorization checks for application management +- *(auth)* Enhance API error handling for authorization exceptions +- *(auth)* Add comprehensive authorization checks for all kind of resource creations +- *(auth)* Implement authorization checks for database management +- *(auth)* Refine authorization checks for S3 storage and service management +- *(auth)* Implement comprehensive authorization checks across API controllers +- *(auth)* Introduce resource creation authorization middleware and policies for enhanced access control +- *(auth)* Add middleware for resource creation authorization +- *(auth)* Enhance authorization checks in Livewire components for resource management +- *(validation)* Add ValidIpOrCidr rule for validating IP addresses and CIDR notations; update API access settings UI and add comprehensive tests +- *(docs)* Update architecture and development guidelines; enhance form components with built-in authorization system and improve routing documentation +- *(docs)* Expand authorization documentation for custom Alpine.js components; include manual protection patterns and implementation guidelines +- *(sentinel)* Implement SentinelRestarted event and update Livewire components to handle server restart notifications +- *(api)* Enhance IP access control in middleware and settings; support CIDR notation and special case for 0.0.0.0 to allow all IPs +- *(acl)* Change views/backend code to able to use proper ACL's later on. Currently it is not enabled. +- *(docs)* Add Backlog.md guidelines and project manager backlog agent; enhance CLAUDE.md with new links for task management +- *(docs)* Add tasks for implementing Docker build caching and optimizing staging builds; include detailed acceptance criteria and implementation plans +- *(docker)* Implement Docker cleanup processing in ScheduledJobManager; refactor server task scheduling to streamline cleanup job dispatching +- *(docs)* Expand Backlog.md guidelines with comprehensive usage instructions, CLI commands, and best practices for task management to enhance project organization and collaboration + +### 🐛 Bug Fixes + +- *(service)* Triliumnext platform and link +- *(application)* Update service environment variables when generating domain for Docker Compose +- *(application)* Add option to suppress toast notifications when loading compose file +- *(git)* Tracking issue due to case sensitivity +- *(git)* Tracking issue due to case sensitivity +- *(git)* Tracking issue due to case sensitivity +- *(ui)* Delete button width on small screens (#6308) +- *(service)* Matrix entrypoint +- *(ui)* Add flex-wrap to prevent overflow on small screens (#6307) +- *(docker)* Volumes get delete when stopping a service if `Delete Unused Volumes` is activated (#6317) +- *(docker)* Cleanup always running on deletion +- *(proxy)* Remove hardcoded port 80/443 checks (#6275) +- *(service)* Update healthcheck of penpot backend container (#6272) +- *(api)* Duplicated logs in application endpoint (#6292) +- *(service)* Documenso signees always pending (#6334) +- *(api)* Update service upsert to retain name and description values if not set +- *(database)* Custom postgres configs with SSL (#6352) +- *(policy)* Update delete method to check for admin status in S3StoragePolicy +- *(container)* Sort containers alphabetically by name in ExecuteContainerCommand and update filtering in Terminal Index +- *(application)* Streamline environment variable updates for Docker Compose services and enhance FQDN generation logic +- *(constants)* Update 'Change Log' to 'Changelog' in settings dropdown +- *(constants)* Update coolify version to 4.0.0-beta.420.7 +- *(parsers)* Clarify comments and update variable checks for FQDN and URL handling +- *(terminal)* Update text color for terminal availability message and improve readability +- *(drizzle-gateway)* Remove healthcheck from drizzle-gateway compose file and update service template +- *(templates)* Should generate old SERVICE_FQDN service templates as well +- *(constants)* Update official service template URL to point to the v4.x branch for accuracy +- *(git)* Use exact refspec in ls-remote to avoid matching similarly named branches (e.g., changeset-release/main). Use refs/heads/ or provider-specific PR refs. +- *(ApplicationPreview)* Change null check to empty check for fqdn in generate_preview_fqdn method +- *(email notifications)* Enhance EmailChannel to validate team membership for recipients and handle errors gracefully +- *(service api)* Separate create and update service functionalities +- *(templates)* Added a category tag for the docs service filter +- *(application)* Clear Docker Compose specific data when switching away from dockercompose +- *(database)* Conditionally set started_at only if the database is running +- *(ui)* Handle null values in postgres metrics (#6388) +- Disable env sorting by default +- *(proxy)* Filter host network from default proxy (#6383) +- *(modal)* Enhance confirmation text handling +- *(notification)* Update unread count display and improve HTML rendering +- *(select)* Remove unnecessary sanitization for logo rendering +- *(tags)* Update tag display to limit name length and adjust styling +- *(init)* Improve error handling for deployment and template pulling processes +- *(settings-dropdown)* Adjust unread count badge size and display logic for better consistency +- *(sanitization)* Enhance DOMPurify hook to remove Alpine.js directives for improved XSS protection +- *(servercheck)* Properly check server statuses with and without Sentinel +- *(errors)* Update error pages to provide navigation options +- *(github-deploy-key)* Update background color for selected private keys in deployment key selection UI +- *(auth)* Enhance authorization checks in application management + +### 💼 Other + +- *(settings-dropdown)* Add icons to buttons for improved UI in settings dropdown +- *(ui)* Introduce task for simplifying resource operations UI by replacing boxes with dropdown selections to enhance user experience and streamline interactions + +### 🚜 Refactor + +- *(jobs)* Remove logging for ScheduledJobManager and ServerResourceManager start and completion +- *(services)* Update validation rules to be optional +- *(service)* Improve langfuse +- *(service)* Improve openpanel template +- *(service)* Improve librechat +- *(public-git-repository)* Enhance form structure and add autofocus to repository URL input +- *(public-git-repository)* Remove commented-out code for cleaner template +- *(templates)* Update service template file handling to use dynamic file name from constants +- *(parsers)* Streamline domain handling in applicationParser and improve DNS validation logic +- *(templates)* Replace SERVICE_FQDN variables with SERVICE_URL in compose files for consistency +- *(links)* Replace inline SVGs with reusable external link component for consistency and improved maintainability +- *(previews)* Improve layout and add deployment/application logs links for previews +- *(docker compose)* Remove deprecated newParser function and associated test file to streamline codebase +- *(shared helpers)* Remove unused parseServiceVolumes function to clean up codebase +- *(parsers)* Update volume parsing logic to use beforeLast and afterLast for improved accuracy +- *(validation)* Implement centralized validation patterns across components +- *(jobs)* Rename job classes to indicate deprecation status +- Update check frequency logic for cloud and self-hosted environments; streamline server task scheduling and timezone handling +- *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility + +### 📚 Documentation + +- *(claude)* Clarify that artisan commands should only be run inside the "coolify" container during development +- Add AGENTS.md for project guidance and development instructions +- Update changelog +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(service)* Improve matrix service +- *(service)* Format runner service +- *(service)* Improve sequin +- *(service)* Add `NOT_SECURED` env to Postiz (#6243) +- *(service)* Improve evolution-api environment variables (#6283) +- *(service)* Update Langfuse template to v3 (#6301) +- *(core)* Remove unused argument +- *(deletion)* Rename isDeleteOperation to deleteConnectedNetworks +- *(docker)* Remove unused arguments on StopService +- *(service)* Homebox formatting +- Clarify usage of custom redis configuration (#6321) +- *(changelogs)* Add .gitignore for changelogs directory and remove outdated changelog files for May, June, and July 2025 +- *(service)* Change affine images (#6366) +- Elasticsearch URL, fromatting and add category +- Update service-templates json files +- *(docs)* Remove AGENTS.md file; enhance CLAUDE.md with detailed form authorization patterns and service configuration examples +- *(cleanup)* Remove unused GitLab view files for change, new, and show pages +- *(workflows)* Add backlog directory to build triggers for production and staging workflows +- *(config)* Disable auto_commit in backlog configuration to prevent automatic commits +- *(versions)* Update coolify version to 4.0.0-beta.420.8 and nightly version to 4.0.0-beta.420.9 in versions.json and constants.php +- *(docker)* Update soketi image version to 1.0.10 in production and Windows configurations + +### ◀️ Revert + +- *(parser)* Enhance FQDN generation logic for services and applications + +## [4.0.0-beta.420.6] - 2025-07-18 + +### 🚀 Features + +- *(service)* Enable password protection for the Wireguard Ul +- *(queues)* Improve Horizon config to reduce CPU and RAM usage (#6212) +- *(service)* Add Gowa service (#6164) +- *(container)* Add updatedSelectedContainer method to connect to non-default containers and update wire:model for improved reactivity +- *(application)* Implement environment variable updates for Docker Compose applications, including creation, updating, and deletion of SERVICE_FQDN and SERVICE_URL variables + +### 🐛 Bug Fixes + +- *(installer)* Public IPv4 link does not work +- *(composer)* Version constraint of prompts +- *(service)* Budibase secret keys (#6205) +- *(service)* Wg-easy host should be just the FQDN +- *(ui)* Search box overlaps the sidebar navigation (#6176) +- *(webhooks)* Exclude webhook routes from CSRF protection (#6200) +- *(services)* Update environment variable naming convention to use underscores instead of dashes for SERVICE_FQDN and SERVICE_URL + +### 🚜 Refactor + +- *(service)* Improve gowa +- *(previews)* Streamline preview domain generation logic in ApplicationDeploymentJob for improved clarity and maintainability +- *(services)* Simplify environment variable updates by using updateOrCreate and add cleanup for removed FQDNs + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(service)* Update Nitropage template (#6181) +- *(versions)* Update all version +- *(bump)* Update composer deps +- *(version)* Bump Coolify version to 4.0.0-beta.420.6 + +## [4.0.0-beta.420.4] - 2025-07-08 + +### 🚀 Features + +- *(scheduling)* Add command to manually run scheduled database backups and tasks with options for chunking, delays, and dry runs +- *(scheduling)* Add frequency filter option for manual execution of scheduled jobs +- *(logging)* Implement scheduled logs command and enhance backup/task scheduling with cron checks +- *(logging)* Add frequency filters for scheduled logs command to support hourly, daily, weekly, and monthly job views +- *(scheduling)* Introduce ScheduledJobManager and ServerResourceManager for enhanced job scheduling and resource management +- *(previews)* Implement soft delete and cleanup for ApplicationPreview, enhancing resource management in DeleteResourceJob + +### 🐛 Bug Fixes + +- *(service)* Update Postiz compose configuration for improved server availability +- *(install.sh)* Use IPV4_PUBLIC_IP variable in output instead of repeated curl +- *(env)* Generate literal env variables better +- *(deployment)* Update x-data initialization in deployment view for improved functionality +- *(deployment)* Enhance COOLIFY_URL and COOLIFY_FQDN variable generation for better compatibility +- *(deployment)* Improve docker-compose domain handling and environment variable generation +- *(deployment)* Refactor domain parsing and environment variable generation using Spatie URL library +- *(deployment)* Update COOLIFY_URL and COOLIFY_FQDN generation to use Spatie URL library for improved accuracy +- *(scheduling)* Change redis cleanup command frequency from hourly to weekly for better resource management +- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.5 and 4.0.0-beta.420.6 +- *(database)* Ensure internal port defaults correctly for unsupported database types in StartDatabaseProxy +- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.6 and 4.0.0-beta.420.7 +- *(scheduling)* Remove unnecessary padding from scheduled task form layout for improved UI consistency +- *(horizon)* Update queue configuration to use environment variable for dynamic queue management +- *(horizon)* Add silenced jobs +- *(application)* Sanitize service names for HTML form binding and ensure original names are stored in docker compose domains +- *(previews)* Adjust padding for rate limit message in application previews +- *(previews)* Order application previews by pull request ID in descending order +- *(previews)* Add unique wire keys for preview containers and services based on pull request ID +- *(previews)* Enhance domain generation logic for application previews, ensuring unique domains are created when none are set +- *(previews)* Refine preview domain generation for Docker Compose applications, ensuring correct method usage based on build pack type +- *(ui)* Typo on proxy request handler tooltip (#6192) +- *(backups)* Large database backups are not working (#6217) +- *(backups)* Error message if there is no exception + +### 🚜 Refactor + +- *(previews)* Streamline preview URL generation by utilizing application method +- *(application)* Adjust layout and spacing in general application view for improved UI +- *(postgresql)* Improve layout and spacing in SSL and Proxy configuration sections for better UI consistency +- *(scheduling)* Replace deprecated job checks with ScheduledJobManager and ServerResourceManager for improved scheduling efficiency +- *(previews)* Move preview domain generation logic to ApplicationPreview model for better encapsulation and consistency across webhook handlers + +### 📚 Documentation + +- Update changelog +- Update changelog + +## [4.0.0-beta.420.3] - 2025-07-03 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.420.2] - 2025-07-03 + +### 🚀 Features + +- *(template)* Added excalidraw (#6095) +- *(template)* Add excalidraw service configuration with documentation and tags + +### 🐛 Bug Fixes + +- *(terminal)* Ensure shell execution only uses valid shell if available in terminal command +- *(ui)* Improve destination selection description for clarity in resource segregation +- *(jobs)* Update middleware to use expireAfter for WithoutOverlapping in multiple job classes +- Removing eager loading (#6071) +- *(template)* Adjust health check interval and retries for excalidraw service +- *(ui)* Env variable settings wrong order +- *(service)* Ensure configuration changes are properly tracked and dispatched + +### 🚜 Refactor + +- *(ui)* Enhance project cloning interface with improved table layout for server and resource selection +- *(terminal)* Simplify command construction for SSH execution +- *(settings)* Streamline instance admin checks and initialization of settings in Livewire components +- *(policy)* Optimize team membership checks in S3StoragePolicy +- *(popup)* Improve styling and structure of the small popup component +- *(shared)* Enhance FQDN generation logic for services in newParser function +- *(redis)* Enhance CleanupRedis command with dry-run option and improved key deletion logic +- *(init)* Standardize method naming conventions and improve command structure in Init.php +- *(shared)* Improve error handling in getTopLevelNetworks function to return network name on invalid docker-compose.yml +- *(database)* Improve error handling for unsupported database types in StartDatabaseProxy + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Bump coolify and nightly versions to 4.0.0-beta.420.3 and 4.0.0-beta.420.4 respectively +- *(versions)* Update coolify and nightly versions to 4.0.0-beta.420.4 and 4.0.0-beta.420.5 respectively + +## [4.0.0-beta.420.1] - 2025-06-26 + +### 🐛 Bug Fixes + +- *(server)* Prepend 'mux_' to UUID in muxFilename method for consistent naming +- *(ui)* Enhance terminal access messaging to clarify server functionality and terminal status +- *(database)* Proxy ssl port if ssl is enabled + +### 🚜 Refactor + +- *(ui)* Separate views for instance settings to separate paths to make it cleaner +- *(ui)* Remove unnecessary step3ButtonText attributes from modal confirmation components for cleaner code + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update Coolify versions to 4.0.0-beta.420.2 and 4.0.0-beta.420.3 in multiple files + +## [4.0.0-beta.420] - 2025-06-26 + +### 🚀 Features + +- *(service)* Add Miniflux service (#5843) +- *(service)* Add Pingvin Share service (#5969) +- *(auth)* Add Discord OAuth Provider (#5552) +- *(auth)* Add Clerk OAuth Provider (#5553) +- *(auth)* Add Zitadel OAuth Provider (#5490) +- *(core)* Set custom API rate limit (#5984) +- *(service)* Enhance service status handling and UI updates +- *(cleanup)* Add functionality to delete teams with no members or servers in CleanupStuckedResources command +- *(ui)* Add heart icon and enhance popup messaging for sponsorship support +- *(settings)* Add sponsorship popup toggle and corresponding database migration +- *(migrations)* Add optimized indexes to activity_log for improved query performance + +### 🐛 Bug Fixes + +- *(service)* Audiobookshelf healthcheck command (#5993) +- *(service)* Downgrade Evolution API phone version (#5977) +- *(service)* Pingvinshare-with-clamav +- *(ssh)* Scp requires square brackets for ipv6 (#6001) +- *(github)* Changing github app breaks the webhook. it does not anymore +- *(parser)* Improve FQDN generation and update environment variable handling +- *(ui)* Enhance status refresh buttons with loading indicators +- *(ui)* Update confirmation button text for stopping database and service +- *(routes)* Update middleware for deploy route to use 'api.ability:deploy' +- *(ui)* Refine API token creation form and update helper text for clarity +- *(ui)* Adjust layout of deployments section for improved alignment +- *(ui)* Adjust project grid layout and refine server border styling for better visibility +- *(ui)* Update border styling for consistency across components and enhance loading indicators +- *(ui)* Add padding to section headers in settings views for improved spacing +- *(ui)* Reduce gap between input fields in email settings for better alignment +- *(docker)* Conditionally enable gzip compression in Traefik labels based on configuration +- *(parser)* Enable gzip compression conditionally for Pocketbase images and streamline service creation logic +- *(ui)* Update padding for trademarks policy and enhance spacing in advanced settings section +- *(ui)* Correct closing tag for sponsorship link in layout popups +- *(ui)* Refine wording in sponsorship donation prompt in layout popups +- *(ui)* Update navbar icon color and enhance popup layout for sponsorship support +- *(ui)* Add target="_blank" to sponsorship links in layout popups for improved user experience +- *(models)* Refine comment wording in User model for clarity on user deletion criteria +- *(models)* Improve user deletion logic in User model to handle team member roles and prevent deletion if user is alone in root team +- *(ui)* Update wording in sponsorship prompt for clarity and engagement +- *(shared)* Refactor gzip handling for Pocketbase in newParser function for improved clarity + +### 🚜 Refactor + +- *(service)* Update Hoarder to their new name karakeep (#5964) +- *(service)* Karakeep naming and formatting +- *(service)* Improve miniflux +- *(core)* Rename API rate limit ENV +- *(ui)* Simplify container selection form in execute-container-command view +- *(email)* Streamline SMTP and resend settings logic for improved clarity +- *(invitation)* Rename methods for consistency and enhance invitation deletion logic +- *(user)* Streamline user deletion process and enhance team management logic + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(service)* Update Evolution API image to the official one (#6031) +- *(versions)* Bump coolify versions to v4.0.0-beta.420 and v4.0.0-beta.421 +- *(dependencies)* Update composer dependencies to latest versions including resend-laravel to ^0.19.0 and aws-sdk-php to 3.347.0 +- *(versions)* Update Coolify version to 4.0.0-beta.420.1 and add new services (karakeep, miniflux, pingvinshare) to service templates + +## [4.0.0-beta.419] - 2025-06-17 + +### 🚀 Features + +- *(core)* Add 'postmarketos' to supported OS list +- *(service)* Add memos service template (#5032) +- *(ui)* Upgrade to Tailwind v4 (#5710) +- *(service)* Add Navidrome service template (#5022) +- *(service)* Add Passbolt service (#5769) +- *(service)* Add Vert service (#5663) +- *(service)* Add Ryot service (#5232) +- *(service)* Add Marimo service (#5559) +- *(service)* Add Diun service (#5113) +- *(service)* Add Observium service (#5613) +- *(service)* Add Leantime service (#5792) +- *(service)* Add Limesurvey service (#5751) +- *(service)* Add Paymenter service (#5809) +- *(service)* Add CodiMD service (#4867) +- *(modal)* Add dispatchAction property to confirmation modal +- *(security)* Implement server patching functionality +- *(service)* Add Typesense service (#5643) +- *(service)* Add Yamtrack service (#5845) +- *(service)* Add PG Back Web service (#5079) +- *(service)* Update Maybe service and adjust it for the new release (#5795) +- *(oauth)* Set redirect uri as optional and add default value (#5760) +- *(service)* Add apache superset service (#4891) +- *(service)* Add One Time Secret service (#5650) +- *(service)* Add Seafile service (#5817) +- *(service)* Add Netbird-Client service (#5873) +- *(service)* Add OrangeHRM and Grist services (#5212) +- *(rules)* Add comprehensive documentation for Coolify architecture and development practices for AI tools, especially for cursor +- *(server)* Implement server patch check notifications +- *(api)* Add latest query param to Service restart API (#5881) +- *(api)* Add connect_to_docker_network setting to App creation API (#5691) +- *(routes)* Restrict backup download access to team admins and owners +- *(destination)* Update confirmation modal text and add persistent storage warning for server deployment +- *(terminal-access)* Implement terminal access control for servers and containers, including UI updates and backend logic +- *(ca-certificate)* Add CA certificate management functionality with UI integration and routing +- *(security-patches)* Add update check initialization and enhance notification messaging in UI +- *(previews)* Add force deploy without cache functionality and update deploy method to accept force rebuild parameter +- *(security-patterns)* Expand sensitive patterns list to include additional security-related variables +- *(database-backup)* Add MongoDB credential extraction and backup handling to DatabaseBackupJob +- *(activity-monitor)* Implement auto-scrolling functionality and dynamic content observation for improved user experience +- *(utf8-handling)* Implement UTF-8 sanitization for command outputs and enhance error handling in logs processing +- *(navbar)* Add Traefik dashboard availability check and server IP handling; refactor dynamic configurations loading +- *(proxy-dashboard)* Implement ProxyDashboardCacheService to manage Traefik dashboard cache; clear cache on configuration changes and proxy actions +- *(terminal-connection)* Enhance terminal connection handling with auto-connect feature and improved status messaging +- *(terminal)* Implement resize handling with ResizeObserver for improved terminal responsiveness +- *(migration)* Add is_sentinel_enabled column to server_settings with default true +- *(seeder)* Dispatch StartProxy action for each server in ProductionSeeder +- *(seeder)* Add CheckAndStartSentinelJob dispatch for each server in ProductionSeeder +- *(seeder)* Conditionally dispatch StartProxy action based on proxy check result +- *(service)* Update Changedetection template (#5937) + +### 🐛 Bug Fixes + +- *(constants)* Adding 'fedora-asahi-remix' as a supported OS (#5646) +- *(authentik)* Update docker-compose configuration for authentik service +- *(api)* Allow nullable destination_uuid (#5683) +- *(service)* Fix documenso startup and mail (#5737) +- *(docker)* Fix production dockerfile +- *(service)* Navidrome service +- *(service)* Passbolt +- *(service)* Add missing ENVs to NTFY service (#5629) +- *(service)* NTFY is behind a proxy +- *(service)* Vert logo and ENVs +- *(service)* Add platform to Observium service +- *(ActivityMonitor)* Prevent multiple event dispatches during polling +- *(service)* Convex ENVs and update image versions (#5827) +- *(service)* Paymenter +- *(ApplicationDeploymentJob)* Ensure correct COOLIFY_FQDN/COOLIFY_URL values (#4719) +- *(service)* Snapdrop no matching manifest error (#5849) +- *(service)* Use the same volume between chatwoot and sidekiq (#5851) +- *(api)* Validate docker_compose_raw input in ApplicationsController +- *(api)* Enhance validation for docker_compose_raw in ApplicationsController +- *(select)* Update PostgreSQL versions and titles in resource selection +- *(database)* Include DatabaseStatusChanged event in activityMonitor dispatch +- *(css)* Tailwind v5 things +- *(service)* Diun ENV for consistency +- *(service)* Memos service name +- *(css)* 8+ issue with new tailwind v4 +- *(css)* `bg-coollabs-gradient` not working anymore +- *(ui)* Add back missing service navbar components +- *(deploy)* Update resource timestamp handling in deploy_resource method +- *(patches)* DNF reboot logic is flipped +- *(deployment)* Correct syntax for else statement in docker compose build command +- *(shared)* Remove unused relation from queryDatabaseByUuidWithinTeam function +- *(deployment)* Correct COOLIFY_URL and COOLIFY_FQDN assignments based on parsing version in preview deployments +- *(docker)* Ensure correct parsing of environment variables by limiting explode to 2 parts +- *(project)* Update selected environment handling to use environment name instead of UUID +- *(ui)* Update server status display and improve server addition layout +- *(service)* Neon WS Proxy service not working on ARM64 (#5887) +- *(server)* Enhance error handling in server patch check notifications +- *(PushServerUpdateJob)* Add null checks before updating application and database statuses +- *(environment-variables)* Update label text for build variable checkboxes to improve clarity +- *(service-management)* Update service stop and restart messages for improved clarity and formatting +- *(preview-form)* Update helper text formatting in preview URL template input for better readability +- *(application-management)* Improve stop messages for application, database, and service to enhance clarity and formatting +- *(application-configuration)* Prevent access to preview deployments for deploy_key applications and update menu visibility accordingly +- *(select-component)* Handle exceptions during parameter retrieval and environment selection in the mount method +- *(previews)* Escape container names in stopContainers method to prevent shell injection vulnerabilities +- *(docker)* Add protection against empty container queries in GetContainersStatus to prevent unnecessary updates +- *(modal-confirmation)* Decode HTML entities in confirmation text to ensure proper display +- *(select-component)* Enhance user interaction by adding cursor styles and disabling selection during processing +- *(deployment-show)* Remove unnecessary fixed positioning for button container to improve layout responsiveness +- *(email-notifications)* Change notify method to notifyNow for immediate test email delivery +- *(service-templates)* Update Convex service configuration to use FQDN variables +- *(database-heading)* Simplify stop database message for clarity +- *(navbar)* Remove unnecessary x-init directive for loading proxy configuration +- *(patches)* Add padding to loading message for better visibility during update checks +- *(terminal-connection)* Improve error handling and stability for auto-connection; enhance component readiness checks and retry logic +- *(terminal)* Add unique wire:key to terminal component for improved reactivity and state management +- *(css)* Adjust utility classes in utilities.css for consistent application of Tailwind directives +- *(css)* Refine utility classes in utilities.css for proper Tailwind directive application +- *(install)* Update Docker installation script to use dynamic OS_TYPE and correct installation URL +- *(cloudflare)* Add error handling to automated Cloudflare configuration script +- *(navbar)* Add error handling for proxy status check to improve user feedback +- *(web)* Update user team retrieval method for consistent authentication handling +- *(cloudflare)* Update refresh method to correctly set Cloudflare tunnel status and improve user notification on IP address update +- *(service)* Update service template for affine and add migration service for improved deployment process +- *(supabase)* Update Supabase service images and healthcheck methods for improved reliability +- *(terminal)* Now it should work +- *(degraded-status)* Remove unnecessary whitespace in badge element for cleaner HTML +- *(routes)* Add name to security route for improved route management +- *(migration)* Update default value handling for is_sentinel_enabled column in server_settings +- *(seeder)* Conditionally dispatch CheckAndStartSentinelJob based on server's sentinel status +- *(service)* Disable healthcheck logging for Gotenberg (#6005) +- *(service)* Joplin volume name (#5930) +- *(server)* Update sentinelUpdatedAt assignment to use server's sentinel_updated_at property + +### 💼 Other + +- Add support for postmarketOS (#5608) +- *(core)* Simplify events for app/db/service status changes + +### 🚜 Refactor + +- *(service)* Observium +- *(service)* Improve leantime +- *(service)* Imporve limesurvey +- *(service)* Improve CodiMD +- *(service)* Typsense +- *(services)* Improve yamtrack +- *(service)* Improve paymenter +- *(service)* Consolidate configuration change dispatch logic and remove unused navbar component +- *(sidebar)* Simplify server patching link by removing button element +- *(slide-over)* Streamline button element and improve code readability +- *(service)* Enhance modal confirmation component with event dispatching for service stop actions +- *(slide-over)* Enhance class merging for improved component styling +- *(core)* Use property promotion +- *(service)* Improve maybe +- *(applications)* Remove unused docker compose raw decoding +- *(service)* Make TYPESENSE_API_KEY required +- *(ui)* Show toast when server does not work and on stop +- *(service)* Improve superset +- *(service)* Improve Onetimesecret +- *(service)* Improve Seafile +- *(service)* Improve orangehrm +- *(service)* Improve grist +- *(application)* Enhance application stopping logic to support multiple servers +- *(pricing-plans)* Improve label class binding for payment frequency selection +- *(error-handling)* Replace generic Exception with RuntimeException for improved error specificity +- *(error-handling)* Change Exception to RuntimeException for clearer error reporting +- *(service)* Remove informational dispatch during service stop for cleaner execution +- *(server-ui)* Improve layout and messaging in advanced settings and charts views +- *(terminal-access)* Streamline resource retrieval and enhance terminal access messaging in UI +- *(terminal)* Enhance terminal connection management and error handling, including improved reconnection logic and cleanup procedures +- *(application-deployment)* Separate handling of FAILED and CANCELLED_BY_USER statuses for clearer logic and notification +- *(jobs)* Update middleware to include job-specific identifiers for WithoutOverlapping +- *(jobs)* Modify middleware to use job-specific identifier for WithoutOverlapping +- *(environment-variables)* Remove debug logging from bulk submit handling for cleaner code +- *(environment-variables)* Simplify application build pack check in environment variable handling +- *(logs)* Adjust padding in logs view for improved layout consistency +- *(application-deployment)* Streamline post-deployment process by always dispatching container status check +- *(service-management)* Enhance container stopping logic by implementing parallel processing and removing deprecated methods +- *(activity-monitor)* Change activity property visibility and update view references for consistency +- *(activity-monitor)* Enhance layout responsiveness by adjusting class bindings and structure for better display +- *(service-management)* Update stopContainersInParallel method to enforce Server type hint for improved type safety +- *(service-management)* Rearrange docker cleanup logic in StopService to improve readability +- *(database-management)* Simplify docker cleanup logic in StopDatabase to enhance readability +- *(activity-monitor)* Consolidate activity monitoring logic and remove deprecated NewActivityMonitor component +- *(activity-monitor)* Update dispatch method to use activityMonitor instead of deprecated newActivityMonitor +- *(push-server-update)* Enhance application preview handling by incorporating pull request IDs and adding status update protections +- *(docker-compose)* Replace hardcoded Docker Compose configuration with external YAML template for improved database detection testing +- *(test-database-detection)* Rename services for clarity, add new database configurations, and update application service dependencies +- *(database-detection)* Enhance isDatabaseImage function to utilize service configuration for improved detection accuracy +- *(install-scripts)* Update Docker installation process to include manual installation fallback and improve error handling +- *(logs-view)* Update logs display for service containers with improved headings and dynamic key binding +- *(logs)* Enhance container loading logic and improve UI for logs display across various resource types +- *(cloudflare-tunnel)* Enhance layout and structure of Cloudflare Tunnel documentation and confirmation modal +- *(terminal-connection)* Streamline auto-connection logic and improve component readiness checks +- *(logs)* Remove unused methods and debug functionality from Logs.php for cleaner code +- *(remoteProcess)* Update sanitize_utf8_text function to accept nullable string parameter for improved type safety +- *(events)* Remove ProxyStarted event and associated ProxyStartedNotification listener for code cleanup +- *(navbar)* Remove unnecessary parameters from server navbar component for cleaner implementation +- *(proxy)* Remove commented-out listener and method for cleaner code structure +- *(events)* Update ProxyStatusChangedUI constructor to accept nullable teamId for improved flexibility +- *(cloudflare)* Update server retrieval method for improved query efficiency +- *(navbar)* Remove unused PHP use statement for cleaner code +- *(proxy)* Streamline proxy status handling and improve dashboard availability checks +- *(navbar)* Simplify proxy status handling and enhance loading indicators for better user experience +- *(resource-operations)* Filter out build servers from the server list and clean up commented-out code in the resource operations view +- *(execute-container-command)* Simplify connection logic and improve terminal availability checks +- *(navigation)* Remove wire:navigate directive from configuration links for cleaner HTML structure +- *(proxy)* Update StartProxy calls to use named parameter for async option +- *(clone-project)* Enhance server retrieval by including destinations and filtering out build servers +- *(ui)* Terminal +- *(ui)* Remove terminal header from execute-container-command view +- *(ui)* Remove unnecessary padding from deployment, backup, and logs sections + +### 📚 Documentation + +- Update changelog +- *(service)* Add new docs link for zipline (#5912) +- Update changelog +- Update changelog +- Update changelog + +### 🎨 Styling + +- *(css)* Update padding utility for password input and add newline in app.css +- *(css)* Refine badge utility styles in utilities.css +- *(css)* Enhance badge utility styles in utilities.css + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update coolify version to 4.0.0-beta.419 and nightly version to 4.0.0-beta.420 in configuration files +- *(service)* Rename hoarder server to karakeep (#5607) +- *(service)* Update Supabase services (#5708) +- *(service)* Remove unused documenso env +- *(service)* Formatting and cleanup of ryot +- *(docs)* Remove changelog and add it to gitignore +- *(versions)* Update version to 4.0.0-beta.419 +- *(service)* Diun formatting +- *(docs)* Update CHANGELOG.md +- *(service)* Switch convex vars +- *(service)* Pgbackweb formatting and naming update +- *(service)* Remove typesense default API key +- *(service)* Format yamtrack healthcheck +- *(core)* Remove unused function +- *(ui)* Remove unused stopEvent code +- *(service)* Remove unused env +- *(tests)* Update test environment database name and add new feature test for converting container environment variables to array +- *(service)* Update Immich service (#5886) +- *(service)* Remove unused logo +- *(api)* Update API docs +- *(dependencies)* Update package versions in composer.json and composer.lock for improved compatibility and performance +- *(dependencies)* Update package versions in package.json and package-lock.json for improved stability and features +- *(version)* Update coolify-realtime to version 1.0.9 in docker-compose and versions files +- *(version)* Update coolify version to 4.0.0-beta.420 and nightly version to 4.0.0-beta.421 +- *(service)* Changedetection remove unused code + +## [4.0.0-beta.417] - 2025-05-07 + +### 🐛 Bug Fixes + +- *(select)* Update fallback logo path to use absolute URL for improved reliability + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update coolify version to 4.0.0-beta.418 + +## [4.0.0-beta.416] - 2025-05-05 + +### 🚀 Features + +- *(migration)* Add 'is_migrated' and 'custom_type' columns to service_applications and service_databases tables +- *(backup)* Implement custom database type selection and enhance scheduled backups management +- *(README)* Add Gozunga and Macarne to sponsors list +- *(redis)* Add scheduled cleanup command for Redis keys and enhance cleanup logic + +### 🐛 Bug Fixes + +- *(service)* Graceful shutdown of old container (#5731) +- *(ServerCheck)* Enhance proxy container check to ensure it is running before proceeding +- *(applications)* Include pull_request_id in deployment queue check to prevent duplicate deployments +- *(database)* Update label for image input field to improve clarity +- *(ServerCheck)* Set default proxy status to 'exited' to handle missing container state +- *(database)* Reduce container stop timeout from 300 to 30 seconds for improved responsiveness +- *(ui)* System theming for charts (#5740) +- *(dev)* Mount points?! +- *(dev)* Proxy mount point +- *(ui)* Allow adding scheduled backups for non-migrated databases +- *(DatabaseBackupJob)* Escape PostgreSQL password in backup command (#5759) +- *(ui)* Correct closing div tag in service index view + +### 🚜 Refactor + +- *(Database)* Streamline container shutdown process and reduce timeout duration +- *(core)* Streamline container stopping process and reduce timeout duration; update related methods for consistency +- *(database)* Update DB facade usage for consistency across service files +- *(database)* Enhance application conversion logic and add existence checks for databases and applications +- *(actions)* Standardize method naming for network and configuration deletion across application and service classes +- *(logdrain)* Consolidate log drain stopping logic to reduce redundancy +- *(StandaloneMariadb)* Add type hint for destination method to improve code clarity +- *(DeleteResourceJob)* Streamline resource deletion logic and improve conditional checks for database types +- *(jobs)* Update middleware to prevent job release after expiration for CleanupInstanceStuffsJob, RestartProxyJob, and ServerCheckJob +- *(jobs)* Unify middleware configuration to prevent job release after expiration for DockerCleanupJob and PushServerUpdateJob + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(seeder)* Update git branch from 'main' to 'v4.x' for multiple examples in ApplicationSeeder +- *(versions)* Update coolify version to 4.0.0-beta.417 and nightly version to 4.0.0-beta.418 + +## [4.0.0-beta.415] - 2025-04-29 + +### 🐛 Bug Fixes + +- *(ui)* Remove required attribute from image input in service application view +- *(ui)* Change application image validation to be nullable in service application view +- *(Server)* Correct proxy path formatting for Traefik proxy type + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update coolify version to 4.0.0-beta.416 and nightly version to 4.0.0-beta.417 in configuration files; fix links in deployment view + +## [4.0.0-beta.414] - 2025-04-28 + +### 🐛 Bug Fixes + +- *(ui)* Disable livewire navigate feature (causing spam of setInterval()) + +## [4.0.0-beta.413] - 2025-04-28 + +### 💼 Other + +- Adjust Workflows for v5 (#5689) + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(workflows)* Adjust workflow for announcement + +## [4.0.0-beta.411] - 2025-04-23 + +### 🚀 Features + +- *(deployment)* Add repository_project_id handling for private GitHub apps and clean up unused Caddy label logic +- *(api)* Enhance OpenAPI specifications with token variable and additional key attributes +- *(docker)* Add HTTP Basic Authentication support and enhance hostname parsing in Docker run conversion +- *(api)* Add HTTP Basic Authentication fields to OpenAPI specifications and enhance PrivateKey model descriptions +- *(README)* Add InterviewPal sponsorship link and corresponding SVG icon + +### 🐛 Bug Fixes + +- *(backup-edit)* Conditionally enable S3 checkbox based on available validated S3 storage +- *(source)* Update no sources found message for clarity +- *(api)* Correct middleware for service update route to ensure proper permissions +- *(api)* Handle JSON response in service creation and update methods for improved error handling +- Add 201 json code to servers validate api response +- *(docker)* Ensure password hashing only occurs when HTTP Basic Authentication is enabled +- *(docker)* Enhance hostname and GPU option validation in Docker run to compose conversion +- *(terminal)* Enhance WebSocket client verification with authorized IPs in terminal server +- *(ApplicationDeploymentJob)* Ensure source is an object before checking GitHub app properties + +### 🚜 Refactor + +- *(jobs)* Comment out unused Caddy label handling in ApplicationDeploymentJob and simplify proxy path logic in Server model +- *(database)* Simplify database type checks in ServiceDatabase and enhance image validation in Docker helper +- *(shared)* Remove unused ray debugging statement from newParser function +- *(applications)* Remove redundant error response in create_env method +- *(api)* Restructure routes to include versioning and maintain existing feedback endpoint +- *(api)* Remove token variable from OpenAPI specifications for clarity +- *(environment-variables)* Remove protected variable checks from delete methods for cleaner logic +- *(http-basic-auth)* Rename 'http_basic_auth_enable' to 'http_basic_auth_enabled' across application files for consistency +- *(docker)* Remove debug statement and enhance hostname handling in Docker run conversion +- *(server)* Simplify proxy path logic and remove unnecessary conditions + +### 📚 Documentation + +- Update changelog +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update coolify version to 4.0.0-beta.411 and nightly version to 4.0.0-beta.412 in configuration files +- *(versions)* Update coolify version to 4.0.0-beta.412 and nightly version to 4.0.0-beta.413 in configuration files +- *(versions)* Update coolify version to 4.0.0-beta.413 and nightly version to 4.0.0-beta.414 in configuration files +- *(versions)* Update realtime version to 1.0.8 in versions.json +- *(versions)* Update realtime version to 1.0.8 in versions.json +- *(docker)* Update soketi image version to 1.0.8 in production configuration files +- *(versions)* Update coolify version to 4.0.0-beta.414 and nightly version to 4.0.0-beta.415 in configuration files + +## [4.0.0-beta.410] - 2025-04-18 + +### 🚀 Features + +- Add HTTP Basic Authentication +- *(readme)* Add new sponsors Supadata AI and WZ-IT to the README +- *(core)* Enable magic env variables for compose based applications + +### 🐛 Bug Fixes + +- *(application)* Append base directory to git branch URLs for improved path handling +- *(templates)* Correct casing of "denokv" to "denoKV" in service templates JSON +- *(navbar)* Update error message link to use route for environment variables navigation +- Unsend template +- Replace ports with expose +- *(templates)* Update Unsend compose configuration for improved service integration + +### 🚜 Refactor + +- *(jobs)* Update WithoutOverlapping middleware to use expireAfter for better queue management + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Bump coolify version to 4.0.0-beta.410 and update nightly version to 4.0.0-beta.411 in configuration files +- *(templates)* Update plausible and clickhouse images to latest versions and remove mail service + +## [4.0.0-beta.409] - 2025-04-16 + +### 🐛 Bug Fixes + +- *(parser)* Transform associative array labels into key=value format for better compatibility +- *(redis)* Update username and password input handling to clarify database sync requirements +- *(source)* Update connected source display to handle cases with no source connected + +### 🚜 Refactor + +- *(source)* Conditionally display connected source and change source options based on private key presence + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Bump coolify version to 4.0.0-beta.409 in configuration files + +## [4.0.0-beta.408] - 2025-04-14 + +### 🚀 Features + +- *(OpenApi)* Enhance OpenAPI specifications by adding UUID parameters for application, project, and service updates; improve deployment listing with pagination parameters; update command signature for OpenApi generation +- *(subscription)* Enhance subscription management with loading states and Stripe status checks + +### 🐛 Bug Fixes + +- *(pre-commit)* Correct input redirection for /dev/tty and add OpenAPI generation command +- *(pricing-plans)* Adjust grid class for improved layout consistency in subscription pricing plans +- *(migrations)* Make stripe_comment field nullable in subscriptions table +- *(mongodb)* Also apply custom config when SSL is enabled +- *(templates)* Correct casing of denoKV references in service templates and YAML files +- *(deployment)* Handle missing destination in deployment process to prevent errors + +### 💼 Other + +- Add missing openapi items to PrivateKey + +### 🚜 Refactor + +- *(commands)* Reorganize OpenAPI and Services generation commands into a new namespace for better structure; remove old command files +- *(Dockerfile)* Remove service generation command from the build process to streamline Dockerfile and improve build efficiency +- *(navbar-delete-team)* Simplify modal confirmation layout and enhance button styling for better user experience +- *(Server)* Remove debug logging from isReachableChanged method to clean up code and improve performance + +### 📚 Documentation + +- Update changelog +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update nightly version to 4.0.0-beta.410 +- *(pre-commit)* Remove OpenAPI generation command from pre-commit hook +- *(versions)* Update realtime version to 1.0.7 and bump dependencies in package.json + +## [4.0.0-beta.407] - 2025-04-09 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.406] - 2025-04-05 + +### 🚀 Features + +- *(Deploy)* Add info dispatch for proxy check initiation +- *(EnvironmentVariable)* Add handling for Redis credentials in the environment variable component +- *(EnvironmentVariable)* Implement protection for critical environment variables and enhance deletion logic +- *(Application)* Add networkAliases attribute for handling network aliases as JSON or comma-separated values +- *(GithubApp)* Update default events to include 'pull_request' and streamline event handling +- *(CleanupDocker)* Add support for realtime image management in Docker cleanup process +- *(Deployment)* Enhance queue_application_deployment to handle existing deployments and return appropriate status messages +- *(SourceManagement)* Add functionality to change Git source and display current source in the application settings + +### 🐛 Bug Fixes + +- *(CheckProxy)* Update port conflict check to ensure accurate grep matching +- *(CheckProxy)* Refine port conflict detection with improved grep patterns +- *(CheckProxy)* Enhance port conflict detection by adjusting ss command for better output +- *(api)* Add back validateDataApplications (#5539) +- *(CheckProxy, Status)* Prevent proxy checks when force_stop is active; remove debug statement in General +- *(Status)* Conditionally check proxy status and refresh button based on force_stop state +- *(General)* Change redis_password property to nullable string +- *(DeployController)* Update request handling to use input method and enhance OpenAPI description for deployment endpoint + +### 💼 Other + +- Add missing UUID to openapi spec + +### 🚜 Refactor + +- *(Server)* Use data_get for safer access to settings properties in isFunctional method +- *(Application)* Rename network_aliases to custom_network_aliases across the application for clarity and consistency +- *(ApplicationDeploymentJob)* Streamline environment variable handling by introducing generate_coolify_env_variables method and consolidating logic for pull request and main branch scenarios +- *(ApplicationDeploymentJob, ApplicationDeploymentQueue)* Improve deployment status handling and log entry management with transaction support +- *(SourceManagement)* Sort sources by name and improve UI for changing Git source with better error handling +- *(Email)* Streamline SMTP and resend settings handling in copyFromInstanceSettings method +- *(Email)* Enhance error handling in SMTP and resend methods by passing context to handleError function +- *(DynamicConfigurations)* Improve handling of dynamic configuration content by ensuring fallback to empty string when content is null +- *(ServicesGenerate)* Update command signature from 'services:generate' to 'generate:services' for consistency; update Dockerfile to run service generation during build; update Odoo image version to 18 and add extra addons volume in compose configuration +- *(Dockerfile)* Streamline RUN commands for improved readability and maintainability by adding line continuations +- *(Dockerfile)* Reintroduce service generation command in the build process for consistency and ensure proper asset compilation + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Bump version to 406 +- *(versions)* Bump version to 407 and 408 for coolify and nightly +- *(versions)* Bump version to 408 for coolify and 409 for nightly + +## [4.0.0-beta.405] - 2025-04-04 + +### 🚀 Features + +- *(api)* Update OpenAPI spec for services (#5448) +- *(proxy)* Enhance proxy handling and port conflict detection + +### 🐛 Bug Fixes + +- *(api)* Used ssh keys can be deleted +- *(email)* Transactional emails not sending + +### 🚜 Refactor + +- *(CheckProxy)* Replace 'which' with 'command -v' for command availability checks + +### 📚 Documentation + +- Update changelog +- Update changelog +- Update changelog +- Update changelog +- Update changelog +- Update changelog +- Update changelog +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Bump version to 406 +- *(versions)* Bump version to 407 + +## [4.0.0-beta.404] - 2025-04-03 + +### 🚀 Features + +- *(lang)* Added Azerbaijani language updated turkish language. (#5497) +- *(lang)* Added Portuguese from Brazil language (#5500) +- *(lang)* Add Indonesian language translations (#5513) + +### 🐛 Bug Fixes + +- *(docs)* Comment out execute for now +- *(installation)* Mount the docker config +- *(installation)* Path to config file for docker login +- *(service)* Add health check to Bugsink service (#5512) +- *(email)* Emails are not sent in multiple cases +- *(deployments)* Use graceful shutdown instead of `rm` +- *(docs)* Contribute service url (#5517) +- *(proxy)* Proxy restart does not work on domain +- *(ui)* Only show copy button on https +- *(database)* Custom config for MongoDB (#5471) + +### 📚 Documentation + +- Update changelog +- Update changelog +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(service)* Remove unused code in Bugsink service +- *(versions)* Update version to 404 +- *(versions)* Bump version to 403 (#5520) +- *(versions)* Bump version to 404 + +## [4.0.0-beta.402] - 2025-04-01 + +### 🚀 Features + +- *(deployments)* Add list application deployments api route +- *(deploy)* Add pull request ID parameter to deploy endpoint +- *(api)* Add pull request ID parameter to applications endpoint +- *(api)* Add endpoints for retrieving application logs and deployments +- *(lang)* Added Norwegian language (#5280) +- *(dep)* Bump all dependencies + +### 🐛 Bug Fixes + +- Only get apps for the current team +- *(DeployController)* Cast 'pr' query parameter to integer +- *(deploy)* Validate team ID before deployment +- *(wakapi)* Typo in env variables and add some useful variables to wakapi.yaml (#5424) +- *(ui)* Instance Backup settings + +### 🚜 Refactor + +- *(dev)* Remove OpenAPI generation functionality +- *(migration)* Enhance local file volumes migration with logging + +### ⚙️ Miscellaneous Tasks + +- *(service)* Update minecraft service ENVs +- *(service)* Add more vars to infisical.yaml (#5418) +- *(service)* Add google variables to plausible.yaml (#5429) +- *(service)* Update authentik.yaml versions (#5373) +- *(core)* Remove redocs +- *(versions)* Update coolify version numbers to 4.0.0-beta.403 and 4.0.0-beta.404 + +## [4.0.0-beta.401] - 2025-03-28 + +### 📚 Documentation + +- Update changelog +- Update changelog + +## [4.0.0-beta.400] - 2025-03-27 + +### 🚀 Features + +- *(database)* Disable MongoDB SSL by default in migration +- *(database)* Add CA certificate generation for database servers +- *(application)* Add SPA configuration and update Nginx generation logic + +### 🐛 Bug Fixes + +- *(file-storage)* Double save on compose volumes +- *(parser)* Add logging support for applications in services + +### 🚜 Refactor + +- *(proxy)* Improve port availability checks with multiple methods +- *(database)* Update MongoDB SSL configuration for improved security +- *(database)* Enhance SSL configuration handling for various databases +- *(notifications)* Update Telegram button URL for staging environment +- *(models)* Remove unnecessary cloud check in isEnabled method +- *(database)* Streamline event listeners in Redis General component +- *(database)* Remove redundant database status display in MongoDB view +- *(database)* Update import statements for Auth in database components +- *(database)* Require PEM key file for SSL certificate regeneration +- *(database)* Change MySQL daemon command to MariaDB daemon +- *(nightly)* Update version numbers and enhance upgrade script +- *(versions)* Update version numbers for coolify and nightly +- *(email)* Validate team membership for email recipients +- *(shared)* Simplify deployment status check logic +- *(shared)* Add logging for running deployment jobs +- *(shared)* Enhance job status check to include 'reserved' +- *(email)* Improve error handling by passing context to handleError +- *(email)* Streamline email sending logic and improve configuration handling +- *(email)* Remove unnecessary whitespace in email sending logic +- *(email)* Allow custom email recipients in email sending logic +- *(email)* Enhance sender information formatting in email logic +- *(proxy)* Remove redundant stop call in restart method +- *(file-storage)* Add loadStorageOnServer method for improved error handling +- *(docker)* Parse and sanitize YAML compose file before encoding +- *(file-storage)* Improve layout and structure of input fields +- *(email)* Update label for test email recipient input +- *(database-backup)* Remove existing Docker container before backup upload +- *(database)* Improve decryption and deduplication of local file volumes +- *(database)* Remove debug output from volume update process + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update version numbers for coolify and nightly + +### ◀️ Revert + +- Encrypting mount and fs_path + +## [4.0.0-beta.399] - 2025-03-25 + +### 🚀 Features + +- *(service)* Neon +- *(migration)* Add `ssl_certificates` table and model +- *(migration)* Add ssl setting to `standalone_postgresqls` table +- *(ui)* Add ssl settings to Postgres ui +- *(db)* Add ssl mode to Postgres URLs +- *(db)* Setup ssl during Postgres start +- *(migration)* Encrypt local file volumes content and paths +- *(ssl)* Ssl generation helper +- *(ssl)* Migrate to `ECC`certificates using `secp521r1` +- *(ssl)* Improve SSL helper +- *(ssl)* Add a Coolify CA Certificate to all servers +- *(seeder)* Call CA SSL seeder in prod and dev +- *(ssl)* Add Coolify CA Certificate when adding a new server +- *(installer)* Create CA folder during installation +- *(ssl)* Improve SSL helper +- *(ssl)* Use new improved helper for SSL generation +- *(ui)* Add CA cert UI +- *(ui)* New copy button component +- *(ui)* Use new copy button component everywhere +- *(ui)* Improve server advanced view +- *(migration)* Add CN and alternative names to DB +- *(databases)* Add CA SSL crt location to Postgres URLs +- *(ssl)* Improve ssl generation +- *(ssl)* Regenerate SSL certs job +- *(ssl)* Regenerate certificate and valid until UI +- *(ssl)* Regenerate CA cert and all other certs logic +- *(ssl)* Add full MySQL SSL Support +- *(ssl)* Add full MariaDB SSL support +- *(ssl)* Add `openssl.conf` to configure SSL extension properly +- *(ssl)* Improve SSL generation and security a lot +- *(ssl)* Check for SSL renewal twice daily +- *(ssl)* Add SSL relationships to all DBs +- Add full SSL support to MongoDB +- *(ssl)* Fix some issues and improve ssl generation helper +- *(ssl)* Ability to create `.pem` certs and add `clientAuth` to `extendedKeyUsage` +- *(ssl)* New modes for MongoDB and get `caCert` and `mountPath` correctly +- *(ssl)* Full SSL support for Redis +- New mode implementation for MongoDB +- *(ssl)* Improve Redis and remove modes +- Full SSL support for DrangonflyDB +- SSL notification +- *(github-source)* Enhance GitHub App configuration with manual and private key support +- *(ui)* Improve GitHub repository selection and styling +- *(database)* Implement two-step confirmation for database deletion +- *(assets)* Add new SVG logo for Coolify +- *(install)* Enhance Docker address pool configuration and validation +- *(install)* Improve Docker address pool management and service restart logic +- *(install)* Add missing env variable to install script +- *(LocalFileVolume)* Add binary file detection and update UI logic +- *(templates)* Change glance for v0.7 +- *(templates)* Add Freescout service template +- *(service)* Add Evolution API template +- *(service)* Add evolution-api and neon-ws-proxy templates +- *(svg)* Add coolify and evolution-api SVG logos +- *(api)* Add api to create custom services +- *(api)* Separate create and one-click routes +- *(api)* Update Services api routes and handlers +- *(api)* Unify service creation endpoint and enhance validation +- *(notifications)* Add discord ping functionality and settings +- *(user)* Implement session deletion on password reset +- *(github)* Enhance repository loading and validation in applications + +### 🐛 Bug Fixes + +- *(api)* Docker compose based apps creationg through api +- *(database)* Improve database type detection for Supabase Postgres images +- *(ssl)* Permission of ssl crt and key inside the container +- *(ui)* Make sure file mounts do not showing the encrypted values +- *(ssl)* Make default ssl mode require not verify-full as it does not need a ca cert +- *(ui)* Select component should not always uses title case +- *(db)* SSL certificates table and model +- *(migration)* Ssl certificates table +- *(databases)* Fix database name users new `uuid` instead of DB one +- *(database)* Fix volume and file mounts and naming +- *(migration)* Store subjectAlternativeNames as a json array in the db +- *(ssl)* Make sure the subjectAlternativeNames are unique and stored correctly +- *(ui)* Certificate expiration data is null before starting the DB +- *(deletion)* Fix DB deletion +- *(ssl)* Improve SSL cert file mounts +- *(ssl)* Always create ca crt on disk even if it is already there +- *(ssl)* Use mountPath parameter not a hardcoded path +- *(ssl)* Use 1 instead of on for mysql +- *(ssl)* Do not remove SSL directory +- *(ssl)* Wrong ssl cert is loaded to the server and UI error when regenerating SSL +- *(ssl)* Make sure when regenerating the CA cert it is not overwritten with a server cert +- *(ssl)* Regenerating certs for a specific DB +- *(ssl)* Fix MariaDB and MySQL need CA cert +- *(ssl)* Add mount path to DB to fix regeneration of certs +- *(ssl)* Fix SSL regeneration to sign with CA cert and use mount path +- *(ssl)* Get caCert correctly +- *(ssl)* Remove caCert even if it is a folder by accident +- *(ssl)* Ger caCert and `mountPath` correctly +- *(ui)* Only show Regenerate SSL Certificates button when there is a cert +- *(ssl)* Server id +- *(ssl)* When regenerating SSL certs the cert is not singed with the new CN +- *(ssl)* Adjust ca paths for MySQL +- *(ssl)* Remove mode selection for MariaDB as it is not supported +- *(ssl)* Permission issue with MariDB cert and key and paths +- *(ssl)* Rename Redis mode to verify-ca as it is not verify-full +- *(ui)* Remove unused mode for MongoDB +- *(ssl)* KeyDB port and caCert args are missing +- *(ui)* Enable SSL is not working correctly for KeyDB +- *(ssl)* Add `--tls` arg to DrangflyDB +- *(notification)* Always send SSL notifications +- *(database)* Change default value of enable_ssl to false for multiple tables +- *(ui)* Correct grammatical error in 404 page +- *(seeder)* Update GitHub app name in GithubAppSeeder +- *(plane)* Update APP_RELEASE to v0.25.2 in environment configuration +- *(domain)* Dispatch refreshStatus event after successful domain update +- *(database)* Correct container name generation for service databases +- *(database)* Limit container name length for database proxy +- *(database)* Handle unsupported database types in StartDatabaseProxy +- *(database)* Simplify container name generation in StartDatabaseProxy +- *(install)* Handle potential errors in Docker address pool configuration +- *(backups)* Retention settings +- *(redis)* Set default redis_username for new instances +- *(core)* Improve instantSave logic and error handling +- *(general)* Correct link to framework specific documentation +- *(core)* Redirect healthcheck route for dockercompose applications +- *(api)* Use name from request payload +- *(issue#4746)* Do not use setGitImportSettings inside of generateGitLsRemoteCommands +- Correct some spellings +- *(service)* Replace deprecated credentials env variables on keycloak service +- *(keycloak)* Update keycloak image version to 26.1 +- *(console)* Handle missing root user in password reset command +- *(ssl)* Handle missing CA certificate in SSL regeneration job +- *(copy-button)* Ensure text is safely passed to clipboard + +### 💼 Other + +- Bump Coolify to 4.0.0-beta.400 +- *(migration)* Add SSL fields to database tables +- SSL Support for KeyDB + +### 🚜 Refactor + +- *(ui)* Unhide log toggle in application settings +- *(nginx)* Streamline default Nginx configuration and improve error handling +- *(install)* Clean up install script and enhance Docker installation logic +- *(ScheduledTask)* Clean up code formatting and remove unused import +- *(app)* Remove unused MagicBar component and related code +- *(database)* Streamline SSL configuration handling across database types +- *(application)* Streamline healthcheck parsing from Dockerfile +- *(notifications)* Standardize getRecipients method signatures +- *(configuration)* Centralize configuration management in ConfigurationRepository +- *(docker)* Update image references to use centralized registry URL +- *(env)* Add centralized registry URL to environment configuration +- *(storage)* Simplify file storage iteration in Blade template +- *(models)* Add is_directory attribute to LocalFileVolume model +- *(modal)* Add ignoreWire attribute to modal-confirmation component +- *(invite-link)* Adjust layout for better responsiveness in form +- *(invite-link)* Enhance form layout for improved responsiveness +- *(network)* Enhance docker network creation with ipv6 fallback +- *(network)* Check for existing coolify network before creation +- *(database)* Enhance encryption process for local file volumes + +### 📚 Documentation + +- Update changelog +- Update changelog +- *(CONTRIBUTING)* Add note about Laravel Horizon accessibility +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(migration)* Remove unused columns +- *(ssl)* Improve code in ssl helper +- *(migration)* Ssl cert and key should not be nullable +- *(ssl)* Rename CA cert to `coolify-ca.crt` because of conflicts +- Rename ca crt folder to ssl +- *(ui)* Improve valid until handling +- Improve code quality suggested by code rabbit +- *(supabase)* Update Supabase service template and Postgres image version +- *(versions)* Update version numbers for coolify and nightly + +## [4.0.0-beta.398] - 2025-03-01 + +### 🚀 Features + +- *(billing)* Add Stripe past due subscription status tracking +- *(ui)* Add past due subscription warning banner + +### 🐛 Bug Fixes + +- *(billing)* Restrict Stripe subscription status update to 'active' only + +### 💼 Other + +- Bump Coolify to 4.0.0-beta.398 + +### 🚜 Refactor + +- *(billing)* Enhance Stripe subscription status handling and notifications + +## [4.0.0-beta.397] - 2025-02-28 + +### 🐛 Bug Fixes + +- *(billing)* Handle 'past_due' subscription status in Stripe processing +- *(revert)* Label parsing +- *(helpers)* Initialize command variable in parseCommandFromMagicEnvVariable + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.396] - 2025-02-28 + +### 🚀 Features + +- *(ui)* Add wire:key to two-step confirmation settings +- *(database)* Add index to scheduled task executions for improved query performance +- *(database)* Add index to scheduled database backup executions + +### 🐛 Bug Fixes + +- *(core)* Production dockerfile +- *(ui)* Update storage configuration guidance link +- *(ui)* Set default SMTP encryption to starttls +- *(notifications)* Correct environment URL path in application notifications +- *(config)* Update default PostgreSQL host to coolify-db instead of postgres +- *(docker)* Improve Docker compose file validation process +- *(ui)* Restrict service retrieval to current team +- *(core)* Only validate custom compose files +- *(mail)* Set default mailer to array when not specified +- *(ui)* Correct redirect routes after task deletion +- *(core)* Adding a new server should not try to make the default docker network +- *(core)* Clean up unnecessary files during application image build +- *(core)* Improve label generation and merging for applications and services + +### 💼 Other + +- Bump all dependencies (#5216) + +### 🚜 Refactor + +- *(ui)* Simplify file storage modal confirmations +- *(notifications)* Improve transactional email settings handling +- *(scheduled-tasks)* Improve scheduled task creation and management + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Bump helper and realtime version + +## [4.0.0-beta.395] - 2025-02-22 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.394] - 2025-02-17 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.393] - 2025-02-15 + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.392] - 2025-02-13 + +### 🚀 Features + +- *(ui)* Add top padding to pricing plans view +- *(core)* Add error logging and cron parsing to docker/server schedules +- *(core)* Prevent using servers with existing resources as build servers +- *(ui)* Add textarea switching option in service compose editor + +### 🐛 Bug Fixes + +- Pull latest image from registry when using build server +- *(deployment)* Improve server selection for deployment cancellation +- *(deployment)* Improve log line rendering and formatting +- *(s3-storage)* Optimize team admin notification query +- *(core)* Improve connection testing with dynamic disk configuration for s3 backups +- *(core)* Update service status refresh event handling +- *(ui)* Adjust polling intervals for database and service status checks +- *(service)* Update Fider service template healthcheck command +- *(core)* Improve server selection error handling in Docker component +- *(core)* Add server functionality check before dispatching container status +- *(ui)* Disable sticky scroll in Monaco editor +- *(ui)* Add literal and multiline env support to services. +- *(services)* Owncloud docs link +- *(template)* Remove db-migration step from `infisical.yaml` (#5209) +- *(service)* Penpot (#5047) + +### 🚜 Refactor + +- Use pull flag on docker compose up + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Rollback Coolify version to 4.0.0-beta.392 +- Bump Coolify version to 4.0.0-beta.393 +- Bump Coolify version to 4.0.0-beta.394 +- Bump Coolify version to 4.0.0-beta.395 +- Bump Coolify version to 4.0.0-beta.396 +- *(services)* Update zipline to use new Database env var. (#5210) +- *(service)* Upgrade authentik service +- *(service)* Remove unused env from zipline + +## [4.0.0-beta.391] - 2025-02-04 + +### 🚀 Features + +- Add application api route +- Container logs +- Remove ansi color from log +- Add lines query parameter +- *(changelog)* Add git cliff for automatic changelog generation +- *(workflows)* Improve changelog generation and workflows +- *(ui)* Add periodic status checking for services +- *(deployment)* Ensure private key is stored in filesystem before deployment +- *(slack)* Show message title in notification previews (#5063) +- *(i18n)* Add Arabic translations (#4991) +- *(i18n)* Add French translations (#4992) +- *(services)* Update `service-templates.json` + +### 🐛 Bug Fixes + +- *(core)* Improve deployment failure Slack notification formatting +- *(core)* Update Slack notification formatting to use bold correctly +- *(core)* Enhance Slack deployment success notification formatting +- *(ui)* Simplify service templates loading logic +- *(ui)* Align title and add button vertically in various views +- Handle pullrequest:updated for reliable preview deployments +- *(ui)* Fix typo on team page (#5105) +- Cal.com documentation link give 404 (#5070) +- *(slack)* Notification settings URL in `HighDiskUsage` message (#5071) +- *(ui)* Correct typo in Storage delete dialog (#5061) +- *(lang)* Add missing italian translations (#5057) +- *(service)* Improve duplicati.yaml (#4971) +- *(service)* Links in homepage service (#5002) +- *(service)* Added SMTP credentials to getoutline yaml template file (#5011) +- *(service)* Added `KEY` Variable to Beszel Template (#5021) +- *(cloudflare-tunnels)* Dead links to docs (#5104) +- System-wide GitHub apps (#5114) + +### 🚜 Refactor + +- Simplify service start and restart workflows + +### 📚 Documentation + +- *(services)* Reword nitropage url and slogan +- *(readme)* Add Convex to special sponsors section +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(config)* Increase default PHP memory limit to 256M +- Add openapi response +- *(workflows)* Make naming more clear and remove unused code +- Bump Coolify version to 4.0.0-beta.392/393 +- *(ci)* Update changelog generation workflow to target 'next' branch +- *(ci)* Update changelog generation workflow to target main branch + +## [4.0.0-beta.390] - 2025-01-28 + +### 🚀 Features + +- *(template)* Add Open Web UI +- *(templates)* Add Open Web UI service template +- *(ui)* Update GitHub source creation advanced section label +- *(core)* Add dynamic label reset for application settings +- *(ui)* Conditionally enable advanced application settings based on label readonly status +- *(env)* Added COOLIFY_RESOURCE_UUID environment variable +- *(vite)* Add Cloudflare async script and style tag attributes +- *(meta)* Add comprehensive SEO and social media meta tags +- *(core)* Add name to default proxy configuration + +### 🐛 Bug Fixes + +- *(ui)* Update database control UI to check server functionality before displaying actions +- *(ui)* Typo in upgrade message +- *(ui)* Cloudflare tunnel configuration should be an info, not a warning +- *(s3)* DigitalOcean storage buckets do not work +- *(ui)* Correct typo in container label helper text +- Disable certain parts if readonly label is turned off +- Cleanup old scheduled_task_executions +- Validate cron expression in Scheduled Task update +- *(core)* Check cron expression on save +- *(database)* Detect more postgres database image types +- *(templates)* Update service templates +- Remove quotes in COOLIFY_CONTAINER_NAME +- *(templates)* Update Trigger.dev service templates with v3 configuration +- *(database)* Adjust MongoDB restore command and import view styling +- *(core)* Improve public repository URL parsing for branch and base directory +- *(core)* Increase HTTP/2 max concurrent streams to 250 (default) +- *(ui)* Update docker compose file helper text to clarify repository modification +- *(ui)* Skip SERVICE_FQDN and SERVICE_URL variables during update +- *(core)* Stopping database is not disabling db proxy +- *(core)* Remove --remove-orphans flag from proxy startup command to prevent other proxy deletions (db) +- *(api)* Domain check when updating domain +- *(ui)* Always redirect to dashboard after team switch +- *(backup)* Escape special characters in database backup commands + +### 💼 Other + +- Trigger.dev templates - wrong key length issue +- Trigger.dev template - missing ports and wrong env usage +- Trigger.dev template - fixed otel config +- Trigger.dev template - fixed otel config +- Trigger.dev template - fixed port config + +### 🚜 Refactor + +- *(s3)* Improve S3 bucket endpoint formatting +- *(vite)* Improve environment variable handling in Vite configuration +- *(ui)* Simplify GitHub App registration UI and layout + +### ⚙️ Miscellaneous Tasks + +- *(version)* Bump Coolify version to 4.0.0-beta.391 + +### ◀️ Revert + +- Remove Cloudflare async tag attributes + +## [4.0.0-beta.389] - 2025-01-23 + +### 🚀 Features + +- *(docs)* Update tech stack +- *(terminal)* Show terminal unavailable if the container does not have a shell on the global terminal UI +- *(ui)* Improve deployment UI + +### 🐛 Bug Fixes + +- *(service)* Infinite loading and lag with invoiceninja service (#4876) +- *(service)* Invoiceninja service +- *(workflows)* `Waiting for changes` label should also be considered and improved messages +- *(workflows)* Remove tags only if the PR has been merged into the main branch +- *(terminal)* Terminal shows that it is not available, even though it is +- *(labels)* Docker labels do not generated correctly +- *(helper)* Downgrade Nixpacks to v1.29.0 +- *(labels)* Generate labels when they are empty not when they are already generated +- *(storage)* Hetzner storage buckets not working + +### 📚 Documentation + +- Add TECH_STACK.md (#4883) + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update coolify versions to v4.0.0-beta.389 +- *(core)* EnvironmentVariable Model now extends BaseModel to remove duplicated code +- *(versions)* Update coolify versions to v4.0.0-beta.3909 + +## [4.0.0-beta.388] - 2025-01-22 + +### 🚀 Features + +- *(core)* Add SOURCE_COMMIT variable to build environment in ApplicationDeploymentJob +- *(service)* Update affine.yaml with AI environment variables (#4918) +- *(service)* Add new service Flipt (#4875) + +### 🐛 Bug Fixes + +- *(core)* Update environment variable generation logic in ApplicationDeploymentJob to handle different build packs +- *(env)* Shared variables can not be updated +- *(ui)* Metrics stuck in loading state +- *(ui)* Use `wire:navigate` to navigate to the server settings page +- *(service)* Plunk API & health check endpoint (#4925) + +## [4.0.0-beta.386] - 2025-01-22 + +### 🐛 Bug Fixes + +- *(redis)* Update environment variable keys from standalone_redis_id to resourceable_id +- *(routes)* Local API docs not available on domain or IP +- *(routes)* Local API docs not available on domain or IP +- *(core)* Update application_id references to resourable_id and resourable_type for Nixpacks configuration +- *(core)* Correct spelling of 'resourable' to 'resourceable' in Nixpacks configuration for ApplicationDeploymentJob +- *(ui)* Traefik dashboard url not working +- *(ui)* Proxy status badge flashing during navigation + +### 🚜 Refactor + +- *(workflows)* Replace jq with PHP script for version retrieval in workflows + +### ⚙️ Miscellaneous Tasks + +- *(dep)* Bump helper version to 1.0.5 +- *(docker)* Add blank line for readability in Dockerfile +- *(versions)* Update coolify versions to v4.0.0-beta.388 +- *(versions)* Update coolify versions to v4.0.0-beta.389 and add helper version retrieval script + +## [4.0.0-beta.385] - 2025-01-21 + +### 🚀 Features + +- *(core)* Wip version of coolify.json + +### 🐛 Bug Fixes + +- *(email)* Transactional email sending +- *(ui)* Add missing save button for new Docker Cleanup page +- *(ui)* Show preview deployment environment variables +- *(ui)* Show error on terminal if container has no shell (bash/sh) +- *(parser)* Resource URL should only be parsed if there is one +- *(core)* Compose parsing for apps + +### ⚙️ Miscellaneous Tasks + +- *(dep)* Bump nixpacks version +- *(dep)* Version++ + +## [4.0.0-beta.384] - 2025-01-21 + +### 🐛 Bug Fixes + +- *(ui)* Backups link should not redirected to general +- Envs with special chars during build +- *(db)* `finished_at` timestamps are not set for existing deployments +- Load service templates on cloud + +## [4.0.0-beta.383] - 2025-01-20 + +### 🐛 Bug Fixes + +- *(service)* Add healthcheck to Cloudflared service (#4859) +- Remove wire:navigate from import backups + +## [4.0.0-beta.382] - 2025-01-17 + +### 🚀 Features + +- Add log file check message in upgrade script for better troubleshooting +- Add root user details to install script + +### 🐛 Bug Fixes + +- Create the private key before the server in the prod seeder +- Update ProductionSeeder to check for private key instead of server's private key +- *(ui)* Missing underline for docs link in the Swarm section (#4860) +- *(service)* Change chatwoot service postgres image from `postgres:12` to `pgvector/pgvector:pg12` +- Docker image parser +- Add public key attribute to privatekey model +- Correct service update logic in Docker Compose parser +- Update CDN URL in install script to point to nightly version + +### 🚜 Refactor + +- Comment out RootUserSeeder call in ProductionSeeder for clarity +- Streamline ProductionSeeder by removing debug logs and unnecessary checks, while ensuring essential seeding operations remain intact +- Remove debug echo statements from Init command to clean up output and improve readability + +## [4.0.0-beta.381] - 2025-01-17 + +### 🚀 Features + +- Able to import full db backups for pg/mysql/mariadb +- Restore backup from server file +- Docker volume data cloning +- Move volume data cloning to a Job +- Volume cloning for ResourceOperations +- Remote server volume cloning +- Add horizon server details to queue +- Enhance horizon:manage command with worker restart check +- Add is_coolify_host to the server api responses +- DB migration for Backup retention +- UI for backup retention settings +- New global s3 and local backup deletion function +- Use new backup deletion functions +- Add calibre-web service +- Add actual-budget service +- Add rallly service +- Template for Gotenberg, a Docker-powered stateless API for PDF files +- Enhance import command options with additional guidance and improved checkbox label +- Purify for better sanitization +- Move docker cleanup to its own tab +- DB and Model for docker cleanup executions +- DockerCleanupExecutions relationship +- DockerCleanupDone event +- Get command and output for logs from CleanupDocker +- New sidebar menu and order +- Docker cleanup executions UI +- Add execution log to dockerCleanupJob +- Improve deployment UI +- Root user envs and seeding +- Email, username and password validation when they are set via envs +- Improved error handling and log output +- Add root user configuration variables to production environment + +### 🐛 Bug Fixes + +- Compose envs +- Scheduled tasks and backups are executed by server timezone. +- Show backup timezone on the UI +- Disappearing UI after livewire event received +- Add default vector db for anythingllm +- We need XSRF-TOKEN for terminal +- Prevent default link behavior for resource and settings actions in dashboard +- Increase default php memory limit +- Show if only build servers are added to your team +- Update Livewire button click method to use camelCase +- Local dropzonejs +- Import backups due to js stuff should not be navigated +- Install inetutils on Arch Linux +- Use ip in place of hostname from inetutils in arch +- Update import command to append file redirection for database restoration +- Ui bug on pw confirmation +- Exclude system and computed fields from model replication +- Service cloning on a separate server +- Application cloning +- `Undefined variable $fs_path` for databases +- Service and database cloning and label generation +- Labels and URL generation when cloning +- Clone naming for different database data volumes +- Implement all the cloneMe changes for ResourceOperations as well +- Volume and fileStorages cloning +- View text and helpers +- Teable +- Trigger with external db +- Set `EXPERIMENTAL_FEATURES` to false for labelstudio +- Monaco editor disabled state +- Edge case where executions could be null +- Create destination properly +- Getcontainer status should timeout after 30s +- Enable response for temporary unavailability in sentinel push endpoint +- Use timeout in cleanup resources +- Add timeout to sentinel process checks for improved reliability +- Horizon job checker +- Update response message for sentinel push route +- Add own servers on cloud +- Application deployment +- Service update statsu +- If $SERVICE found in the service specific configuration, then search for it in the db +- Instance wide GitHub apps are not available on other teams then the source team +- Function calls +- UI +- Deletion of single backup +- Backup job deletion - delete all backups from s3 and local +- Use new removeOldBackups function +- Retention functions and folder deletion for local backups +- Storage retention setting +- Db without s3 should still backup +- Wording +- `Undefined variable $service` when creating a new service +- Nodebb service +- Calibre-web service +- Rallly and actualbudget service +- Removed container_name +- Added healthcheck for gotenberg template +- Gotenberg +- *(template)* Gotenberg healthcheck, use /health instead of /version +- Use wire:navigate on sidebar +- Use wire:navigate on dashboard +- Use wire:navigate on projects page +- More wire:navigate +- Even more wire:navigate +- Service navigation +- Logs icons everywhere + terminal +- Redis DB should use the new resourceable columns +- Joomla service +- Add back letters to prod password requirement +- Check System and GitHub time and throw and error if it is over 50s out of sync +- Error message and server time getting +- Error rendering +- Render html correctly now +- Indent +- Potential fix for permissions update +- Expiration time claim ('exp') must be a numeric value +- Sanitize html error messages +- Production password rule and cleanup code +- Use json as it is just better than string for huge amount of logs +- Use `wire:navigate` on server sidebar +- Use finished_at for the end time instead of created_at +- Cancelled deployments should not show end and duration time +- Redirect to server index instead of show on error in Advanced and DockerCleanup components +- Disable registration after creating the root user +- RootUserSeeder +- Regex username validation +- Add spacing around echo outputs +- Success message +- Silent return if envs are empty or not set. + +### 💼 Other + +- Arrrrr +- Dep +- Docker dep + +### 🚜 Refactor + +- Rename parameter in DatabaseBackupJob for clarity +- Improve checkbox component accessibility and styling +- Remove unused tags method from ApplicationDeploymentJob +- Improve deployment status check in isAnyDeploymentInprogress function +- Extend HorizonServiceProvider from HorizonApplicationServiceProvider +- Streamline job status retrieval and clean up repository interface +- Enhance ApplicationDeploymentJob and HorizonServiceProvider for improved job handling +- Remove commented-out unsubscribe route from API +- Update redirect calls to use a consistent navigation method in deployment functions +- AppServiceProvider +- Github.php +- Improve data formatting and UI + +### ⚙️ Miscellaneous Tasks + +- Improve Penpot healthchecks +- Switch up readonly lables to make more sense +- Remove unused computed fields +- Use the new job dispatch +- Disable volume data cloning for now +- Improve code +- Lowcoder service naming +- Use new functions +- Improve error styling +- Css +- More css as it still looks like shit +- Final css touches +- Ajust time to 50s (tests done) +- Remove debug log, finally found it +- Remove more logging +- Remove limit on commit message +- Remove dayjs +- Remove unused code and fix import + +## [4.0.0-beta.380] - 2024-12-27 + +### 🚀 Features + +- New ServerReachabilityChanged event +- Use new ServerReachabilityChanged event instead of isDirty +- Add infomaniak oauth +- Add server disk usage check frequency +- Add environment_uuid support and update API documentation +- Add service/resource/project labels +- Add coolify.environment label +- Add database subtype +- Migrate to new encryption options +- New encryption options + +### 🐛 Bug Fixes + +- Render html on error page correctly +- Invalid API response on missing project +- Applications API response code + schema +- Applications API writing to unavailable models +- If an init script is renamed the old version is still on the server +- Oauthseeder +- Compose loading seq +- Resource clone name + volume name generation +- Update Dockerfile entrypoint path to /etc/entrypoint.d +- Debug mode +- Unreachable notifications +- Remove duplicated ServerCheckJob call +- Few fixes and use new ServerReachabilityChanged event +- Use serverStatus not just status +- Oauth seeder +- Service ui structure +- Check port 8080 and fallback to 80 +- Refactor database view +- Always use docker cleanup frequency +- Advanced server UI +- Html css +- Fix domain being override when update application +- Use nixpacks predefined build variables, but still could update the default values from Coolify +- Use local monaco-editor instead of Cloudflare +- N8n timezone +- Smtp encryption +- Bind() to 0.0.0.0:80 failed +- Oauth seeder +- Unreachable notifications +- Instance settings migration +- Only encrypt instance email settings if there are any +- Error message +- Update healthcheck and port configurations to use port 8080 + +### 🚜 Refactor + +- Rename `coolify.environment` to `coolify.environmentName` + +### ⚙️ Miscellaneous Tasks + +- Regenerate API spec, removing notification fields +- Remove ray debugging +- Version ++ + +## [4.0.0-beta.378] - 2024-12-13 + +### 🐛 Bug Fixes + +- Monaco editor light and dark mode switching +- Service status indicator + oauth saving +- Socialite for azure and authentik +- Saving oauth +- Fallback for copy button +- Copy the right text +- Maybe fallback is now working +- Only show copy button on secure context + +## [4.0.0-beta.377] - 2024-12-13 + +### 🚀 Features + +- Add deploy-only token permission +- Able to deploy without cache on every commit +- Update private key nam with new slug as well +- Allow disabling default redirect, set status to 503 +- Add TLS configuration for default redirect in Server model +- Slack notifications +- Introduce root permission +- Able to download schedule task logs +- Migrate old email notification settings from the teams table +- Migrate old discord notification settings from the teams table +- Migrate old telegram notification settings from the teams table +- Add slack notifications to a new table +- Enable success messages again +- Use new notification stuff inside team model +- Some more notification settings and better defaults +- New email notification settings +- New shared function name `is_transactional_emails_enabled()` +- New shared notifications functions +- Email Notification Settings Model +- Telegram notification settings Model +- Discord notification settings Model +- Slack notification settings Model +- New Discord notification UI +- New Slack notification UI +- New telegram UI +- Use new notification event names +- Always sent notifications +- Scheduled task success notification +- Notification trait +- Get discord Webhook form new table +- Get Slack Webhook form new table +- Use new table or instance settings for email +- Use new place for settings and topic IDs for telegram +- Encrypt instance email settings +- Use encryption in instance settings model +- Scheduled task success and failure notifications +- Add docker cleanup success and failure notification settings columns +- UI for docker cleanup success and failure notification +- Docker cleanup email views +- Docker cleanup success and failure notification files +- Scheduled task success email +- Send new docker cleanup notifications +- :passport_control: integrate Authentik authentication with Coolify +- *(notification)* Add Pushover +- Add seeder command and configuration for database seeding +- Add new password magic env with symbols +- Add documenso service + +### 🐛 Bug Fixes + +- Resolve undefined searchInput reference in Alpine.js component +- URL and sync new app name +- Typos and naming +- Client and webhook secret disappear after sync +- Missing `mysql_password` API property +- Incorrect MongoDB init API property +- Old git versions does not have --cone implemented properly +- Don't allow editing traefik config +- Restart proxy +- Dev mode +- Ui +- Display actual values for disk space checks in installer script +- Proxy change behaviour +- Add warning color +- Import NotificationSlack correctly +- Add middleware to new abilities, better ux for selecting permissions, etc. +- Root + read:sensive could read senstive data with a middlewarew +- Always have download logs button on scheduled tasks +- Missing css +- Development image +- Dockerignore +- DB migration error +- Drop all unused smtp columns +- Backward compatibility +- Email notification channel enabled function +- Instance email settins +- Make sure resend is false if SMTP is true and vice versa +- Email Notification saving +- Slack and discord url now uses text filed because encryption makes the url very long +- Notification trait +- Encryption fixes +- Docker cleanup email template +- Add missing deployment notifications to telegram +- New docker cleanup settings are now saved to the DB correctly +- Ui + migrations +- Docker cleanup email notifications +- General notifications does not go through email channel +- Test notifications to only send it to the right channel +- Remove resale_license from db as well +- Nexus service +- Fileflows volume names +- --cone +- Provider error +- Database migration +- Seeder +- Migration call +- Slack helper +- Telegram helper +- Discord helper +- Telegram topic IDs +- Make pushover settings more clear +- Typo in pushover user key +- Use Livewire refresh method and lock properties +- Create pushover settings for existing teams +- Update token permission check from 'write' to 'root' +- Pushover +- Oauth seeder +- Correct heading display for OAuth settings in settings-oauth.blade.php +- Adjust spacing in login form for improved layout +- Services env values should be sensitive +- Documenso +- Dolibarr +- Typo +- Update OauthSettingSeeder to handle new provider definitions and ensure authentik is recreated if missing +- Improve OauthSettingSeeder to correctly delete non-existent providers and ensure proper handling of provider definitions +- Encrypt resend API key in instance settings +- Resend api key is already a text column + +### 💼 Other + +- Test rename GitHub app +- Checkmate service and fix prowlar slogan (too long) + +### 🚜 Refactor + +- Update Traefik configuration for improved security and logging +- Improve proxy configuration and code consistency in Server model +- Rename name method to sanitizedName in BaseModel for clarity +- Improve migration command and enhance application model with global scope and status checks +- Unify notification icon +- Remove unused Azure and Authentik service configurations from services.php +- Change email column types in instance_settings migration from string to text +- Change OauthSetting creation to updateOrCreate for better handling of existing records + +### ⚙️ Miscellaneous Tasks + +- Regenerate openapi spec +- Composer dep bump +- Dep bump +- Upgrade cloudflared and minio +- Remove comments and improve DB column naming +- Remove unused seeder +- Remove unused waitlist stuff +- Remove wired.php (not used anymore) +- Remove unused resale license job +- Remove commented out internal notification +- Remove more waitlist stuff +- Remove commented out notification +- Remove more waitlist stuff +- Remove unused code +- Fix typo +- Remove comment out code +- Some reordering +- Remove resale license reference +- Remove functions from shared.php +- Public settings for email notification +- Remove waitlist redirect +- Remove log +- Use new notification trait +- Remove unused route +- Remove unused email component +- Comment status changes as it is disabled for now +- Bump dep +- Reorder navbar +- Rename topicID to threadId like in the telegram API response +- Update PHP configuration to set memory limit using environment variable + +## [4.0.0-beta.376] - 2024-12-07 + +### 🐛 Bug Fixes + +- Api endpoint + +## [4.0.0-beta.374] - 2024-12-03 + +### 🐛 Bug Fixes + +- Application view loading +- Postiz service +- Only able to select the right keys +- Test email should not be required +- A few inputs + +### 🧪 Testing + +- Setup database for upcoming tests + +## [4.0.0-beta.372] - 2024-11-26 + +### 🚀 Features + +- Add MacOS template +- Add Windows template +- *(service)* :sparkles: add mealie +- Add hex magic env var + +### 🐛 Bug Fixes + +- Service generate includes yml files as well (haha) +- ServercheckJob should run every 5 minutes on cloud +- New resource icons +- Search should be more visible on scroll on new resource +- Logdrain settings +- Ui +- Email should be retried with backoff +- Alpine in body layout + +### 💼 Other + +- Caddy docker labels do not honor "strip prefix" option + +## [4.0.0-beta.371] - 2024-11-22 + +### 🐛 Bug Fixes + +- Improve helper text for metrics input fields +- Refine helper text for metrics input fields +- If mux conn fails, still use it without mux + save priv key with better logic +- Migration +- Always validate ssh key +- Make sure important jobs/actions are running on high prio queue +- Do not send internal notification for backups and status jobs +- Validateconnection +- View issue +- Heading +- Remove mux cleanup +- Db backup for services +- Version should come from constants + fix stripe webhook error reporting +- Undefined variable +- Remove version.php as everything is coming from constants.php +- Sentry error +- Websocket connections autoreconnect +- Sentry error +- Sentry +- Empty server API response +- Incorrect server API patch response +- Missing `uuid` parameter on server API patch +- Missing `settings` property on servers API +- Move servers API `delete_unused_*` properties +- Servers API returning `port` as a string -> integer +- Only return server uuid on server update + +## [4.0.0-beta.370] - 2024-11-15 + +### 🐛 Bug Fixes + +- Modal (+ add) on dynamic config was not opening, removed x-cloak +- AUTOUPDATE + checkbox opacity + +## [4.0.0-beta.369] - 2024-11-15 + +### 🐛 Bug Fixes + +- Modal-input + +## [4.0.0-beta.368] - 2024-11-15 + +### 🚀 Features + +- Check local horizon scheduler deployments +- Add internal api docs to /docs/api with auth +- Add proxy type change to create/update apis + +### 🐛 Bug Fixes + +- Show proper error message on invalid Git source +- Convert HTTP to SSH source when using deploy key on GitHub +- Cloud + stripe related +- Terminal view loading in async +- Cool 500 error (thanks hugodos) +- Update schema in code decorator +- Openapi docs +- Add tests for git url converts +- Minio / logto url generation +- Admin view +- Min docker version 26 +- Pull latest service-templates.json on init +- Workflow files for coolify build +- Autocompletes +- Timezone settings validation +- Invalid tz should not prevent other jobs to be executed +- Testing-host should be built locally +- Poll with modal issue +- Terminal opening issue +- If service img not found, use github as a source +- Fallback to local coolify.png +- Gather private ips +- Cf tunnel menu should be visible when server is not validated +- Deployment optimizations +- Init script + optimize laravel +- Default docker engine version + fix install script +- Pull helper image on init +- SPA static site default nginx conf + +### 💼 Other + +- Https://github.com/coollabsio/coolify/issues/4186 +- Separate resources by type in projects view +- Improve s3 add view + +### ⚙️ Miscellaneous Tasks + +- Update dep + +## [4.0.0-beta.365] - 2024-11-11 + +### 🚀 Features + +- Custom nginx configuration for static deployments + fix 404 redirects in nginx conf + +### 🐛 Bug Fixes + +- Trigger.dev db host & sslmode=disable +- Manual update should be executed only once + better UX +- Upgrade.sh +- Missing privateKey + +## [4.0.0-beta.364] - 2024-11-08 + +### 🐛 Bug Fixes + +- Define separate volumes for mattermost service template +- Github app name is too long +- ServerTimezone update + +### ⚙️ Miscellaneous Tasks + +- Edit www helper + +## [4.0.0-beta.363] - 2024-11-08 + +### 🚀 Features + +- Add Firefox template +- Add template for Wiki.js +- Add upgrade logs to /data/coolify/source + +### 🐛 Bug Fixes + +- Saving resend api key +- Wildcard domain save +- Disable cloudflare tunnel on "localhost" + +## [4.0.0-beta.362] - 2024-11-08 + +### 🐛 Bug Fixes + +- Notifications ui +- Disable wire:navigate +- Confirmation Settings css for light mode +- Server wildcard + +## [4.0.0-beta.361] - 2024-11-08 + +### 🚀 Features + +- Add Transmission template +- Add transmission healhcheck +- Add zipline template +- Dify template +- Required envs +- Add EdgeDB +- Show warning if people would like to use sslip with https +- Add is shared to env variables +- Variabel sync and support shared vars +- Add notification settings to server_disk_usage +- Add coder service tamplate and logo +- Debug mode for sentinel +- Add jitsi template +- Add --gpu support for custom docker command + +### 🐛 Bug Fixes + +- Make sure caddy is not removed by cleanup +- Libretranslate +- Do not allow to change number of lines when streaming logs +- Plunk +- No manual timezones +- Helper push +- Format +- Add port metadata and Coolify magic to generate the domain +- Sentinel +- Metrics +- Generate sentinel url +- Only enable Sentinel for new servers +- Is_static through API +- Allow setting standalone redis variables via ENVs (team variables...) +- Check for username separately form password +- Encrypt all existing redis passwords +- Pull helper image on helper_version change +- Redis database user and password +- Able to update ipv4 / ipv6 instance settings +- Metrics for dbs +- Sentinel start fixed +- Validate sentinel custom URL when enabling sentinel +- Should be able to reset labels in read-only mode with manual click +- No sentinel for swarm yet +- Charts ui +- Volume +- Sentinel config changes restarts sentinel +- Disable sentinel for now +- Disable Sentinel temporarily +- Disable Sentinel temporarily for non-dev environments +- Access team's github apps only +- Admins should now invite owner +- Add experimental flag +- GenerateSentinelUrl method +- NumberOfLines could be null +- Login / register view +- Restart sentinel once a day +- Changing private key manually won't trigger a notification +- Grammar for helper +- Fix my own grammar +- Add telescope only in dev mode +- New way to update container statuses +- Only run server storage every 10 mins if sentinel is not active +- Cloud admin view +- Queries in kernel.php +- Lower case emails only +- Change emails to lowercase on init +- Do not error on update email +- Always authenticate with lowercase emails +- Dashboard refactor +- Add min/max length to input/texarea +- Remove livewire legacy from help view +- Remove unnecessary endpoints (magic) +- Transactional email livewire +- Destinations livewire refactor +- Refactor destination/docker view +- Logdrains validation +- Reworded +- Use Auth(), add new db proxy stop event refactor clickhouse view +- Add user/pw to db view +- Sort servers by name +- Keydb view +- Refactor tags view / remove obsolete one +- Send discord/telegram notifications on high job queue +- Server view refresh on validation +- ShowBoarding +- Show docker installation logs & ubuntu 24.10 notification +- Do not overlap servercheckjob +- Server limit check +- Server validation +- Clear route / view +- Only skip docker installation on 24.10 if its not installed +- For --gpus device support +- Db/service start should be on high queue +- Do not stop sentinel on Coolify restart +- Run resourceCheck after new serviceCheckJob +- Mongodb in dev +- Better invitation errors +- Loading indicator for db proxies +- Do not execute gh workflow on template changes +- Only use sentry in cloud +- Update packagejson of coolify-realtime + add lock file +- Update last online with old function +- Seeder should not start sentinel +- Start sentinel on seeder + +### 💼 Other + +- Add peppermint +- Loggy +- Add UI for redis password and username +- Wireguard-easy template + +### 📚 Documentation + +- Update link to deploy api docs + +### ⚙️ Miscellaneous Tasks + +- Add transmission template desc +- Update transmission docs link +- Update version numbers to 4.0.0-beta.360 in configuration files +- Update AWS environment variable names in unsend.yaml +- Update AWS environment variable names in unsend.yaml +- Update livewire/livewire dependency to version 3.4.9 +- Update version to 4.0.0-beta.361 +- Update Docker build and push actions to v6 +- Update Docker build and push actions to v6 +- Update Docker build and push actions to v6 +- Sync coolify-helper to dockerhub as well +- Push realtime to dockerhub +- Sync coolify-realtime to dockerhub +- Rename workflows +- Rename development to staging build +- Sync coolify-testing-host to dockerhbu +- Sync coolify prod image to dockerhub as well +- Update Docker version to 26.0 +- Update project resource index page +- Update project service configuration view + +## [4.0.0-beta.360] - 2024-10-11 + +### ⚙️ Miscellaneous Tasks + +- Update livewire/livewire dependency to version 3.4.9 + +## [4.0.0-beta.359] - 2024-10-11 + +### 🐛 Bug Fixes + +- Use correct env variable for invoice ninja password + +### ⚙️ Miscellaneous Tasks + +- Update laravel/horizon dependency to version 5.29.1 +- Update service extra fields to use dynamic keys + +## [4.0.0-beta.358] - 2024-10-10 + +### 🚀 Features + +- Add customHelper to stack-form +- Add cloudbeaver template +- Add ntfy template +- Add qbittorrent template +- Add Homebox template +- Add owncloud service and logo +- Add immich service +- Auto generate url +- Refactored to work with coolify auto env vars +- Affine service template and logo +- Add LibreTranslate template +- Open version in a new tab + +### 🐛 Bug Fixes + +- Signup +- Application domains should be http and https only +- Validate and sanitize application domains +- Sanitize and validate application domains + +### 💼 Other + +- Other DB options for freshrss +- Nextcloud MariaDB and MySQL versions + +### ⚙️ Miscellaneous Tasks + +- Fix form submission and keydown event handling in modal-confirmation.blade.php +- Update version numbers to 4.0.0-beta.359 in configuration files +- Disable adding default environment variables in shared.php + +## [4.0.0-beta.357] - 2024-10-08 + +### 🚀 Features + +- Add Mautic 4 and 5 to service templates +- Add keycloak template +- Add onedev template +- Improve search functionality in project selection + +### 🐛 Bug Fixes + +- Update mattermost image tag and add default port +- Remove env, change timezone +- Postgres healthcheck +- Azimutt template - still not working haha +- New parser with SERVICE_URL_ envs +- Improve service template readability +- Update password variables in Service model +- Scheduled database server +- Select server view + +### 💼 Other + +- Keycloak + +### ⚙️ Miscellaneous Tasks + +- Add mattermost logo as svg +- Add mattermost svg to compose +- Update version to 4.0.0-beta.357 + +## [4.0.0-beta.356] - 2024-10-07 + +### 🚀 Features + +- Add Argilla service configuration to Service model +- Add Invoice Ninja service configuration to Service model +- Project search on frontend +- Add ollama service with open webui and logo +- Update setType method to use slug value for type +- Refactor setType method to use slug value for type +- Refactor setType method to use slug value for type +- Add Supertokens template +- Add easyappointments service template +- Add dozzle template +- Adds forgejo service with runners + +### 🐛 Bug Fixes + +- Reset description and subject fields after submitting feedback +- Tag mass redeployments +- Service env orders, application env orders +- Proxy conf in dev +- One-click services +- Use local service-templates in dev +- New services +- Remove not used extra host +- Chatwoot service +- Directus +- Database descriptions +- Update services +- Soketi +- Select server view + +### 💼 Other + +- Update helper version +- Outline +- Directus +- Supertokens +- Supertokens json +- Rabbitmq +- Easyappointments +- Soketi +- Dozzle +- Windmill +- Coolify.json + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.356 +- Remove commented code for shared variable type validation +- Update MariaDB image to version 11 and fix service environment variable orders +- Update anythingllm.yaml volumes configuration +- Update proxy configuration paths for Caddy and Nginx in dev +- Update password form submission in modal-confirmation component +- Update project query to order by name in uppercase +- Update project query to order by name in lowercase +- Update select.blade.php with improved search functionality +- Add Nitropage service template and logo +- Bump coolify-helper version to 1.0.2 +- Refactor loadServices2 method and remove unused code +- Update version to 4.0.0-beta.357 +- Update service names and volumes in windmill.yaml +- Update version to 4.0.0-beta.358 +- Ignore .ignition.json files in Docker and Git + +## [4.0.0-beta.355] - 2024-10-03 + +### 🐛 Bug Fixes + +- Scheduled backup for services view +- Parser, espacing container labels + +### ⚙️ Miscellaneous Tasks + +- Update homarr service template and remove unnecessary code +- Update version to 4.0.0-beta.355 + +## [4.0.0-beta.354] - 2024-10-03 + +### 🚀 Features + +- Add it-tools service template and logo +- Add homarr service tamplate and logo + +### 🐛 Bug Fixes + +- Parse proxy config and check the set ports usage +- Update FQDN + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.354 +- Remove debug statement in Service model +- Remove commented code in Server model +- Fix application deployment queue filter logic +- Refactor modal-confirmation component +- Update it-tools service template and port configuration +- Update homarr service template and remove unnecessary code + +## [4.0.0-beta.353] - 2024-10-03 + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.353 +- Update service application view + +## [4.0.0-beta.352] - 2024-10-03 + +### 🐛 Bug Fixes + +- Service application view +- Add new supported database images + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.352 +- Refactor DatabaseBackupJob to handle missing team + +## [4.0.0-beta.351] - 2024-10-03 + +### 🚀 Features + +- Add strapi template + +### 🐛 Bug Fixes + +- Able to support more database dynamically from Coolify's UI +- Strapi template +- Bitcoin core template +- Api useBuildServer + +## [4.0.0-beta.349] - 2024-10-01 + +### 🚀 Features + +- Add command to check application deployment queue +- Support Hetzner S3 +- Handle HTTPS domain in ConfigureCloudflareTunnels +- Backup all databases for mysql,mariadb,postgresql +- Restart service without pulling the latest image + +### 🐛 Bug Fixes + +- Remove autofocuses +- Ipv6 scp should use -6 flag +- Cleanup stucked applicationdeploymentqueue +- Realtime watch in development mode +- Able to select root permission easier + +### 💼 Other + +- Show backup button on supported db service stacks + +### 🚜 Refactor + +- Remove deployment queue when deleting an application +- Improve SSH command generation in Terminal.php and terminal-server.js +- Fix indentation in modal-confirmation.blade.php +- Improve parsing of commands for sudo in parseCommandsByLineForSudo +- Improve popup component styling and button behavior +- Encode delimiter in SshMultiplexingHelper +- Remove inactivity timer in terminal-server.js +- Improve socket reconnection interval in terminal.js +- Remove unnecessary watch command from soketi service entrypoint + +### ⚙️ Miscellaneous Tasks + +- Update version numbers to 4.0.0-beta.350 in configuration files +- Update command signature and description for cleanup application deployment queue +- Add missing import for Attribute class in ApplicationDeploymentQueue model +- Update modal input in server form to prevent closing on outside click +- Remove unnecessary command from SshMultiplexingHelper +- Remove commented out code for uploading to S3 in DatabaseBackupJob +- Update soketi service image to version 1.0.3 + +## [4.0.0-beta.348] - 2024-10-01 + +### 🚀 Features + +- Update resource deletion job to allow configurable options through API +- Add query parameters for deleting configurations, volumes, docker cleanup, and connected networks + +### 🐛 Bug Fixes + +- In dev mode do not ask confirmation on delete +- Mixpost +- Handle deletion of 'hello' in confirmation modal for dev environment + +### 💼 Other + +- Server storage check + +### 🚜 Refactor + +- Update search input placeholder in resource index view + +### ⚙️ Miscellaneous Tasks + +- Fix docs link in running state +- Update Coolify Realtime workflow to only trigger on the main branch +- Refactor instanceSettings() function to improve code readability +- Update Coolify Realtime image to version 1.0.2 +- Remove unnecessary code in DatabaseBackupJob.php +- Add "Not Usable" indicator for storage items +- Refactor instanceSettings() function and improve code readability +- Update version numbers to 4.0.0-beta.349 and 4.0.0-beta.350 + +## [4.0.0-beta.347] - 2024-09-28 + +### 🚀 Features + +- Allow specify use_build_server when creating/updating an application +- Add support for `use_build_server` in API endpoints for creating/updating applications +- Add Mixpost template + +### 🐛 Bug Fixes + +- Filebrowser template +- Edit is_build_server_enabled upon creating application on other application type +- Save settings after assigning value + +### 💼 Other + +- Remove memlock as it caused problems for some users + +### ⚙️ Miscellaneous Tasks + +- Update Mailpit logo to use SVG format + +## [4.0.0-beta.346] - 2024-09-27 + +### 🚀 Features + +- Add ContainerStatusTypes enum for managing container status + +### 🐛 Bug Fixes + +- Proxy fixes +- Proxy +- *(templates)* Filebrowser FQDN env variable +- Handle edge case when build variables and env variables are in different format +- Compose based terminal + +### 💼 Other + +- Manual cleanup button and unused volumes and network deletion +- Force helper image removal +- Use the new confirmation flow +- Typo +- Typo in install script +- If API is disabeled do not show API token creation stuff +- Disable API by default +- Add debug bar + +### 🚜 Refactor + +- Update environment variable name for uptime-kuma service +- Improve start proxy script to handle existing containers gracefully +- Update delete server confirmation modal buttons +- Remove unnecessary code + +### ⚙️ Miscellaneous Tasks + +- Add autocomplete attribute to input fields +- Refactor API Tokens component to use isApiEnabled flag +- Update versions.json file +- Remove unused .env.development.example file +- Update API Tokens view to include link to Settings menu +- Update web.php to cast server port as integer +- Update backup deletion labels to use language files +- Update database startup heading title +- Update database startup heading title +- Custom vite envs +- Update version numbers to 4.0.0-beta.348 +- Refactor code to improve SSH key handling and storage + +## [4.0.0-beta.343] - 2024-09-25 + +### 🐛 Bug Fixes + +- Parser +- Exited services statuses +- Make sure to reload window if app status changes +- Deploy key based deployments + +### 🚜 Refactor + +- Remove commented out code and improve environment variable handling in newParser function +- Improve label positioning in input and checkbox components +- Group and sort fields in StackForm by service name and password status +- Improve layout and add checkbox for task enablement in scheduled task form +- Update checkbox component to support full width option +- Update confirmation label in danger.blade.php template +- Fix typo in execute-container-command.blade.php +- Update OS_TYPE for Asahi Linux in install.sh script +- Add localhost as Server if it doesn't exist and not in cloud environment +- Add localhost as Server if it doesn't exist and not in cloud environment +- Update ProductionSeeder to fix issue with coolify_key assignment +- Improve modal confirmation titles and button labels +- Update install.sh script to remove redirection of upgrade output to /dev/null +- Fix modal input closeOutside prop in configuration.blade.php +- Add support for IPv6 addresses in sslip function + +### ⚙️ Miscellaneous Tasks + +- Update version numbers to 4.0.0-beta.343 +- Update version numbers to 4.0.0-beta.344 +- Update version numbers to 4.0.0-beta.345 +- Update version numbers to 4.0.0-beta.346 + +## [4.0.0-beta.342] - 2024-09-24 + +### 🚀 Features + +- Add nullable constraint to 'fingerprint' column in private_keys table +- *(api)* Add an endpoint to execute a command +- *(api)* Add endpoint to execute a command + +### 🐛 Bug Fixes + +- Proxy status +- Coolify-db should not be in the managed resources +- Store original root key in the original location +- Logto service +- Cloudflared service +- Migrations +- Cloudflare tunnel configuration, ui, etc + +### 💼 Other + +- Volumes on development environment +- Clean new volume name for dev volumes +- Persist DBs, services and so on stored in data/coolify +- Add SSH Key fingerprint to DB +- Add a fingerprint to every private key on save, create... +- Make sure invalid private keys can not be added +- Encrypt private SSH keys in the DB +- Add is_sftp and is_server_ssh_key coloums +- New ssh key file name on disk +- Store all keys on disk by default +- Populate SSH key folder +- Populate SSH keys in dev +- Use new function names and logic everywhere +- Create a Multiplexing Helper +- SSH multiplexing +- Remove unused code form multiplexing +- SSH Key cleanup job +- Private key with ID 2 on dev +- Move more functions to the PrivateKey Model +- Add ssh key fingerprint and generate one for existing keys +- ID issues on dev seeders +- Server ID 0 +- Make sure in use private keys are not deleted +- Do not delete SSH Key from disk during server validation error +- UI bug, do not write ssh key to disk in server dialog +- SSH Multiplexing for Jobs +- SSH algorhytm text +- Few multiplexing things +- Clear mux directory +- Multiplexing do not write file manually +- Integrate tow step process in the modal component WIP +- Ability to hide labels +- DB start, stop confirm +- Del init script +- General confirm +- Preview deployments and typos +- Service confirmation +- Confirm file storage +- Stop service confirm +- DB image cleanup +- Confirm ressource operation +- Environment variabel deletion +- Confirm scheduled tasks +- Confirm API token +- Confirm private key +- Confirm server deletion +- Confirm server settings +- Proxy stop and restart confirmation +- GH app deletion confirmation +- Redeploy all confirmation +- User deletion confirmation +- Team deletion confirmation +- Backup job confirmation +- Delete volume confirmation +- More conformations and fixes +- Delete unused private keys button +- Ray error because port is not uncommented +- #3322 deploy DB alterations before updating +- Css issue with advanced settings and remove cf tunnel in onboarding +- New cf tunnel install flow +- Made help text more clear +- Cloudflare tunnel +- Make helper text more clean to use a FQDN and not an URL + +### 🚜 Refactor + +- Update Docker cleanup label in Heading.php and Navbar.php +- Remove commented out code in Navbar.php +- Remove CleanupSshKeysJob from schedule in Kernel.php +- Update getAJoke function to exclude offensive jokes +- Update getAJoke function to use HTTPS for API request +- Update CleanupHelperContainersJob to use more efficient Docker command +- Update PrivateKey model to improve code readability and maintainability +- Remove unnecessary code in PrivateKey model +- Update PrivateKey model to use ownedByCurrentTeam() scope for cleanupUnusedKeys() +- Update install.sh script to check if coolify-db volume exists before generating SSH key +- Update ServerSeeder and PopulateSshKeysDirectorySeeder +- Improve attribute sanitization in Server model +- Update confirmation button text for deletion actions +- Remove unnecessary code in shared.php file +- Update environment variables for services in compose files +- Update select.blade.php to improve trademarks policy display +- Update select.blade.php to improve trademarks policy display +- Fix typo in subscription URLs +- Add Postiz service to compose file (disabled for now) +- Update shared.php to include predefined ports for services +- Simplify SSH key synchronization logic +- Remove unused code in DatabaseBackupStatusJob and PopulateSshKeysDirectorySeeder + +### ⚙️ Miscellaneous Tasks + +- Update version numbers to 4.0.0-beta.342 +- Update remove-labels-and-assignees-on-close.yml +- Add SSH key for localhost in ProductionSeeder +- Update SSH key generation in install.sh script +- Update ProductionSeeder to call OauthSettingSeeder and PopulateSshKeysDirectorySeeder +- Update install.sh to support Asahi Linux +- Update install.sh version to 1.6 +- Remove unused middleware and uniqueId method in DockerCleanupJob +- Refactor DockerCleanupJob to remove unused middleware and uniqueId method +- Remove unused migration file for populating SSH keys and clearing mux directory +- Add modified files to the commit +- Refactor pre-commit hook to improve performance and readability +- Update CONTRIBUTING.md with troubleshooting note about database migrations +- Refactor pre-commit hook to improve performance and readability +- Update cleanup command to use Redis instead of queue +- Update Docker commands to start proxy + +## [4.0.0-beta.341] - 2024-09-18 + +### 🚀 Features + +- Add buddy logo + +## [4.0.0-beta.336] - 2024-09-16 + +### 🚀 Features + +- Make coolify full width by default +- Fully functional terminal for command center +- Custom terminal host + +### 🐛 Bug Fixes + +- Keep-alive ws connections +- Add build.sh to debug logs +- Update Coolify installer +- Terminal +- Generate https for minio +- Install script +- Handle WebSocket connection close in terminal.blade.php +- Able to open terminal to any containers +- Refactor run-command +- If you exit a container manually, it should close the underlying tty as well +- Move terminal to separate view on services +- Only update helper image in DB +- Generated fqdn for SERVICE_FQDN_APP_3000 magic envs + +### 💼 Other + +- Remove labels and assignees on issue close +- Make sure this action is also triggered on PR issue close + +### 🚜 Refactor + +- Remove unnecessary code in ExecuteContainerCommand.php +- Improve Docker network connection command in StartService.php +- Terminal / run command +- Add authorization check in ExecuteContainerCommand mount method +- Remove unnecessary code in Terminal.php +- Remove unnecessary code in Terminal.blade.php +- Update WebSocket connection initialization in terminal.blade.php +- Remove unnecessary console.log statements in terminal.blade.php + +### ⚙️ Miscellaneous Tasks + +- Update release version to 4.0.0-beta.336 +- Update coolify environment variable assignment with double quotes +- Update shared.php to fix issues with source and network variables +- Update terminal styling for better readability +- Update button text for container connection form +- Update Dockerfile and workflow for Coolify Realtime (v4) +- Remove unused entrypoint script and update volume mapping +- Update .env file and docker-compose configuration +- Update APP_NAME environment variable in docker-compose.prod.yml +- Update WebSocket URL in terminal.blade.php +- Update Dockerfile and workflow for Coolify Realtime (v4) +- Update Dockerfile and workflow for Coolify Realtime (v4) +- Update Dockerfile and workflow for Coolify Realtime (v4) +- Rename Command Center to Terminal in code and views +- Update branch restriction for push event in coolify-helper.yml +- Update terminal button text and layout in application heading view +- Refactor terminal component and select form layout +- Update coolify nightly version to 4.0.0-beta.335 +- Update helper version to 1.0.1 +- Fix syntax error in versions.json +- Update version numbers to 4.0.0-beta.337 +- Update Coolify installer and scripts to include a function for fetching programming jokes +- Update docker network connection command in ApplicationDeploymentJob.php +- Add validation to prevent selecting 'default' server or container in RunCommand.php +- Update versions.json to reflect latest version of realtime container +- Update soketi image to version 1.0.1 +- Nightly - Update soketi image to version 1.0.1 and versions.json to reflect latest version of realtime container +- Update version numbers to 4.0.0-beta.339 +- Update version numbers to 4.0.0-beta.340 +- Update version numbers to 4.0.0-beta.341 + +### ◀️ Revert + +- Databasebackup + +## [4.0.0-beta.335] - 2024-09-12 + +### 🐛 Bug Fixes + +- Cloudflare tunnel with new multiplexing feature + +### 💼 Other + +- SSH Multiplexing on docker desktop on Windows + +### ⚙️ Miscellaneous Tasks + +- Update release version to 4.0.0-beta.335 +- Update constants.ssh.mux_enabled in remoteProcess.php +- Update listeners and proxy settings in server form and new server components +- Remove unnecessary null check for proxy_type in generate_default_proxy_configuration +- Remove unnecessary SSH command execution time logging + +## [4.0.0-beta.334] - 2024-09-12 + +### ⚙️ Miscellaneous Tasks + +- Remove itsgoingd/clockwork from require-dev in composer.json +- Update 'key' value of gitlab in Service.php to use environment variable + +## [4.0.0-beta.333] - 2024-09-11 + +### 🐛 Bug Fixes + +- Disable mux_enabled during server validation +- Move mc command to coolify image from helper +- Keydb. add `:` delimiter for connection string + +### 💼 Other + +- Remote servers with port and user +- Do not change localhost server name on revalidation +- Release.md file + +### 🚜 Refactor + +- Improve handling of environment variable merging in upgrade script + +### ⚙️ Miscellaneous Tasks + +- Update version numbers to 4.0.0-beta.333 +- Copy .env file to .env-{DATE} if it exists +- Update .env file with new values +- Update server check job middleware to use server ID instead of UUID +- Add reminder to backup .env file before running install script again +- Copy .env file to backup location during installation script +- Add reminder to backup .env file during installation script +- Update permissions in pr-build.yml and version numbers +- Add minio/mc command to Dockerfile + +## [4.0.0-beta.332] - 2024-09-10 + +### 🚀 Features + +- Expose project description in API response +- Add elixir finetunes to the deployment job + +### 🐛 Bug Fixes + +- Reenable overlapping servercheckjob +- Appwrite template + parser +- Don't add `networks` key if `network_mode` is used +- Remove debug statement in shared.php +- Scp through cloudflare +- Delete older versions of the helper image other than the latest one +- Update remoteProcess.php to handle null values in logItem properties + +### 💼 Other + +- Set a default server timezone +- Implement SSH Multiplexing +- Enabel mux +- Cleanup stale multiplexing connections + +### 🚜 Refactor + +- Improve environment variable handling in shared.php + +### ⚙️ Miscellaneous Tasks + +- Set timeout for ServerCheckJob to 60 seconds +- Update appwrite.yaml to include OpenSSL key variable assignment + +## [4.0.0-beta.330] - 2024-09-06 + +### 🐛 Bug Fixes + +- Parser +- Plunk NEXT_PUBLIC_API_URI + +### 💼 Other + +- Pull helper image if not available otherwise s3 backup upload fails + +### 🚜 Refactor + +- Improve handling of server timezones in scheduled backups and tasks +- Improve handling of server timezones in scheduled backups and tasks +- Improve handling of server timezones in scheduled backups and tasks +- Update cleanup schedule to run daily at midnight +- Skip returning volume if driver type is cifs or nfs + +### ⚙️ Miscellaneous Tasks + +- Update coolify-helper.yml to get version from versions.json +- Disable Ray by default +- Enable Ray by default and update Dockerfile with latest versions of PACK and NIXPACKS +- Update Ray configuration and Dockerfile +- Add middleware for updating environment variables by UUID in `api.php` routes +- Expose port 3000 in browserless.yaml template +- Update Ray configuration and Dockerfile +- Update coolify version to 4.0.0-beta.331 +- Update versions.json and sentry.php to 4.0.0-beta.332 +- Update version to 4.0.0-beta.332 +- Update DATABASE_URL in plunk.yaml to use plunk database +- Add coolify.managed=true label to Docker image builds +- Update docker image pruning command to exclude managed images +- Update docker cleanup schedule to run daily at midnight +- Update versions.json to version 1.0.1 +- Update coolify-helper.yml to include "next" branch in push trigger + +## [4.0.0-beta.326] - 2024-09-03 + +### 🚀 Features + +- Update server_settings table to force docker cleanup +- Update Docker Compose file with DB_URL environment variable +- Refactor shared.php to improve environment variable handling + +### 🐛 Bug Fixes + +- Wrong executions order +- Handle project not found error in environment_details API endpoint +- Deployment running for - without "ago" +- Update helper image pulling logic to only pull if the version is newer + +### 💼 Other + +- Plunk svg + +### 📚 Documentation + +- Update Plunk documentation link in compose/plunk.yaml + +### ⚙️ Miscellaneous Tasks + +- Update UI for displaying no executions found in scheduled task list +- Update UI for displaying deployment status in deployment list +- Update UI for displaying deployment status in deployment list +- Ignore unnecessary files in production build workflow +- Update server form layout and settings +- Update Dockerfile with latest versions of PACK and NIXPACKS + +## [4.0.0-beta.324] - 2024-09-02 + +### 🚀 Features + +- Preserve git repository with advanced file storages +- Added Windmill template +- Added Budibase template +- Add shm-size for custom docker commands +- Add custom docker container options to all databases +- Able to select different postgres database +- Add new logos for jobscollider and hostinger +- Order scheduled task executions +- Add Code Server environment variables to Service model +- Add coolify build env variables to building phase +- Add new logos for GlueOps, Ubicloud, Juxtdigital, Saasykit, and Massivegrid +- Add new logos for GlueOps, Ubicloud, Juxtdigital, Saasykit, and Massivegrid + +### 🐛 Bug Fixes + +- Timezone not updated when systemd is missing +- If volumes + file mounts are defined, should merge them together in the compose file +- All mongo v4 backups should use the different backup command +- Database custom environment variables +- Connect compose apps to the right predefined network +- Docker compose destination network +- Server status when there are multiple servers +- Sync fqdn change on the UI +- Pr build names in case custom name is used +- Application patch request instant_deploy +- Canceling deployment on build server +- Backup of password protected postgresql database +- Docker cleanup job +- Storages with preserved git repository +- Parser parser parser +- New parser only in dev +- Parser parser +- Numberoflines should be number +- Docker cleanup job +- Fix directory and file mount headings in file-storage.blade.php +- Preview fqdn generation +- Revert a few lines +- Service ui sync bug +- Setup script doesn't work on rhel based images with some curl variant already installed +- Let's wait for healthy container during installation and wait an extra 20 seconds (for migrations) +- Infra files +- Log drain only for Applications +- Copy large compose files through scp (not ssh) +- Check if array is associative or not +- Openapi endpoint urls +- Convert environment variables to one format in shared.php +- Logical volumes could be overwritten with new path +- Env variable in value parsed +- Pull coolify image only when the app needs to be updated + +### 💼 Other + +- Actually update timezone on the server +- Cron jobs are executed based on the server timezone +- Server timezone seeder +- Recent backups UI +- Use apt-get instead of apt +- Typo +- Only pull helper image if the version is newer than the one + +### 🚜 Refactor + +- Update event listeners in Show components +- Refresh application to get latest database changes +- Update RabbitMQ configuration to use environment variable for port +- Remove debug statement in parseDockerComposeFile function +- ParseServiceVolumes +- Update OpenApi command to generate documentation +- Remove unnecessary server status check in destination view +- Remove unnecessary admin user email and password in budibase.yaml +- Improve saving of custom internal name in Advanced.php +- Add conditional check for volumes in generate_compose_file() +- Improve storage mount forms in add.blade.php +- Load environment variables based on resource type in sortEnvironmentVariables() +- Remove unnecessary network cleanup in Init.php +- Remove unnecessary environment variable checks in parseDockerComposeFile() +- Add null check for docker_compose_raw in parseCompose() +- Update dockerComposeParser to use YAML data from $yaml instead of $compose +- Convert service variables to key-value pairs in parseDockerComposeFile function +- Update database service name from mariadb to mysql +- Remove unnecessary code in DatabaseBackupJob and BackupExecutions +- Update Docker Compose parsing function to convert service variables to key-value pairs +- Update Docker Compose parsing function to convert service variables to key-value pairs +- Remove unused server timezone seeder and related code +- Remove unused server timezone seeder and related code +- Remove unused PullCoolifyImageJob from schedule +- Update parse method in Advanced, All, ApplicationPreview, General, and ApplicationDeploymentJob classes +- Remove commented out code for getIptables() in Dashboard.php +- Update .env file path in install.sh script +- Update SELF_HOSTED environment variable in docker-compose.prod.yml +- Remove unnecessary code for creating coolify network in upgrade.sh +- Update environment variable handling in StartClickhouse.php and ApplicationDeploymentJob.php +- Improve handling of COOLIFY_URL in shared.php +- Update build_args property type in ApplicationDeploymentJob +- Update background color of sponsor section in README.md +- Update Docker Compose location handling in PublicGitRepository +- Upgrade process of Coolify + +### 🧪 Testing + +- More tests + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.324 +- New compose parser with tests +- Update version to 1.3.4 in install.sh and 1.0.6 in upgrade.sh +- Update memory limit to 64MB in horizon configuration +- Update php packages +- Update axios npm dependency to version 1.7.5 +- Update Coolify version to 4.0.0-beta.324 and fix file paths in upgrade script +- Update Coolify version to 4.0.0-beta.324 +- Update Coolify version to 4.0.0-beta.325 +- Update Coolify version to 4.0.0-beta.326 +- Add cd command to change directory before removing .env file +- Update Coolify version to 4.0.0-beta.327 +- Update Coolify version to 4.0.0-beta.328 +- Update sponsor links in README.md +- Update version.json to versions.json in GitHub workflow +- Cleanup stucked resources and scheduled backups +- Update GitHub workflow to use versions.json instead of version.json +- Update GitHub workflow to use versions.json instead of version.json +- Update GitHub workflow to use versions.json instead of version.json +- Update GitHub workflow to use jq container for version extraction +- Update GitHub workflow to use jq container for version extraction + +## [4.0.0-beta.323] - 2024-08-08 + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.323 + +## [4.0.0-beta.322] - 2024-08-08 + +### 🐛 Bug Fixes + +- Manual update process + +### 🚜 Refactor + +- Update Server model getContainers method to use collect() for containers and containerReplicates +- Import ProxyTypes enum and use TRAEFIK instead of TRAEFIK_V2 + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.322 + +## [4.0.0-beta.321] - 2024-08-08 + +### 🐛 Bug Fixes + +- Scheduledbackup not found + +### 🚜 Refactor + +- Update StandalonePostgresql database initialization and backup handling +- Update cron expressions and add helper text for scheduled tasks + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.321 + +## [4.0.0-beta.320] - 2024-08-08 + +### 🚀 Features + +- Delete team in cloud without subscription +- Coolify init should cleanup stuck networks in proxy +- Add manual update check functionality to settings page +- Update auto update and update check frequencies in settings +- Update Upgrade component to check for latest version of Coolify +- Improve homepage service template +- Support map fields in Directus +- Labels by proxy type +- Able to generate only the required labels for resources + +### 🐛 Bug Fixes + +- Only append docker network if service/app is running +- Remove lazy load from scheduled tasks +- Plausible template +- Service_url should not have a trailing slash +- If usagebefore cannot be determined, cleanup docker with force +- Async remote command +- Only run logdrain if necessary +- Remove network if it is only connected to coolify proxy itself +- Dir mounts should have proper dirs +- File storages (dir/file mount) handled properly +- Do not use port exposes on docker compose buildpacks +- Minecraft server template fixed +- Graceful shutdown +- Stop resources gracefully +- Handle null and empty disk usage in DockerCleanupJob +- Show latest version on manual update view +- Empty string content should be saved as a file +- Update Traefik labels on init +- Add missing middleware for server check job + +### 🚜 Refactor + +- Update CleanupDatabase.php to adjust keep_days based on environment +- Adjust keep_days in CleanupDatabase.php based on environment +- Remove commented out code for cleaning up networks in CleanupDocker.php +- Update livewire polling interval in heading.blade.php +- Remove unused code for checking server status in Heading.php +- Simplify log drain installation in ServerCheckJob +- Remove unnecessary debug statement in ServerCheckJob +- Simplify log drain installation and stop log drain if necessary +- Cleanup unnecessary dynamic proxy configuration in Init command +- Remove unnecessary debug statement in ApplicationDeploymentJob +- Update timeout for graceful_shutdown_container in ApplicationDeploymentJob +- Remove unused code and optimize CheckForUpdatesJob +- Update ProxyTypes enum values to use TRAEFIK instead of TRAEFIK_V2 +- Update Traefik labels on init and cleanup unnecessary dynamic proxy configuration + +### 🎨 Styling + +- Linting + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.320 +- Add pull_request image builds to GH actions +- Add comment explaining the purpose of disconnecting the network in cleanup_unused_network_from_coolify_proxy() +- Update formbricks template +- Update registration view to display a notice for first user that it will be an admin +- Update server form to use password input for IP Address/Domain field +- Update navbar to include service status check +- Update navbar and configuration to improve service status check functionality +- Update workflows to include PR build and merge manifest steps +- Update UpdateCoolifyJob timeout to 10 minutes +- Update UpdateCoolifyJob to dispatch CheckForUpdatesJob synchronously + +## [4.0.0-beta.319] - 2024-07-26 + +### 🐛 Bug Fixes + +- Parse docker composer +- Service env parsing +- Service env variables +- Activity type invalid +- Update env on ui + +### 💼 Other + +- Service env parsing + +### ⚙️ Miscellaneous Tasks + +- Collect/create/update volumes in parseDockerComposeFile function + +## [4.0.0-beta.318] - 2024-07-24 + +### 🚀 Features + +- Create/delete project endpoints +- Add patch request to projects +- Add server api endpoints +- Add branddev logo to README.md +- Update API endpoint summaries +- Update Caddy button label in proxy.blade.php +- Check custom internal name through server's applications. +- New server check job + +### 🐛 Bug Fixes + +- Preview deployments should be stopped properly via gh webhook +- Deleting application should delete preview deployments +- Plane service images +- Fix issue with deployment start command in ApplicationDeploymentJob +- Directory will be created by default for compose host mounts +- Restart proxy does not work + status indicator on the UI +- Uuid in api docs type +- Raw compose deployment .env not found +- Api -> application patch endpoint +- Remove pull always when uploading backup to s3 +- Handle array env vars +- Link in task failed job notifications +- Random generated uuid will be full length (not 7 characters) +- Gitlab service +- Gitlab logo +- Bitbucket repository url +- By default volumes that we cannot determine if they are directories or files are treated as directories +- Domain update on services on the UI +- Update SERVICE_FQDN/URL env variables when you change the domain +- Several shared environment variables in one value, parsed correctly +- Members of root team should not see instance admin stuff + +### 💼 Other + +- Formbricks template add required CRON_SECRET +- Add required CRON_SECRET to Formbricks template + +### ⚙️ Miscellaneous Tasks + +- Update APP_BASE_URL to use SERVICE_FQDN_PLANE +- Update resource-limits.blade.php with improved input field helpers +- Update version numbers to 4.0.0-beta.319 +- Remove commented out code for docker image pruning + +## [4.0.0-beta.314] - 2024-07-15 + +### 🚀 Features + +- Improve error handling in loadComposeFile method +- Add readonly labels +- Preserve git repository +- Force cleanup server + +### 🐛 Bug Fixes + +- Typo in is_literal helper +- Env is_literal helper text typo +- Update docker compose pull command with --policy always +- Plane service template +- Vikunja +- Docmost template +- Drupal +- Improve github source creation +- Tag deployments +- New docker compose parsing +- Handle / in preselecting branches +- Handle custom_internal_name check in ApplicationDeploymentJob.php +- If git limit reached, ignore it and continue with a default selection +- Backup downloads +- Missing input for api endpoint +- Volume detection (dir or file) is fixed +- Supabase +- Create file storage even if content is empty + +### 💼 Other + +- Add basedir + compose file in new compose based apps + +### 🚜 Refactor + +- Remove unused code and fix storage form layout +- Update Docker Compose build command to include --pull flag +- Update DockerCleanupJob to handle nullable usageBefore property +- Server status job and docker cleanup job +- Update DockerCleanupJob to use server settings for force cleanup +- Update DockerCleanupJob to use server settings for force cleanup +- Disable health check for Rust applications during deployment + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.315 +- Update version to 4.0.0-beta.316 +- Update bug report template +- Update repository form with simplified URL input field +- Update width of container in general.blade.php +- Update checkbox labels in general.blade.php +- Update general page of apps +- Handle JSON parsing errors in format_docker_command_output_to_json +- Update Traefik image version to v2.11 +- Update version to 4.0.0-beta.317 +- Update version to 4.0.0-beta.318 +- Update helper message with link to documentation +- Disable health check by default +- Remove commented out code for sending internal notification + +### ◀️ Revert + +- Pull policy +- Advanced dropdown + +## [4.0.0-beta.308] - 2024-07-11 + +### 🚀 Features + +- Cleanup unused docker networks from proxy +- Compose parser v2 +- Display time interval for rollback images +- Add security and storage access key env to twenty template +- Add new logo for Latitude +- Enable legacy model binding in Livewire configuration + +### 🐛 Bug Fixes + +- Do not overwrite hardcoded variables if they rely on another variable +- Remove networks when deleting a docker compose based app +- Api +- Always set project name during app deployments +- Remove volumes as well +- Gitea pr previews +- Prevent instance fqdn persisting to other servers dynamic proxy configs +- Better volume cleanups +- Cleanup parameter +- Update redirect URL in unauthenticated exception handler +- Respect top-level configs and secrets +- Service status changed event +- Disable sentinel until a few bugs are fixed +- Service domains and envs are properly updated +- *(reactive-resume)* New healthcheck command for MinIO +- *(MinIO)* New command healthcheck +- Update minio hc in services +- Add validation for missing docker compose file + +### 🚜 Refactor + +- Add force parameter to StartProxy handle method +- Comment out unused code for network cleanup +- Reset default labels when docker_compose_domains is modified +- Webhooks view +- Tags view +- Only get instanceSettings once from db +- Update Dockerfile to set CI environment variable to true +- Remove unnecessary code in AppServiceProvider.php +- Update Livewire configuration views +- Update Webhooks.php to use nullable type for webhook URLs +- Add lazy loading to tags in Livewire configuration view +- Update metrics.blade.php to improve alert message clarity +- Update version numbers to 4.0.0-beta.312 +- Update version numbers to 4.0.0-beta.314 + +### ⚙️ Miscellaneous Tasks + +- Update Plausible docker compose template to Plausible 2.1.0 +- Update Plausible docker compose template to Plausible 2.1.0 +- Update livewire/livewire dependency to version 3.4.9 +- Refactor checkIfDomainIsAlreadyUsed function +- Update storage.blade.php view for livewire project service +- Update version to 4.0.0-beta.310 +- Update composer dependencies +- Add new logo for Latitude +- Bump version to 4.0.0-beta.311 + +### ◀️ Revert + +- Instancesettings + +## [4.0.0-beta.301] - 2024-06-24 + +### 🚀 Features + +- Local fonts +- More API endpoints +- Bulk env update api endpoint +- Update server settings metrics history days to 7 +- New app API endpoint +- Private gh deployments through api +- Lots of api endpoints +- Api api api api api api +- Rename CloudCleanupSubs to CloudCleanupSubscriptions +- Early fraud warning webhook +- Improve internal notification message for early fraud warning webhook +- Add schema for uuid property in app update response + +### 🐛 Bug Fixes + +- Run user commands on high prio queue +- Load js locally +- Remove lemon + paddle things +- Run container commands on high priority +- Image logo +- Remove both option for api endpoints. it just makes things complicated +- Cleanup subs in cloud +- Show keydbs/dragonflies/clickhouses +- Only run cloud clean on cloud + remove root team +- Force cleanup on busy servers +- Check domain on new app via api +- Custom container name will be the container name, not just internal network name +- Api updates +- Yaml everywhere +- Add newline character to private key before saving +- Add validation for webhook endpoint selection +- Database input validators +- Remove own app from domain checks +- Return data of app update + +### 💼 Other + +- Update process +- Glances service +- Glances +- Able to update application + +### 🚜 Refactor + +- Update Service model's saveComposeConfigs method +- Add default environment to Service model's saveComposeConfigs method +- Improve handling of default environment in Service model's saveComposeConfigs method +- Remove commented out code in Service model's saveComposeConfigs method +- Update stack-form.blade.php to include wire:target attribute for submit button +- Update code to use str() instead of Str::of() for string manipulation +- Improve formatting and readability of source.blade.php +- Add is_build_time property to nixpacks_php_fallback_path and nixpacks_php_root_dir +- Simplify code for retrieving subscription in Stripe webhook + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.302 +- Update version to 4.0.0-beta.303 +- Update version to 4.0.0-beta.305 +- Update version to 4.0.0-beta.306 +- Add log1x/laravel-webfonts package +- Update version to 4.0.0-beta.307 +- Refactor ServerStatusJob constructor formatting +- Update Monaco Editor for Docker Compose and Proxy Configuration +- More details +- Refactor shared.php helper functions + +## [4.0.0-beta.298] - 2024-06-24 + +### 🚀 Features + +- Spanish translation +- Cancelling a deployment will check if new could be started. +- Add supaguide logo to donations section +- Nixpacks now could reach local dbs internally +- Add Tigris logo to other/logos directory +- COOLIFY_CONTAINER_NAME predefined variable +- Charts +- Sentinel + charts +- Container metrics +- Add high priority queue +- Add metrics warning for servers without Sentinel enabled +- Add blacksmith logo to donations section +- Preselect server and destination if only one found +- More api endpoints +- Add API endpoint to update application by UUID +- Update statusnook logo filename in compose template + +### 🐛 Bug Fixes + +- Stripprefix middleware correctly labeled to http +- Bitbucket link +- Compose generator +- Do no truncate repositories wtih domain (git) in it +- In services should edit compose file for volumes and envs +- Handle laravel deployment better +- Db proxy status shown better in the UI +- Show commit message on webhooks + prs +- Metrics parsing +- Charts +- Application custom labels reset after saving +- Static build with new nixpacks build process +- Make server charts one livewire component with one interval selector +- You can now add env variable from ui to services +- Update compose environment with UI defined variables +- Refresh deployable compose without reload +- Remove cloud stripe notifications +- App deployment should be in high queue +- Remove zoom from modals +- Get envs before sortby +- MB is % lol +- Projects with 0 envs + +### 💼 Other + +- Unnecessary notification + +### 🚜 Refactor + +- Update text color for stderr output in deployment show view +- Update text color for stderr output in deployment show view +- Remove debug code for saving environment variables +- Update Docker build commands for better performance and flexibility +- Update image sizes and add new logos to README.md +- Update README.md with new logos and fix styling +- Update shared.php to use correct key for retrieving sentinel version +- Update container name assignment in Application model +- Remove commented code for docker container removal +- Update Application model to include getDomainsByUuid method +- Update Project/Show component to sort environments by created_at +- Update profile index view to display 2FA QR code in a centered container +- Update dashboard.blade.php to use project's default environment for redirection +- Update gitCommitLink method to handle null values in source.html_url +- Update docker-compose generation to use multi-line literal block + +### ⚙️ Miscellaneous Tasks + +- Update version numbers to 4.0.0-beta.298 +- Switch to database sessions from redis +- Update dependencies and remove unused code +- Update tailwindcss and vue versions in package.json +- Update service template URL in constants.php +- Update sentinel version to 0.0.8 +- Update chart styling and loading text +- Update sentinel version to 0.0.9 +- Update Spanish translation for failed authentication messages +- Add portuguese traslation +- Add Turkish translations +- Add Vietnamese translate +- Add Treive logo to donations section +- Update README.md with latest release version badge +- Update latest release version badge in README.md +- Update version to 4.0.0-beta.299 +- Move server delete component to the bottom of the page +- Update version to 4.0.0-beta.301 + +## [4.0.0-beta.297] - 2024-06-11 + +### 🚀 Features + +- Easily redirect between www-and-non-www domains +- Add logos for new sponsors +- Add homepage template +- Update homepage.yaml with environment variables and volumes + +### 🐛 Bug Fixes + +- Multiline build args +- Setup script doesnt link to the correct source code file +- Install.sh do not reinstall packages on arch +- Just restart + +### 🚜 Refactor + +- Replaces duplications in code with a single function + +### ⚙️ Miscellaneous Tasks + +- Update page title in resource index view +- Update logo file path in logto.yaml +- Update logo file path in logto.yaml +- Remove commented out code for docker container removal +- Add isAnyDeploymentInprogress function to check if any deployments are in progress +- Add ApplicationDeploymentJob and pint.json + +## [4.0.0-beta.295] - 2024-06-10 + +### 🚀 Features + +- Able to change database passwords on the UI. It won't sync to the database. +- Able to add several domains to compose based previews +- Add bounty program link to bug report template +- Add titles +- Db proxy logs + +### 🐛 Bug Fixes + +- Custom docker compose commands, add project dir if needed +- Autoupdate process +- Backup executions view +- Handle previously defined compose previews +- Sort backup executions +- Supabase service, newest versions +- Set default name for Docker volumes if it is null +- Multiline variable should be literal + should be multiline in bash with \ +- Gitlab merge request should close PR + +### 💼 Other + +- Rocketchat +- New services based git apps + +### 🚜 Refactor + +- Append utm_source parameter to documentation URL +- Update save_environment_variables method to use application's environment_variables instead of environment_variables_preview +- Update deployment previews heading to "Deployments" +- Remove unused variables and improve code readability +- Initialize null properties in Github Change component +- Improve pre and post deployment command inputs +- Improve handling of Docker volumes in parseDockerComposeFile function + +### ⚙️ Miscellaneous Tasks + +- Update version numbers to 4.0.0-beta.295 +- Update supported OS list with almalinux +- Update install.sh to support PopOS +- Update install.sh script to version 1.3.2 and handle Linux Mint as Ubuntu + +## [4.0.0-beta.294] - 2024-06-04 + +### ⚙️ Miscellaneous Tasks + +- Update Dockerfile with latest versions of Docker, Docker Compose, Docker Buildx, Pack, and Nixpacks + +## [4.0.0-beta.289] - 2024-05-29 + +### 🚀 Features + +- Add PHP memory limit environment variable to docker-compose.prod.yml +- Add manual update option to UpdateCoolify handle method +- Add port configuration for Vaultwarden service + +### 🐛 Bug Fixes + +- Sync upgrade process +- Publish horizon +- Add missing team model +- Test new upgrade process? +- Throw exception +- Build server dirs not created on main server +- Compose load with non-root user +- Able to redeploy dockerfile based apps without cache +- Compose previews does have env variables +- Fine-tune cdn pulls +- Spamming :D +- Parse docker version better +- Compose issues +- SERVICE_FQDN has source port in it +- Logto service +- Allow invitations via email +- Sort by defined order + fixed typo +- Only ignore volumes with driver_opts +- Check env in args for compose based apps + +### 🚜 Refactor + +- Update destination.blade.php to add group class for better styling +- Applicationdeploymentjob +- Improve code structure in ApplicationDeploymentJob.php +- Remove unnecessary debug statement in ApplicationDeploymentJob.php +- Remove unnecessary debug statements and improve code structure in RunRemoteProcess.php and ApplicationDeploymentJob.php +- Remove unnecessary logging statements from UpdateCoolify +- Update storage form inputs in show.blade.php +- Improve Docker Compose parsing for services +- Remove unnecessary port appending in updateCompose function +- Remove unnecessary form class in profile index.blade.php +- Update form layout in invite-link.blade.php +- Add log entry when starting new application deployment +- Improve Docker Compose parsing for services +- Update Docker Compose parsing for services +- Update slogan in shlink.yaml +- Improve display of deployment time in index.blade.php +- Remove commented out code for clearing Ray logs +- Update save_environment_variables method to use application's environment_variables instead of environment_variables_preview + +### ⚙️ Miscellaneous Tasks + +- Update for version 289 +- Fix formatting issue in deployment index.blade.php file +- Remove unnecessary wire:navigate attribute in breadcrumbs.blade.php +- Rename docker dirs +- Update laravel/socialite to version v5.14.0 and livewire/livewire to version 3.4.9 +- Update modal styles for better user experience +- Update deployment index.blade.php script for better performance +- Update version numbers to 4.0.0-beta.290 +- Update version numbers to 4.0.0-beta.291 +- Update version numbers to 4.0.0-beta.292 +- Update version numbers to 4.0.0-beta.293 +- Add upgrade guide link to upgrade.blade.php +- Improve upgrade.blade.php with clearer instructions and formatting +- Update version numbers to 4.0.0-beta.294 +- Add Lightspeed.run as a sponsor +- Update Dockerfile to install vim + +## [4.0.0-beta.288] - 2024-05-28 + +### 🐛 Bug Fixes + +- Do not allow service storage mount point modifications +- Volume adding + +### ⚙️ Miscellaneous Tasks + +- Update Sentry release version to 4.0.0-beta.288 + +## [4.0.0-beta.287] - 2024-05-27 + +### 🚀 Features + +- Handle incomplete expired subscriptions in Stripe webhook +- Add more persistent storage types + +### 🐛 Bug Fixes + +- Force load services from cdn on reload list + +### ⚙️ Miscellaneous Tasks + +- Update Sentry release version to 4.0.0-beta.287 +- Add Thompson Edolo as a sponsor +- Add null checks for team in Stripe webhook + +## [4.0.0-beta.286] - 2024-05-27 + +### 🚀 Features + +- If the time seems too long it remains at 0s +- Improve Docker Engine start logic in ServerStatusJob +- If proxy stopped manually, it won't start back again +- Exclude_from_hc magic +- Gitea manual webhooks +- Add container logs in case the container does not start healthy + +### 🐛 Bug Fixes + +- Wrong time during a failed deployment +- Removal of the failed deployment condition, addition of since started instead of finished time +- Use local versions + service templates and query them every 10 minutes +- Check proxy functionality before removing unnecessary coolify.yaml file and checking Docker Engine +- Show first 20 users only in admin view +- Add subpath for services +- Ghost subdir +- Do not pull templates in dev +- Templates +- Update error message for invalid token to mention invalid signature +- Disable containerStopped job for now +- Disable unreachable/revived notifications for now +- JSON_UNESCAPED_UNICODE +- Add wget to nixpacks builds +- Pre and post deployment commands +- Bitbucket commits link +- Better way to add curl/wget to nixpacks +- Root team able to download backups +- Build server should not have a proxy +- Improve build server functionalities +- Sentry issue +- Sentry +- Sentry error + livewire downgrade +- Sentry +- Sentry +- Sentry error +- Sentry + +### 🚜 Refactor + +- Update edit-domain form in project service view +- Add Huly services to compose file +- Remove redundant heading in backup settings page +- Add isBuildServer method to Server model +- Update docker network creation in ApplicationDeploymentJob + +### ⚙️ Miscellaneous Tasks + +- Change pre and post deployment command length in applications table +- Refactor container name logic in GetContainersStatus.php and ForcePasswordReset.php +- Remove unnecessary content from Docker Compose file + +## [4.0.0-beta.285] - 2024-05-21 + +### 🚀 Features + +- Add SerpAPI as a Github Sponsor +- Admin view for deleting users +- Scheduled task failed notification + +### 🐛 Bug Fixes + +- Optimize new resource creation +- Show it docker compose has syntax errors + +### 💼 Other + +- Responsive here and there + +## [4.0.0-beta.284] - 2024-05-19 + +### 🚀 Features + +- Add hc logs to healthchecks + +### ◀️ Revert + +- Hc return code check + +## [4.0.0-beta.283] - 2024-05-17 + +### 🚀 Features + +- Update healthcheck test in StartMongodb action +- Add pull_request_id filter to get_last_successful_deployment method in Application model + +### 🐛 Bug Fixes + +- PR deployments have good predefined envs + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.283 + +## [4.0.0-beta.281] - 2024-05-17 + +### 🚀 Features + +- Shows the latest deployment commit + message on status +- New manual update process + remove next_channel +- Add lastDeploymentInfo and lastDeploymentLink props to breadcrumbs and status components +- Sort envs alphabetically and creation date +- Improve sorting of environment variables in the All component + +### 🐛 Bug Fixes + +- Hc from localhost to 127.0.0.1 +- Use rc in hc +- Telegram group chat notifications + +## [4.0.0-beta.280] - 2024-05-16 + +### 🐛 Bug Fixes + +- Commit message length + +## [4.0.0-beta.279] - 2024-05-16 + +### ⚙️ Miscellaneous Tasks + +- Update version numbers to 4.0.0-beta.279 +- Limit commit message length to 50 characters in ApplicationDeploymentJob + +## [4.0.0-beta.278] - 2024-05-16 + +### 🚀 Features + +- Adding new COOLIFY_ variables +- Save commit message and better view on deployments +- Toggle label escaping mechanism + +### 🐛 Bug Fixes + +- Use commit hash on webhooks + +### ⚙️ Miscellaneous Tasks + +- Refactor Service.php to handle missing admin user in extraFields() method +- Update twenty CRM template with environment variables and dependencies +- Refactor applications.php to remove unused imports and improve code readability +- Refactor deployment index.blade.php for improved readability and rollback handling +- Refactor GitHub app selection UI in project creation form +- Update ServerLimitCheckJob.php to handle missing serverLimit value +- Remove unnecessary code for saving commit message +- Update DOCKER_VERSION to 26.0 in install.sh script +- Update Docker and Docker Compose versions in Dockerfiles + +## [4.0.0-beta.277] - 2024-05-10 + +### 🚀 Features + +- Add AdminRemoveUser command to remove users from the database + +### 🐛 Bug Fixes + +- Color for resource operation server and project name +- Only show realtime error on non-cloud instances +- Only allow push and mr gitlab events +- Improve scheduled task adding/removing +- Docker compose dependencies for pr previews +- Properly populating dependencies + +### 💼 Other + +- Fix a few boxes here and there + +### ⚙️ Miscellaneous Tasks + +- Update version numbers to 4.0.0-beta.278 +- Update hover behavior and cursor style in scheduled task executions view +- Refactor scheduled task view to improve code readability and maintainability +- Skip scheduled tasks if application or service is not running +- Remove debug logging statements in Kernel.php +- Handle invalid cron strings in Kernel.php + +## [4.0.0-beta.275] - 2024-05-06 + +### 🚀 Features + +- Add container name to network aliases in ApplicationDeploymentJob +- Add lazy loading for images in General.php and improve Docker Compose file handling in Application.php +- Experimental sentinel +- Start Sentinel on servers. +- Pull new sentinel image and restart container +- Init metrics + +### 🐛 Bug Fixes + +- Typo in tags.blade.php +- Install.sh error +- Env file +- Comment out internal notification in email_verify method +- Confirmation for custom labels +- Change permissions on newly created dirs + +### 💼 Other + +- Fix tag view + +### 🚜 Refactor + +- Add SCHEDULER environment variable to StartSentinel.php + +### ⚙️ Miscellaneous Tasks + +- Dark mode should be the default +- Improve menu item styling and spacing in service configuration and index views +- Improve menu item styling and spacing in service configuration and index views +- Improve menu item styling and spacing in project index and show views +- Remove docker compose versions +- Add Listmonk service template and logo +- Refactor GetContainersStatus.php for improved readability and maintainability +- Refactor ApplicationDeploymentJob.php for improved readability and maintainability +- Add metrics and logs directories to installation script +- Update sentinel version to 0.0.2 in versions.json +- Update permissions on metrics and logs directories +- Comment out server sentinel check in ServerStatusJob + +## [4.0.0-beta.273] - 2024-05-03 + +### 🐛 Bug Fixes + +- Formbricks image origin +- Add port even if traefik is used + +### ⚙️ Miscellaneous Tasks + +- Update version to 4.0.0-beta.275 +- Update DNS server validation helper text + +## [4.0.0-beta.267] - 2024-04-26 + +### 🚀 Features + +- Initial datalist +- Update service contribution docs URL +- The final pricing plan, pay-as-you-go + +### 🐛 Bug Fixes + +- Move s3 storages to separate view +- Mongo db backup +- Backups +- Autoupdate +- Respect start period and chekc interval for hc +- Parse HEALTHCHECK from dockerfile +- Make s3 name and endpoint required +- Able to update source path for predefined volumes +- Get logs with non-root user +- Mongo 4.0 db backup + +### 💼 Other + +- Update resource operations view + +### ◀️ Revert + +- Variable parsing + +## [4.0.0-beta.266] - 2024-04-24 + +### 🐛 Bug Fixes + +- Refresh public ips on start + +## [4.0.0-beta.259] - 2024-04-17 + +### 🚀 Features + +- Literal env variables +- Lazy load stuffs + tell user if compose based deployments have missing envs +- Can edit file/dir volumes from ui in compose based apps +- Upgrade Appwrite service template to 1.5 +- Upgrade Appwrite service template to 1.5 +- Add db name to backup notifications + +### 🐛 Bug Fixes + +- Helper image only pulled if required, not every 10 mins +- Make sure that confs when checking if it is changed sorted +- Respect .env file (for default values) +- Remove temporary cloudflared config +- Remove lazy loading until bug figured out +- Rollback feature +- Base64 encode .env +- $ in labels escaped +- .env saved to deployment server, not to build server +- Do no able to delete gh app without deleting resources +- 500 error on edge case +- Able to select server when creating new destination +- N8n template + +### 💼 Other + +- Non-root user for remote servers +- Non-root + +## [4.0.0-beta.258] - 2024-04-12 + +### 🚀 Features + +- Dynamic mux time + +### 🐛 Bug Fixes + +- Check each required binaries one-by-one + +## [4.0.0-beta.256] - 2024-04-12 + +### 🚀 Features + +- Upload large backups +- Edit domains easier for compose +- Able to delete configuration from server +- Configuration checker for all resources +- Allow tab in textarea + +### 🐛 Bug Fixes + +- Service config hash update +- Redeploy if image not found in restart only mode + +### 💼 Other + +- New pricing +- Fix allowTab logic +- Use 2 space instead of tab + +## [4.0.0-beta.252] - 2024-04-09 + +### 🚀 Features + +- Add amazon linux 2023 + +### 🐛 Bug Fixes + +- Git submodule update +- Unintended left padding on sidebar +- Hashed random delimeter in ssh commands + make sure to remove the delimeter from the command + +## [4.0.0-beta.250] - 2024-04-05 + +### 🚀 Features + +- *(application)* Update submodules after git checkout + +## [4.0.0-beta.249] - 2024-04-03 + +### 🚀 Features + +- Able to make rsa/ed ssh keys + +### 🐛 Bug Fixes + +- Warning if you use multiple domains for a service +- New github app creation +- Always rebuild Dockerfile / dockerimage buildpacks +- Do not rebuild dockerfile based apps twice +- Make sure if envs are changed, rebuild is needed +- Members cannot manage subscriptions +- IsMember +- Storage layout +- How to update docker-compose, environment variables and fqdns + +### 💼 Other + +- Light buttons +- Multiple server view + +## [4.0.0-beta.242] - 2024-03-25 + +### 🚀 Features + +- Change page width +- Watch paths + +### 🐛 Bug Fixes + +- Compose env has SERVICE, but not defined for Coolify +- Public service database +- Make sure service db proxy restarted +- Restart service db proxies +- Two factor +- Ui for tags +- Update resources view +- Realtime connection check +- Multline env in dev mode +- Scheduled backup for other service databases (supabase) +- PR deployments should not be distributed to 2 servers +- Name/from address required for resend +- Autoupdater +- Async service loads +- Disabled inputs are not trucated +- Duplicated generated fqdns are now working +- Uis +- Ui for cftunnels +- Search services +- Trial users subscription page +- Async public key loading +- Unfunctional server should see resources + +### 💼 Other + +- Run cleanup every day +- Fix +- Fix log outputs +- Automatic cloudflare tunnels +- Backup executions + +## [4.0.0-beta.241] - 2024-03-20 + +### 🚀 Features + +- Able to run scheduler/horizon programatically + +### 🐛 Bug Fixes + +- Volumes for prs +- Shared env variable parsing + +### 💼 Other + +- Redesign +- Redesign + +## [4.0.0-beta.240] - 2024-03-18 + +### 🐛 Bug Fixes + +- Empty get logs number of lines +- Only escape envs after v239+ +- 0 in env value +- Consistent container name +- Custom ip address should turn off rolling update +- Multiline input +- Raw compose deployment +- Dashboard view if no project found + +## [4.0.0-beta.239] - 2024-03-14 + +### 🐛 Bug Fixes + +- Duplicate dockerfile +- Multiline env variables +- Server stopped, service page not reachable + +## [4.0.0-beta.237] - 2024-03-14 + +### 🚀 Features + +- Domains api endpoint +- Resources api endpoint +- Team api endpoint +- Add deployment details to deploy endpoint +- Add deployments api +- Experimental caddy support +- Dynamic configuration for caddy +- Reset password +- Show resources on source page + +### 🐛 Bug Fixes + +- Deploy api messages +- Fqdn null in case docker compose bp +- Reload caddy issue +- /realtime endpoint +- Proxy switch +- Service ports for services + caddy +- Failed deployments should send failed email/notification +- Consider custom healthchecks in dockerfile +- Create initial files async +- Docker compose validation + +## [4.0.0-beta.235] - 2024-03-05 + +### 🐛 Bug Fixes + +- Should note delete personal teams +- Make sure to show some buttons +- Sort repositories by name + +## [4.0.0-beta.224] - 2024-02-23 + +### 🚀 Features + +- Custom server limit +- Delay container/server jobs +- Add static ipv4 ipv6 support +- Server disabled by overflow +- Preview deployment logs +- Logs and execute commands with several servers + +### 🐛 Bug Fixes + +- Subscription / plan switch, etc +- Firefly service +- Force enable/disable server in case ultimate package quantity decreases +- Server disabled +- Custom dockerfile location always checked +- Import to mysql and mariadb +- Resource tab not loading if server is not reachable +- Load unmanaged async +- Do not show n/a networsk +- Service container status updates +- Public prs should not be commented +- Pull request deployments + build servers +- Env value generation +- Sentry error +- Service status updated + +### 💼 Other + +- Change + icon to hamburger. + +## [4.0.0-beta.222] - 2024-02-22 + +### 🚀 Features + +- Able to add dynamic configurations from proxy dashboard + +### 🐛 Bug Fixes + +- Connections being stuck and not processed until proxy restarts +- Use latest image if nothing is specified +- No coolify.yaml found +- Server validation +- Statuses +- Unknown image of service until it is uploaded + +## [4.0.0-beta.220] - 2024-02-19 + +### 🚀 Features + +- Save github app permission locally +- Minversion for services + +### 🐛 Bug Fixes + +- Add openbsd ssh server check +- Resources +- Empty build variables +- *(server)* Revalidate server button not showing in server's page +- Fluent bit ident level +- Submodule cloning +- Database status +- Permission change updates from webhook +- Server validation + +### 💼 Other + +- Updates + +## [4.0.0-beta.213] - 2024-02-12 + +### 🚀 Features + +- Magic for traefik redirectregex in services +- Revalidate server +- Disable gzip compression on service applications + +### 🐛 Bug Fixes + +- Cleanup scheduled tasks +- Padding left on input boxes +- Use ls / command instead ls +- Do not add the same server twice +- Only show redeployment required if status is not exited + +## [4.0.0-beta.212] - 2024-02-08 + +### 🚀 Features + +- Cleanup queue + +### 🐛 Bug Fixes + +- New menu on navbar +- Make sure resources are deleted in async mode +- Go to prod env from dashboard if there is no other envs defined +- User proper image_tag, if set +- New menu ui +- Lock logdrain configuration when one of them are enabled +- Add docker compose check during server validation +- Get service stack as uuid, not name +- Menu +- Flex wrap deployment previews +- Boolean docker options +- Only add 'networks' key if 'network_mode' is absent + +## [4.0.0-beta.206] - 2024-02-05 + +### 🚀 Features + +- Clone to env +- Multi deployments + +### 🐛 Bug Fixes + +- Wrap tags and avoid horizontal overflow +- Stripe webhooks +- Feedback from self-hosted envs to discord + +### 💼 Other + +- Specific about newrelic logdrains + +## [4.0.0-beta.201] - 2024-01-29 + +### 🚀 Features + +- Added manual webhook support for bitbucket +- Add initial support for custom docker run commands +- Cleanup unreachable servers +- Tags and tag deploy webhooks + +### 🐛 Bug Fixes + +- Bitbucket manual deployments +- Webhooks for multiple apps +- Unhealthy deployments should be failed +- Add env variables for wordpress template without database +- Service deletion function +- Service deletion fix +- Dns validation + duplicated fqdns +- Validate server navbar upated +- Regenerate labels on application clone +- Service deletion +- Not able to use other shared envs +- Sentry fix +- Sentry +- Sentry error +- Sentry +- Sentry error +- Create dynamic directory +- Migrate to new modal +- Duplicate domain check +- Tags + +### 💼 Other + +- New modal component + +## [4.0.0-beta.188] - 2024-01-11 + +### 🚀 Features + +- Search between resources +- Move resources between projects / environments +- Clone any resource +- Shared environments +- Concurrent builds / server +- Able to deploy multiple resources with webhook +- Add PR comments +- Dashboard live deployment view + +### 🐛 Bug Fixes + +- Preview deployments with nixpacks +- Cleanup docker stuffs before upgrading +- Service deletion command +- Cpuset limits was determined in a way that apps only used 1 CPU max, ehh, sorry. +- Service stack view +- Change proxy view +- Checkbox click +- Git pull command for deploy key based previews +- Server status job +- Service deletion bug! +- Links +- Redis custom conf +- Sentry error +- Restrict concurrent deployments per server +- Queue +- Change env variable length + +### 💼 Other + +- Send notification email if payment + +### 🚜 Refactor + +- Compose file and install script + +## [4.0.0-beta.186] - 2024-01-11 + +### 🚀 Features + +- Import backups + +### 🐛 Bug Fixes + +- Do not include thegameplan.json into build image +- Submit error on postgresql +- Email verification / forgot password +- Escape build envs properly for nixpacks + docker build +- Undead endpoint +- Upload limit on ui +- Save cmd output propely (merge) +- Load profile on remote commands +- Load profile and set envs on remote cmd +- Restart should not update config hash + +## [4.0.0-beta.184] - 2024-01-09 + +### 🐛 Bug Fixes + +- Healthy status +- Show framework based notification in build logs +- Traefik labels +- Use ip for sslip in dev if remote server is used +- Service labels without ports (unknown ports) +- Sort and rename (unique part) of labels +- Settings menu +- Remove traefik debug in dev mode +- Php pgsql to 8.2 +- Static buildpack should set port 80 +- Update navbar on build_pack change + +## [4.0.0-beta.183] - 2024-01-06 + +### 🚀 Features + +- Add www-non-www redirects to traefik + +### 🐛 Bug Fixes + +- Database env variables + +## [4.0.0-beta.182] - 2024-01-04 + +### 🐛 Bug Fixes + +- File storage save + +## [4.0.0-beta.181] - 2024-01-03 + +### 🐛 Bug Fixes + +- Nixpacks buildpack + +## [4.0.0-beta.180] - 2024-01-03 + +### 🐛 Bug Fixes + +- Nixpacks cache +- Only add restart policy if its empty (compose) + +## [4.0.0-beta.179] - 2024-01-02 + +### 🐛 Bug Fixes + +- Set deployment failed if new container is not healthy + +## [4.0.0-beta.177] - 2024-01-02 + +### 🚀 Features + +- Raw docker compose deployments + +### 🐛 Bug Fixes + +- Duplicate compose variable + +## [4.0.0-beta.176] - 2023-12-31 + +### 🐛 Bug Fixes + +- Horizon + +## [4.0.0-beta.175] - 2023-12-30 + +### 🚀 Features + +- Add environment description + able to change name + +### 🐛 Bug Fixes + +- Sub +- Wrong env variable parsing +- Deploy key + docker compose + +## [4.0.0-beta.174] - 2023-12-27 + +### 🐛 Bug Fixes + +- Restore falsely deleted coolify-db-backup + +## [4.0.0-beta.173] - 2023-12-27 + +### 🐛 Bug Fixes + +- Cpu limit to float from int +- Add source commit to final envs +- Routing, switch back to old one +- Deploy instead of restart in case swarm is used +- Button title + +## [4.0.0-beta.163] - 2023-12-15 + +### 🚀 Features + +- Custom docker compose commands + +### 🐛 Bug Fixes + +- Domains for compose bp +- No action in webhooks +- Add debug output to gitlab webhooks +- Do not push dockerimage +- Add alpha to swarm +- Server not found +- Do not autovalidate server on mount +- Server update schedule +- Swarm support ui +- Server ready +- Get swarm service logs +- Docker compose apps env rewritten +- Storage error on dbs +- Why?! +- Stay tuned + +### 💼 Other + +- Swarm +- Swarm + +## [4.0.0-beta.155] - 2023-12-11 + +### 🚀 Features + +- Autoupdate env during seed +- Disable autoupdate +- Randomly sleep between executions +- Pull latest images for services + +### 🐛 Bug Fixes + +- Do not send telegram noti on intent payment failed +- Database ui is realtime based +- Live mode for github webhooks +- Ui +- Realtime connection popup could be disabled +- Realtime check +- Add new destination +- Proxy logs +- Db status check +- Pusher host +- Add ipv6 +- Realtime connection?! +- Websocket +- Better handling of errors with install script +- Install script parse version +- Only allow to modify in .env file if AUTOUPDATE is set +- Is autoupdate not null +- Run init command after production seeder +- Init +- Comma in traefik custom labels +- Ignore if dynamic config could not be set +- Service env variable ovewritten if it has a default value +- Labelling +- Non-ascii chars in labels +- Labels +- Init script echos +- Update Coolify script +- Null notify +- Check queued deployments as well +- Copy invitation +- Password reset / invitation link requests +- Add catch all route +- Revert random container job delay +- Backup executions view +- Only check server status in container status job +- Improve server status check times +- Handle other types of generated values +- Server checking status +- Ui for adding new destination +- Reset domains on compose file change + +### 💼 Other + +- Fix for comma in labels +- Add image name to service stack + better options visibility + +### 🚜 Refactor + +- Service logs are now on one page +- Application status changed realtime +- Custom labels +- Clone project + +## [4.0.0-beta.154] - 2023-12-07 + +### 🚀 Features + +- Execute command in container + +### 🐛 Bug Fixes + +- Container selection +- Service navbar using new realtime events +- Do not create duplicated networks +- Live event +- Service start + event +- Service deletion job +- Double ws connection +- Boarding view + +### 💼 Other + +- Env vars +- Migrate to livewire 3 + +## [4.0.0-beta.124] - 2023-11-13 + +### 🚀 Features + +- Log drain (wip) +- Enable/disable log drain by service +- Log drainer container check +- Add docker engine support install script to rhel based systems +- Save timestamp configuration for logs +- Custom log drain endpoints +- Auto-restart tcp proxies for databases + +### 🐛 Bug Fixes + +- *(fider template)* Use the correct docs url +- Fqdn for minio +- Generate service fields +- Mariadb backups +- When to pull image +- Do not allow to enter local ip addresses +- Reset password +- Only report nonruntime errors +- Handle different label formats in services +- Server adding process +- Show defined resources in server tab, so you will know what you need to delete before you can delete the server. +- Lots of regarding git + docker compose deployments +- Pull request build variables +- Double default password length +- Do not remove deployment in case compose based failed +- No container servers +- Sentry issue +- Dockercompose save ./ volumes under /data/coolify +- Server view for link() +- Default value do not overwrite existing env value +- Use official install script with rancher (one will work for sure) +- Add cf tunnel to boarding server view +- Prevent autorefresh of proxy status +- Missing docker image thing +- Add hc for soketi +- Deploy the right compose file +- Bind volumes for compose bp +- Use hc port 80 in case of static build +- Switching to static build + +### 💼 Other + +- New deployment jobs +- Compose based apps +- Swarm +- Swarm +- Swarm +- Swarm +- Disable trial +- Meilisearch +- Broadcast +- 🌮 + +### 🚜 Refactor + +- Env variable generator + +### ◀️ Revert + +- Wip + +## [4.0.0-beta.109] - 2023-11-06 + +### 🚀 Features + +- Deployment logs fullscreen +- Service database backups +- Make service databases public + +### 🐛 Bug Fixes + +- Missing environment variables prevewi on service +- Invoice.paid should sleep for 5 seconds +- Local dev repo +- Deployments ui +- Dockerfile build pack fix +- Set labels on generate domain +- Network service parse +- Notification url in containerstatusjob +- Gh webhook response 200 to installation_repositories +- Delete destination +- No id found +- Missing $mailMessage +- Set default from/sender names +- No environments +- Telegram text +- Private key not found error +- UI +- Resourcesdelete command +- Port number should be int +- Separate delete with validation of server +- Add nixpacks info +- Remove filter +- Container logs are now followable in full-screen and sorted by timestamp +- Ui for labels +- Ui +- Deletions +- Build_image not found +- Github source view +- Github source view +- Dockercleanupjob should be released back +- Ui +- Local ip address +- Revert workdir to basedir +- Container status jobs for old pr deployments +- Service updates + +## [4.0.0-beta.99] - 2023-10-24 + +### 🚀 Features + +- Improve deployment time by a lot + +### 🐛 Bug Fixes + +- Space in build args +- Lock SERVICE_FQDN envs +- If user is invited, that means its email is verified +- Force password reset on invited accounts +- Add ssh options to git ls-remote +- Git ls-remote +- Remove coolify labels from ui + +### 💼 Other + +- Fix subs + +## [4.0.0-beta.97] - 2023-10-20 + +### 🚀 Features + +- Standalone mongodb +- Cloning project +- Api tokens + deploy webhook +- Start all kinds of things +- Simple search functionality +- Mysql, mariadb +- Lock environment variables +- Download local backups + +### 🐛 Bug Fixes + +- Service docs links +- Add PGUSER to prevent HC warning +- Preselect s3 storage if available +- Port exposes change, shoud regenerate label +- Boarding +- Clone to with the same environment name +- Cleanup stucked resources on start +- Do not allow to delete env if a resource is defined +- Service template generator + appwrite +- Mongodb backup +- Make sure coolfiy network exists on install +- Syncbunny command +- Encrypt mongodb password +- Mongodb healtcheck command +- Rate limit for api + add mariadb + mysql +- Server settings guarded + +### 💼 Other + +- Generate services +- Mongodb backup +- Mongodb backup +- Updates + +## [4.0.0-beta.93] - 2023-10-18 + +### 🚀 Features + +- Able to customize docker labels on applications +- Show if config is not applied + +### 🐛 Bug Fixes + +- Setup:dev script & contribution guide +- Do not show configuration changed if config_hash is null +- Add config_hash if its null (old deployments) +- Label generation +- Labels +- Email channel no recepients +- Limit horizon processes to 2 by default +- Add custom port as ssh option to deploy_key based commands +- Remove custom port from git repo url +- ContainerStatus job + +### 💼 Other + +- PAT by team + +## [4.0.0-beta.92] - 2023-10-17 + +### 🐛 Bug Fixes + +- Proxy start process + +## [4.0.0-beta.91] - 2023-10-17 + +### 🐛 Bug Fixes + +- Always start proxy if not NONE is selected + +### 💼 Other + +- Add helper to service domains + +## [4.0.0-beta.90] - 2023-10-17 + +### 🐛 Bug Fixes + +- Only include config.json if its exists and a file + +### 💼 Other + +- Wordpress + +## [4.0.0-beta.89] - 2023-10-17 + +### 🐛 Bug Fixes + +- Noindex meta tag +- Show docker build logs + +## [4.0.0-beta.88] - 2023-10-17 + +### 🚀 Features + +- Use docker login credentials from server + +## [4.0.0-beta.87] - 2023-10-17 + +### 🐛 Bug Fixes + +- Service status check is a bit better +- Generate fqdn if you deleted a service app, but it requires fqdn +- Cancel any deployments + queue next +- Add internal domain names during build process + +## [4.0.0-beta.86] - 2023-10-15 + +### 🐛 Bug Fixes + +- Build image before starting dockerfile buildpacks + +## [4.0.0-beta.85] - 2023-10-14 + +### 🐛 Bug Fixes + +- Redis URL generated + +## [4.0.0-beta.83] - 2023-10-13 + +### 🐛 Bug Fixes + +- Docker hub URL + +## [4.0.0-beta.70] - 2023-10-09 + +### 🚀 Features + +- Add email verification for cloud +- Able to deploy docker images +- Add dockerfile location +- Proxy logs on the ui +- Add custom redis conf + +### 🐛 Bug Fixes + +- Server validation process +- Fqdn could be null +- Small +- Server unreachable count +- Do not reset unreachable count +- Contact docs +- Check connection +- Server saving +- No env goto envs from dashboard +- Goto +- Tcp proxy for dbs +- Database backups +- Only send email if transactional email set +- Backupfailed notification is forced +- Use port exposed for reverse proxy +- Contact link +- Use only ip addresses for servers +- Deleted team and it is the current one +- Add new team button +- Transactional email link +- Dashboard goto link +- Only require registry image in case of dockerimage bp +- Instant save build pack change +- Public git +- Cannot remove localhost +- Check localhost connection +- Send unreachable/revived notifications +- Boarding + verification +- Make sure proxy wont start in NONE mode +- Service check status 10 sec +- IsCloud in production seeder +- Make sure to use IP address +- Dockerfile location feature +- Server ip could be hostname in self-hosted +- Urls should be password fields +- No backup for redis +- Show database logs in case of its not healthy and running +- Proxy check for ports, do not kill anything listening on port 80/443 +- Traefik dashboard ip +- Db labels +- Docker cleanup jobs +- Timeout for instant remote processes +- Dev containerjobs +- Backup database one-by-one. +- Turn off static deployment if you switch buildpacks + +### 💼 Other + +- Dockerimage +- Updated dashboard +- Fix +- Fix +- Coolify proxy access logs exposed in dev +- Able to select environment on new resource +- Delete server +- Redis + +## [4.0.0-beta.58] - 2023-10-02 + +### 🚀 Features + +- Reset root password +- Attach Coolify defined networks to services +- Delete resource command +- Multiselect removable resources +- Disable service, required version +- Basedir / monorepo initial support +- Init version of any git deployment +- Deploy private repo with ssh key + +### 🐛 Bug Fixes + +- If waitlist is disabled, redirect to register +- Add destination to new services +- Predefined content for files +- Move /data to ./_data in dev +- UI +- Show all storages in one place for services +- Ui +- Add _data to vite ignore +- Only use _ in volume names for services +- Volume names in services +- Volume names +- Service logs visible if the whole service stack is not running +- Ui +- Compose magic +- Compose parser updated +- Dev compose files +- Traefik labels for multiport deployments +- Visible version number +- Remove SERVICE_ from deployable compose +- Delete event to deleting +- Move dev data to volumes to prevent permission issues +- Traefik labelling in case of several http and https domain added +- PR deployments use the first fqdn as base +- Email notifications subscription fixed +- Services - do not remove unnecessary things for now +- Decrease max horizon processes to get lower memory usage +- Test emails only available for user owned smtp/resend +- Ui for self-hosted email settings +- Set smtp notifications on by default +- Select branch on other git +- Private repository +- Contribution guide +- Public repository names +- *(create)* Flex wrap on server & network selection +- Better unreachable/revived server statuses +- Able to set base dir for Dockerfile build pack + +### 💼 Other + +- Uptime kume hc updated +- Switch back to /data (volume errors) +- Notifications +- Add shared email option to everyone + +## [4.0.0-beta.57] - 2023-10-02 + +### 🚀 Features + +- Container logs + +### 🐛 Bug Fixes + +- Always pull helper image in dev +- Only show last 1000 lines +- Service status + +## [4.0.0-beta.47] - 2023-09-28 + +### 🐛 Bug Fixes + +- Next helper image +- Service templates +- Sync:bunny +- Update process if server has been renamed +- Reporting handler +- Localhost privatekey update +- Remove private key in case you removed a github app +- Only show manually added private keys on server view +- Show source on all type of applications +- Docker cleanup should be a job by server +- File/dir based volumes are now read from the server +- Respect server fqdn +- If public repository does not have a main branch +- Preselect branc on private repos +- Deploykey branch +- Backups are now working again +- Not found base_branch in git webhooks +- Coolify db backup +- Preview deployments name, status etc +- Services should have destination as well +- Dockerfile expose is not overwritten +- If app settings is not saved to db +- Do not show subscription cancelled noti +- Show real volume names +- Only parse expose in dockerfiles if ports_exposes is empty +- Add uuid to volume names +- New volumes for services should have - instead of _ + +### 💼 Other + +- Fix previews to preview + +## [4.0.0-beta.46] - 2023-09-28 + +### 🐛 Bug Fixes + +- Containerstatusjob +- Aaaaaaaaaaaaaaaaa +- Services view +- Services +- Manually create network for services +- Disable early updates +- Sslip for localhost +- ContainerStatusJob +- Cannot delete env with available services +- Sync command +- Install script drops an error +- Prevent sync version (it needs an option) +- Instance fqdn setting +- Sentry 4510197209 +- Sentry 4504136641 +- Sentry 4502634789 + +## [4.0.0-beta.45] - 2023-09-24 + +### 🚀 Features + +- Services +- Image tag for services + +### 🐛 Bug Fixes + +- Applications with port mappins do a normal update (not rolling update) +- Put back build pack chooser +- Proxy configuration + starter +- Show real storage name on services +- New service template layout + +### 💼 Other + +- Fixed z-index for version link. +- Add source button +- Fixed z-index for magicbar +- A bit better error +- More visible feedback button +- Update help modal +- Help +- Marketing emails + +## [4.0.0-beta.28] - 2023-09-08 + +### 🚀 Features + +- Telegram topics separation +- Developer view for env variables +- Cache team settings +- Generate public key from private keys +- Able to invite more people at once +- Trial +- Dynamic trial period +- Ssh-agent instead of filesystem based ssh keys +- New container status checks +- Generate ssh key +- Sentry add email for better support +- Healthcheck for apps +- Add cloudflare tunnel support + +### 🐛 Bug Fixes + +- Db backup job +- Sentry 4459819517 +- Sentry 4451028626 +- Ui +- Retry notifications +- Instance email settings +- Ui +- Test email on for admins or custom smtp +- Coolify already exists should not throw error +- Delete database related things when delete database +- Remove -q from docker compose +- Errors in views +- Only send internal notifcations to enabled channels +- Recovery code +- Email sending error +- Sentry 4469575117 +- Old docker version error +- Errors +- Proxy check, reduce jobs, etc +- Queue after commit +- Remove nixpkgarchive +- Remove nixpkgarchive from ui +- Webhooks should not run if server is not functional +- Server is functional check +- Confirm email before sending +- Help should send cc on email +- Sub type +- Show help modal everywhere +- Forgot password +- Disable dockerfile based healtcheck for now +- Add timeout for ssh commands +- Prevent weird ui bug for validateServer +- Lowercase email in forgot password +- Lower case email on waitlist +- Encrypt jobs +- ProcessWithEnv()->run +- Plus boarding step about Coolify +- SaveConfigurationSync +- Help uri +- Sub for root +- Redirect on server not found +- Ip check +- Uniqueips +- Simply reply to help messages +- Help +- Rate limit +- Collect billing address +- Invitation +- Smtp view +- Ssh-agent revert +- Restarting container state on ui +- Generate new key +- Missing upgrade js +- Team error +- 4.0.0-beta.37 +- Localhost +- Proxy start (if not proxy defined, use Traefik) +- Do not remove localhost in boarding +- Allow non ip address (DNS) +- InstallDocker id not found +- Boarding +- Errors +- Proxy container status +- Proxy configuration saving +- Convert startProxy to action +- Stop/start UI on apps and dbs +- Improve localhost boarding process +- Try to use old docker-compose +- Boarding again +- Send internal notifications of email errors +- Add github app change on new app view +- Delete environment variables on app/db delete +- Save proxy configuration +- Add proxy to network with periodic check +- Proxy connections +- Delete persistent storages on resource deletion +- Prevent overwrite already existing env variables in services +- Mappings +- Sentry issue 4478125289 +- Make sure proxy path created +- StartProxy +- Server validation with cf tunnels +- Only show traefik dashboard if its available +- Services +- Database schema +- Report livewire errors +- Links with path +- Add traefik labels no matter if traefik is selected or not +- Add expose port for containers +- Also check docker socks permission on validation + +### 💼 Other + +- User should know that the public key +- Services are not availble yet +- Show registered users on waitlist page +- Nixpacksarchive +- Add Plausible analytics +- Global env variables +- Fix +- Trial emails +- Server check instead of app check +- Show trial instead of sub +- Server lost connection +- Services +- Services +- Services +- Ui for services +- Services +- Services +- Services +- Fixes +- Fix typo + +## [4.0.0-beta.27] - 2023-09-08 + +### 🐛 Bug Fixes + +- Bug + +## [4.0.0-beta.26] - 2023-09-08 + +### 🚀 Features + +- Public database + +## [4.0.0-beta.25] - 2023-09-07 + +### 🐛 Bug Fixes + +- SaveModel email settings + +## [4.0.0-beta.24] - 2023-09-06 + +### 🚀 Features + +- Send request in cloud +- Add discord notifications + +### 🐛 Bug Fixes + +- Form address +- Show hosted email service, just disable for non pro subs +- Add navbar for source + keys +- Add docker network to build process +- Overlapping apps +- Do not show system wide git on cloud +- Lowercase image names +- Typo + +### 💼 Other + +- Backup existing database + +## [4.0.0-beta.23] - 2023-09-01 + +### 🐛 Bug Fixes + +- Sentry bug +- Button loading animation + +## [4.0.0-beta.22] - 2023-09-01 + +### 🚀 Features + +- Add resend as transactional emails + +### 🐛 Bug Fixes + +- DockerCleanupjob +- Validation +- Webhook endpoint in cloud and no system wide gh app +- Subscriptions +- Password confirmation +- Proxy start job +- Dockerimage jobs are not overlapping + +## [4.0.0-beta.21] - 2023-08-27 + +### 🚀 Features + +- Invite by email from waitlist +- Rolling update + +### 🐛 Bug Fixes + +- Limits & server creation page +- Fqdn on apps + +### 💼 Other + +- Boarding + +## [4.0.0-beta.20] - 2023-08-17 + +### 🚀 Features + +- Send internal notification to discord +- Monitor server connection + +### 🐛 Bug Fixes + +- Make coolify-db backups unique dir + +## [4.0.0-beta.19] - 2023-08-15 + +### 🚀 Features + +- Pricing plans ans subs +- Add s3 storages +- Init postgresql database +- Add backup notifications +- Dockerfile build pack +- Cloud +- Force password reset + waitlist + +### 🐛 Bug Fixes + +- Remove buggregator from dev +- Able to change localhost's private key +- Readonly input box +- Notifications +- Licensing +- Subscription link +- Migrate db schema for smtp + discord +- Text field +- Null fqdn notifications +- Remove old modal +- Proxy stop/start ui +- Proxy UI +- Empty description +- Input and textarea +- Postgres_username name to not name, lol +- DatabaseBackupJob.php +- No storage +- Backup now button +- Ui + subscription +- Self-hosted + +### 💼 Other + +- Scheduled backups + +## [4.0.0-beta.18] - 2023-07-14 + +### 🚀 Features + +- Able to control multiplexing +- Add runRemoteCommandSync +- Github repo with deployment key +- Add persistent volumes +- Debuggable executeNow commands +- Add private gh repos +- Delete gh app +- Installation/update github apps +- Auto-deploy +- Deploy key based deployments +- Resource limits +- Long running queue with 1 hour of timeout +- Add arm build to dev +- Disk cleanup threshold by server +- Notify user of disk cleanup init + +### 🐛 Bug Fixes + +- Logo of CCCareers +- Typo +- Ssh +- Nullable name on deploy_keys +- Enviroments +- Remove dd - oops +- Add inprogress activity +- Application view +- Only set status in case the last command block is finished +- Poll activity +- Small typo +- Show activity on load +- Deployment should fail on error +- Tests +- Version +- Status not needed +- No project redirect +- Gh actions +- Set status +- Seeders +- Do not modify localhost +- Deployment_uuid -> type_uuid +- Read env from config, bc of cache +- Private key change view +- New destination +- Do not update next channel all the time +- Cancel deployment button +- Public repo limit shown + branch should be preselected. +- Better status on ui for apps +- Arm coolify version +- Formatting +- Gh actions +- Show github app secrets +- Do not force next version updates +- Debug log button +- Deployment key based works +- Deployment cancel/debug buttons +- Upgrade button +- Changing static build changes port +- Overwrite default nginx configuration +- Do not overlap docker image names +- Oops +- Found image name +- Name length +- Semicolons encoding by traefik +- Base_dir wip & outputs +- Cleanup docker images +- Nginx try_files +- Master is the default, not main +- No ms in rate limit resets +- Loading after button text +- Default value +- Localhost is usable +- Update docker-compose prod +- Cloud/checkoutid/lms +- Type of license code +- More verbose error +- Version lol +- Update prod compose +- Version + +### 💼 Other + +- Extract process handling from async job. +- Extract process handling from async job. +- Extract process handling from async job. +- Extract process handling from async job. +- Extract process handling from async job. +- Extract process handling from async job. +- Extract process handling from async job. +- Persisting data + +## [3.12.28] - 2023-03-16 + +### 🐛 Bug Fixes + +- Revert from dockerhub if ghcr.io does not exists + +## [3.12.27] - 2023-03-07 + +### 🐛 Bug Fixes + +- Show ip address as host in public dbs + +## [3.12.24] - 2023-03-04 + +### 🐛 Bug Fixes + +- Nestjs buildpack + +## [3.12.22] - 2023-03-03 + +### 🚀 Features + +- Add host path to any container + +### 🐛 Bug Fixes + +- Set PACK_VERSION to 0.27.0 +- PublishDirectory +- Host volumes +- Replace . & .. & $PWD with ~ +- Handle log format volumes + +## [3.12.19] - 2023-02-20 + +### 🚀 Features + +- Github raw icon url +- Remove svg support + +### 🐛 Bug Fixes + +- Typos in docs +- Url +- Network in compose files +- Escape new line chars in wp custom configs +- Applications cannot be deleted +- Arm servics +- Base directory not found +- Cannot delete resource when you are not on root team +- Empty port in docker compose + +## [3.12.18] - 2023-01-24 + +### 🐛 Bug Fixes + +- CleanupStuckedContainers +- CleanupStuckedContainers + +## [3.12.16] - 2023-01-20 + +### 🐛 Bug Fixes + +- Stucked containers + +## [3.12.15] - 2023-01-20 + +### 🐛 Bug Fixes + +- Cleanup function +- Cleanup stucked containers +- Deletion + cleanupStuckedContainers + +## [3.12.14] - 2023-01-19 + +### 🐛 Bug Fixes + +- Www redirect + +## [3.12.13] - 2023-01-18 + +### 🐛 Bug Fixes + +- Secrets + +## [3.12.12] - 2023-01-17 + +### 🚀 Features + +- Init h2c (http2/grpc) support +- Http + h2c paralel + +### 🐛 Bug Fixes + +- Build args docker compose +- Grpc + +## [3.12.11] - 2023-01-16 + +### 🐛 Bug Fixes + +- Compose file location +- Docker log sequence +- Delete apps with previews +- Do not cleanup compose applications as unconfigured +- Build env variables with docker compose +- Public gh repo reload compose + +### 💼 Other + +- Trpc +- Trpc +- Trpc +- Trpc +- Trpc +- Trpc +- Trpc + +## [3.12.10] - 2023-01-11 + +### 💼 Other + +- Add missing variables + +## [3.12.9] - 2023-01-11 + +### 🚀 Features + +- Add Openblocks icon +- Adding icon for whoogle +- *(ui)* Add libretranslate service icon +- Handle invite_only plausible analytics + +### 🐛 Bug Fixes + +- Custom gitlab git user +- Add documentation link again +- Remove prefetches +- Doc link +- Temporary disable dns check with dns servers +- Local images for reverting +- Secrets + +## [3.12.8] - 2022-12-27 + +### 🐛 Bug Fixes + +- Parsing secrets +- Read-only permission +- Read-only iam +- $ sign in secrets + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.12.5] - 2022-12-26 + +### 🐛 Bug Fixes + +- Remove unused imports + +### 💼 Other + +- Conditional on environment + +## [3.12.2] - 2022-12-19 + +### 🐛 Bug Fixes + +- Appwrite tmp volume +- Do not replace secret +- Root user for dbs on arm +- Escape secrets +- Escape env vars +- Envs +- Docker buildpack env +- Secrets with newline +- Secrets +- Add default node_env variable +- Add default node_env variable +- Secrets +- Secrets +- Gh actions +- Duplicate env variables +- Cleanupstorage + +### 💼 Other + +- Trpc +- Trpc +- Trpc +- Trpc +- Trpc +- Trpc +- Trpc +- Trpc +- Trpc +- Trpc + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.12.1] - 2022-12-13 + +### 🐛 Bug Fixes + +- Build commands +- Migration file +- Adding missing appwrite volume + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.12.0] - 2022-12-09 + +### 🚀 Features + +- Use registry for building +- Docker registries working +- Custom docker compose file location in repo +- Save doNotTrackData to db +- Add default sentry +- Do not track in settings +- System wide git out of beta +- Custom previewseparator +- Sentry frontend +- Able to host static/php sites on arm +- Save application data before deploying +- SimpleDockerfile deployment +- Able to push image to docker registry +- Revert to remote image +- *(api)* Name label + +### 🐛 Bug Fixes + +- 0 destinations redirect after creation +- Seed +- Sentry dsn update +- Dnt +- Ui +- Only visible with publicrepo +- Migrations +- Prevent webhook errors to be logged +- Login error +- Remove beta from systemwide git +- Git checkout +- Remove sentry before migration +- Webhook previewseparator +- Apache on arm +- Update PR/MRs with new previewSeparator +- Static for arm +- Failed builds should not push images +- Turn off autodeploy for simpledockerfiles +- Security hole +- Rde +- Delete resource on dashboard +- Wrong port in case of docker compose +- Public db icon on dashboard +- Cleanup + +### 💼 Other + +- Pocketbase release + +## [3.11.10] - 2022-11-16 + +### 🚀 Features + +- Only show expose if no proxy conf defined in template +- Custom/private docker registries + +### 🐛 Bug Fixes + +- Local dev api/ws urls +- Wrong template/type +- Gitea icon is svg +- Gh actions +- Gh actions +- Replace $$generate vars +- Webhook traefik +- Exposed ports +- Wrong icons on dashboard +- Escape % in secrets +- Move debug log settings to build logs +- Storage for compose bp + debug on +- Hasura admin secret +- Logs +- Mounts +- Load logs after build failed +- Accept logged and not logged user in /base +- Remote haproxy password/etc +- Remove hardcoded sentry dsn +- Nope in database strings + +### ⚙️ Miscellaneous Tasks + +- Version++ +- Version++ +- Version++ +- Version++ + +## [3.11.9] - 2022-11-15 + +### 🐛 Bug Fixes + +- IsBot issue + +## [3.11.8] - 2022-11-14 + +### 🐛 Bug Fixes + +- Default icon for new services + +## [3.11.1] - 2022-11-08 + +### 🚀 Features + +- Rollback coolify + +### 🐛 Bug Fixes + +- Remove contribution docs +- Umami template +- Compose webhooks fixed +- Variable replacements +- Doc links +- For rollback +- N8n and weblate icon +- Expose ports for services +- Wp + mysql on arm +- Show rollback button loading +- No tags error +- Update on mobile +- Dashboard error +- GetTemplates +- Docker compose persistent volumes +- Application persistent storage things +- Volume names for undefined volume names in compose +- Empty secrets on UI +- Ports for services + +### 💼 Other + +- Secrets on apps +- Fix +- Fixes +- Reload compose loading + +### 🚜 Refactor + +- Code + +### ⚙️ Miscellaneous Tasks + +- Version++ +- Add jda icon for lavalink service +- Version++ + +### ◀️ Revert + +- Revert: revert + +## [3.11.0] - 2022-11-07 + +### 🚀 Features + +- Initial support for specific git commit +- Add default to latest commit and support for gitlab +- Redirect catch-all rule + +### 🐛 Bug Fixes + +- Secret errors +- Service logs +- Heroku bp +- Expose port is readonly on the wrong condition +- Toast +- Traefik proxy q 10s +- App logs view +- Tooltip +- Toast, rde, webhooks +- Pathprefix +- Load public repos +- Webhook simplified +- Remote webhooks +- Previews wbh +- Webhooks +- Websecure redirect +- Wb for previews +- Pr stopps main deployment +- Preview wbh +- Wh catchall for all +- Remove old minio proxies +- Template files +- Compose icon +- Templates +- Confirm restart service +- Template +- Templates +- Templates +- Plausible analytics things +- Appwrite webhook +- Coolify instance proxy +- Migrate template +- Preview webhooks +- Simplify webhooks +- Remove ghost-mariadb from the list +- More simplified webhooks +- Umami + ghost issues + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.10.16] - 2022-10-12 + +### 🐛 Bug Fixes + +- Single container logs and usage with compose + +### 💼 Other + +- New resource label + +## [3.10.15] - 2022-10-12 + +### 🚀 Features + +- Monitoring by container + +### 🐛 Bug Fixes + +- Do not show nope as ip address for dbs +- Add git sha to build args +- Smart search for new services +- Logs for not running containers +- Update docker binaries +- Gh release +- Dev container +- Gitlab auth and compose reload +- Check compose domains in general +- Port required if fqdn is set +- Appwrite v1 missing containers +- Dockerfile +- Pull does not work remotely on huge compose file + +### ⚙️ Miscellaneous Tasks + +- Update staging release + +## [3.10.14] - 2022-10-05 + +### 🚀 Features + +- Docker compose support +- Docker compose +- Docker compose + +### 🐛 Bug Fixes + +- Do not use npx +- Pure docker based development + +### 💼 Other + +- Docker-compose support +- Docker compose +- Remove worker jobs +- One less worker thread + +### 🧪 Testing + +- Remove prisma + +## [3.10.5] - 2022-09-26 + +### 🚀 Features + +- Add migration button to appwrite +- Custom certificate +- Ssl cert on traefik config +- Refresh resource status on dashboard +- Ssl certificate sets custom ssl for applications +- System-wide github apps +- Cleanup unconfigured applications +- Cleanup unconfigured services and databases + +### 🐛 Bug Fixes + +- Ui +- Tooltip +- Dropdown +- Ssl certificate distribution +- Db migration +- Multiplex ssh connections +- Able to search with id +- Not found redirect +- Settings db requests +- Error during saving logs +- Consider base directory in heroku bp +- Basedirectory should be empty if null +- Allow basedirectory for heroku +- Stream logs for heroku bp +- Debug log for bp +- Scp without host verification & cert copy +- Base directory & docker bp +- Laravel php chooser +- Multiplex ssh and ssl copy +- Seed new preview secret types +- Error notification +- Empty preview value +- Error notification +- Seed +- Service logs +- Appwrite function network is not the default +- Logs in docker bp +- Able to delete apps in unconfigured state +- Disable development low disk space +- Only log things to console in dev mode +- Do not get status of more than 10 resources defined by category +- BaseDirectory +- Dashboard statuses +- Default buildImage and baseBuildImage +- Initial deploy status +- Show logs better +- Do not start tcp proxy without main container +- Cleanup stucked tcp proxies +- Default 0 pending invitations +- Handle forked repositories +- Typo +- Pr branches +- Fork pr previews +- Remove unnecessary things +- Meilisearch data dir +- Verify and configure remote docker engines +- Add buildkit features +- Nope if you are not logged in + +### 💼 Other + +- Responsive! +- Fixes +- Fix git icon +- Dropdown as infobox +- Small logs on mobile +- Improvements +- Fix destination view +- Settings view +- More UI improvements +- Fixes +- Fixes +- Fix +- Fixes +- Beta features +- Fix button +- Service fixes +- Fix basedirectory meaning +- Resource button fix +- Main resource search +- Dev logs +- Loading button +- Fix gitlab importer view +- Small fix +- Beta flag +- Hasura console notification +- Fix +- Fix +- Fixes +- Inprogress version of iam +- Fix indicato +- Iam & settings update +- Send 200 for ping and installation wh +- Settings icon + +### ⚙️ Miscellaneous Tasks + +- Version++ +- Version++ +- Version++ +- Version++ +- Version++ +- Version++ +- Version++ + +### ◀️ Revert + +- Show usage everytime + +## [3.10.2] - 2022-09-11 + +### 🚀 Features + +- Add queue reset button +- Previewapplications init +- PreviewApplications finalized +- Fluentbit +- Show remote servers +- *(layout)* Added drawer when user is in mobile +- Re-apply ui improves +- *(ui)* Improve header of pages +- *(styles)* Make header css component +- *(routes)* Improve ui for apps, databases and services logs + +### 🐛 Bug Fixes + +- Changing umami image URL to get latest version +- Gitlab importer for public repos +- Show error logs +- Umami init sql +- Plausible analytics actions +- Login +- Dev url +- UpdateMany build logs +- Fallback to db logs +- Fluentbit configuration +- Coolify update +- Fluentbit and logs +- Canceling build +- Logging +- Load more +- Build logs +- Versions of appwrite +- Appwrite?! +- Get building status +- Await +- Await #2 +- Update PR building status +- Appwrite default version 1.0 +- Undead endpoint does not require JWT +- *(routes)* Improve design of application page +- *(routes)* Improve design of git sources page +- *(routes)* Ui from destinations page +- *(routes)* Ui from databases page +- *(routes)* Ui from databases page +- *(routes)* Ui from databases page +- *(routes)* Ui from services page +- *(routes)* More ui tweaks +- *(routes)* More ui tweaks +- *(routes)* More ui tweaks +- *(routes)* More ui tweaks +- *(routes)* Ui from settings page +- *(routes)* Duplicates classes in services page +- *(routes)* Searchbar ui +- Github conflicts +- *(routes)* More ui tweaks +- *(routes)* More ui tweaks +- *(routes)* More ui tweaks +- *(routes)* More ui tweaks +- Ui with headers +- *(routes)* Header of settings page in databases +- *(routes)* Ui from secrets table + +### 💼 Other + +- Fix plausible +- Fix cleanup button +- Fix buttons + +### ⚙️ Miscellaneous Tasks + +- Version++ +- Minor changes +- Minor changes +- Minor changes +- Whoops + +## [3.10.1] - 2022-09-10 + +### 🐛 Bug Fixes + +- Show restarting apps +- Show restarting application & logs +- Remove unnecessary gitlab group name +- Secrets for PR +- Volumes for services +- Build secrets for apps +- Delete resource use window location + +### 💼 Other + +- Fix button +- Fix follow button +- Arm should be on next all the time + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.10.0] - 2022-09-08 + +### 🚀 Features + +- New servers view + +### 🐛 Bug Fixes + +- Change to execa from utils +- Save search input +- Ispublic status on databases +- Port checkers +- Ui variables +- Glitchtip env to pyhton boolean +- Autoupdater + +### 💼 Other + +- Dashboard updates +- Fix tooltip + +## [3.9.4] - 2022-09-07 + +### 🐛 Bug Fixes + +- DnsServer formatting +- Settings for service + +## [3.9.3] - 2022-09-07 + +### 🐛 Bug Fixes + +- Pr previews + +## [3.9.2] - 2022-09-07 + +### 🚀 Features + +- Add traefik acme json to coolify container +- Database secrets + +### 🐛 Bug Fixes + +- Gitlab webhook +- Use ip address instead of window location +- Use ip instead of window location host +- Service state update +- Add initial DNS servers +- Revert last change with domain check +- Service volume generation +- Minio default env variables +- Add php 8.1/8.2 +- Edgedb ui +- Edgedb stuff +- Edgedb + +### 💼 Other + +- Fix login/register page +- Update devcontainer +- Add debug log +- Fix initial loading icon bg +- Fix loading start/stop db/services +- Dashboard updates and a lot more + +### ⚙️ Miscellaneous Tasks + +- Version++ +- Version++ + +## [3.9.0] - 2022-09-06 + +### 🐛 Bug Fixes + +- Debug api logging + gh actions +- Workdir +- Move restart button to settings + +## [3.9.1-rc.1] - 2022-09-06 + +### 🚀 Features + +- *(routes)* Rework ui from login and register page + +### 🐛 Bug Fixes + +- Ssh pid agent name +- Repository link trim +- Fqdn or expose port required +- Service deploymentEnabled +- Expose port is not required +- Remote verification +- Dockerfile + +### 💼 Other + +- Database_branches +- Login page + +### ⚙️ Miscellaneous Tasks + +- Version++ +- Version++ + +## [3.9.0-rc.1] - 2022-09-02 + +### 🚀 Features + +- New service - weblate +- Restart application +- Show elapsed time on running builds +- Github allow fual branches +- Gitlab dual branch +- Taiga + +### 🐛 Bug Fixes + +- Glitchtip things +- Loading state on start +- Ui +- Submodule +- Gitlab webhooks +- UI + refactor +- Exposedport on save +- Appwrite letsencrypt +- Traefik appwrite +- Traefik +- Finally works! :) +- Rename components + remove PR/MR deployment from public repos +- Settings missing id +- Explainer component +- Database name on logs view +- Taiga + +### 💼 Other + +- Fixes +- Change tooltips and info boxes +- Added rc release + +### 🧪 Testing + +- Native binary target +- Dockerfile + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.8.9] - 2022-08-30 + +### 🐛 Bug Fixes + +- Oh god Prisma + +## [3.8.8] - 2022-08-30 + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.8.6] - 2022-08-30 + +### 🐛 Bug Fixes + +- Pr deployment +- CompareVersions +- Include +- Include +- Gitlab apps + +### 💼 Other + +- Fixes +- Route to the correct path when creating destination from db config + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.8.5] - 2022-08-27 + +### 🐛 Bug Fixes + +- Copy all files during install process +- Typo +- Process +- White labeled icon on navbar +- Whitelabeled icon +- Next/nuxt deployment type +- Again + +## [3.8.4] - 2022-08-27 + +### 🐛 Bug Fixes + +- UI thinkgs +- Delete team while it is active +- Team switching +- Queue cleanup +- Decrypt secrets +- Cleanup build cache as well +- Pr deployments + remove public gits + +### 💼 Other + +- Dashbord fixes +- Fixes + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.8.3] - 2022-08-26 + +### 🐛 Bug Fixes + +- Secrets decryption + +## [3.8.2] - 2022-08-26 + +### 🚀 Features + +- *(ui)* Rework home UI and with responsive design + +### 🐛 Bug Fixes + +- Never stop deplyo queue +- Build queue system +- High cpu usage +- Worker +- Better worker system + +### 💼 Other + +- Dashboard fine-tunes +- Fine-tune +- Fixes +- Fix + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.8.1] - 2022-08-24 + +### 🐛 Bug Fixes + +- Ui buttons +- Clear queue on cancelling jobs +- Cancelling jobs +- Dashboard for admins + +## [3.8.0] - 2022-08-23 + +### 🚀 Features + +- Searxng service + +### 🐛 Bug Fixes + +- Port checker +- Cancel build after 5 seconds +- ExposedPort checker +- Batch secret = +- Dashboard for non-root users +- Stream build logs +- Show build log start/end + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.7.0] - 2022-08-19 + +### 🚀 Features + +- Add GlitchTip service + +### 🐛 Bug Fixes + +- Missing commas +- ExposedPort is just optional + +### ⚙️ Miscellaneous Tasks + +- Add .pnpm-store in .gitignore +- Version++ + +## [3.6.0] - 2022-08-18 + +### 🚀 Features + +- Import public repos (wip) +- Public repo deployment +- Force rebuild + env.PORT for port + public repo build + +### 🐛 Bug Fixes + +- Bots without exposed ports + +### 💼 Other + +- Fixes here and there + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.5.2] - 2022-08-17 + +### 🐛 Bug Fixes + +- Restart containers on-failure instead of always +- Show that Ghost values could be changed + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.5.1] - 2022-08-17 + +### 🐛 Bug Fixes + +- Revert docker compose version to 2.6.1 +- Trim secrets + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.5.0] - 2022-08-17 + +### 🚀 Features + +- Deploy bots (no domains) +- Custom dns servers + +### 🐛 Bug Fixes + +- Dns button ui +- Bot deployments +- Bots +- AutoUpdater & cleanupStorage jobs + +### 💼 Other + +- Typing + +## [3.4.0] - 2022-08-16 + +### 🚀 Features + +- Appwrite service +- Heroku deployments + +### 🐛 Bug Fixes + +- Replace docker compose with docker-compose on CSB +- Dashboard ui +- Create coolify-infra, if it does not exists +- Gitpod conf and heroku buildpacks +- Appwrite +- Autoimport + readme +- Services import +- Heroku icon +- Heroku icon + +## [3.3.4] - 2022-08-15 + +### 🐛 Bug Fixes + +- Make it public button +- Loading indicator + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.3.3] - 2022-08-14 + +### 🐛 Bug Fixes + +- Decryption errors +- Postgresql on ARM + +## [3.3.2] - 2022-08-12 + +### 🐛 Bug Fixes + +- Debounce dashboard status requests + +### 💼 Other + +- Fider + +## [3.3.1] - 2022-08-12 + +### 🐛 Bug Fixes + +- Empty buildpack icons + +## [3.2.3] - 2022-08-12 + +### 🚀 Features + +- Databases on ARM +- Mongodb arm support +- New dashboard + +### 🐛 Bug Fixes + +- Cleanup stucked prisma-engines +- Toast +- Secrets +- Cleanup prisma engine if there is more than 1 +- !isARM to isARM +- Enterprise GH link + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.2.2] - 2022-08-11 + +### 🐛 Bug Fixes + +- Coolify-network on verification + +## [3.2.1] - 2022-08-11 + +### 🚀 Features + +- Init heroku buildpacks + +### 🐛 Bug Fixes + +- Follow/cancel buttons +- Only remove coolify managed containers +- White-labeled env +- Schema + +### 💼 Other + +- Fix + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.2.0] - 2022-08-11 + +### 🚀 Features + +- Persistent storage for all services +- Cleanup clickhouse db + +### 🐛 Bug Fixes + +- Rde local ports +- Empty remote destinations could be removed +- Tips +- Lowercase issues fider +- Tooltip colors +- Update clickhouse configuration +- Cleanup command +- Enterprise Github instance endpoint + +### 💼 Other + +- Local ssh port +- Redesign a lot +- Fixes +- Loading indicator for plausible buttons + +## [3.1.4] - 2022-08-01 + +### 🚀 Features + +- Moodle init +- Remote docker engine init +- Working on remote docker engine +- Rde +- Remote docker engine +- Ipv4 and ipv6 +- Contributors +- Add arch to database +- Stop preview deployment + +### 🐛 Bug Fixes + +- Settings from api +- Selectable destinations +- Gitpod hardcodes +- Typo +- Typo +- Expose port checker +- States and exposed ports +- CleanupStorage +- Remote traefik webhook +- Remote engine ip address +- RemoteipAddress +- Explanation for remote engine url +- Tcp proxy +- Lol +- Webhook +- Dns check for rde +- Gitpod +- Revert last commit +- Dns check +- Dns checker +- Webhook +- Df and more debug +- Webhooks +- Load previews async +- Destination icon +- Pr webhook +- Cache image +- No ssh key found +- Prisma migration + update of docker and stuffs +- Ui +- Ui +- Only 1 ssh-agent is needed +- Reuse ssh connection +- Ssh tunnel +- Dns checking +- Fider BASE_URL set correctly + +### 💼 Other + +- Error message https://github.com/coollabsio/coolify/issues/502 +- Changes +- Settings +- For removing app + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.1.3] - 2022-07-18 + +### 🚀 Features + +- Init moodle and separate stuffs to shared package + +### 🐛 Bug Fixes + +- More types for API +- More types +- Do not rebuild in case image exists and sha not changed +- Gitpod urls +- Remove new service start process +- Remove shared dir, deployment does not work +- Gitlab custom url +- Location url for services and apps + +## [3.1.2] - 2022-07-14 + +### 🐛 Bug Fixes + +- Admin password reset should not timeout +- Message for double branches +- Turn off autodeploy if double branch is configured + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.1.1] - 2022-07-13 + +### 🚀 Features + +- Gitpod integration + +### 🐛 Bug Fixes + +- Cleanup less often and can do it manually + +### ⚙️ Miscellaneous Tasks + +- Version++ +- Version++ + +## [3.1.0] - 2022-07-12 + +### 🚀 Features + +- Ability to change deployment type for nextjs +- Ability to change deployment type for nuxtjs +- Gitpod ready code(almost) +- Add Docker buildpack exposed port setting +- Custom port for git instances + +### 🐛 Bug Fixes + +- GitLab pagination load data +- Service domain checker +- Wp missing ftp solution +- Ftp WP issues +- Ftp?! +- Gitpod updates +- Gitpod +- Gitpod +- Wordpress FTP permission issues +- GitLab search fields +- GitHub App button +- GitLab loop on misconfigured source +- Gitpod + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [3.0.3] - 2022-07-06 + +### 🐛 Bug Fixes + +- Domain check +- Domain check +- TrustProxy for Fastify +- Hostname issue + +## [3.0.2] - 2022-07-06 + +### 🐛 Bug Fixes + +- New destination can be created +- Include post +- New destinations + +## [3.0.1] - 2022-07-06 + +### 🐛 Bug Fixes + +- Seeding +- Forgot that the version bump changed 😅 + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.9.11] - 2022-06-20 + +### 🐛 Bug Fixes + +- Be able to change database + service versions +- Lock file + +## [2.9.10] - 2022-06-17 + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.9.9] - 2022-06-10 + +### 🐛 Bug Fixes + +- Host and reload for uvicorn +- Remove package-lock + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.9.8] - 2022-06-10 + +### 🐛 Bug Fixes + +- Persistent nocodb +- Nocodb persistency + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.9.7] - 2022-06-09 + +### 🐛 Bug Fixes + +- Plausible custom script +- Plausible script and middlewares +- Remove console log +- Remove comments +- Traefik middleware + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.9.6] - 2022-06-02 + +### 🐛 Bug Fixes + +- Fider changed an env variable name +- Pnpm command + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.9.5] - 2022-06-02 + +### 🐛 Bug Fixes + +- Proxy stop missing argument + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.9.4] - 2022-06-01 + +### 🐛 Bug Fixes + +- Demo version forms +- Typo +- Revert gh and gl cloning + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.9.3] - 2022-05-31 + +### 🐛 Bug Fixes + +- Recurisve clone instead of submodule +- Versions +- Only reconfigure coolify proxy if its missconfigured + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.9.2] - 2022-05-31 + +### 🐛 Bug Fixes + +- TrustProxy +- Force restart proxy +- Only restart coolify proxy in case of version prior to 2.9.2 +- Force restart proxy on seeding +- Add GIT ENV variable for submodules + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.9.1] - 2022-05-31 + +### 🐛 Bug Fixes + +- GitHub fixes + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.9.0] - 2022-05-31 + +### 🚀 Features + +- PageLoader +- Database + service usage + +### 🐛 Bug Fixes + +- Service checks +- Remove console.log +- Traefik +- Remove debug things +- WIP Traefik +- Proxy for http +- PR deployments view +- Minio urls + domain checks +- Remove gh token on git source changes +- Do not fetch app state in case of missconfiguration +- Demo instance save domain instantly +- Instant save on demo instance +- New source canceled view +- Lint errors in database services +- Otherfqdns +- Host key verification +- Ftp connection + +### 💼 Other + +- Appwrite +- Testing WS +- Traefik?! +- Traefik +- Traefik +- Traefik migration +- Traefik +- Traefik +- Traefik +- Notifications and application usage +- *(fix)* Traefik +- Css + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.8.2] - 2022-05-16 + +### 🐛 Bug Fixes + +- Gastby buildpack + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.8.1] - 2022-05-10 + +### 🐛 Bug Fixes + +- WP custom db +- UI + +## [2.6.1] - 2022-05-03 + +### 🚀 Features + +- Basic server usage on dashboard +- Show usage trends +- Usage on dashboard +- Custom script path for Plausible +- WP could have custom db +- Python image selection + +### 🐛 Bug Fixes + +- ExposedPorts +- Logos for dbs +- Do not run SSL renew in development +- Check domain for coolify before saving +- Remove debug info +- Cancel jobs +- Cancel old builds in database +- Better DNS check to prevent errors +- Check DNS in prod only +- DNS check +- Disable sentry for now +- Cancel +- Sentry +- No image for Docker buildpack +- Default packagemanager +- Server usage only shown for root team +- Expose ports for services +- UI +- Navbar UI +- UI +- UI +- Remove RC python +- UI +- UI +- UI +- Default Python package + +### ⚙️ Miscellaneous Tasks + +- Version++ +- Version++ +- Version++ +- Version++ + +## [2.6.0] - 2022-05-02 + +### 🚀 Features + +- Hasura as a service +- Gzip compression +- Laravel buildpack is working! +- Laravel +- Fider service +- Database and services logs +- DNS check settings for SSL generation +- Cancel builds! + +### 🐛 Bug Fixes + +- Unami svg size +- Team switching moved to IAM menu +- Always use IP address for webhooks +- Remove unnecessary test endpoint +- UI +- Migration +- Fider envs +- Checking low disk space +- Build image +- Update autoupdate env variable +- Renew certificates +- Webhook build images +- Missing node versions + +### 💼 Other + +- Laravel + +## [2.4.11] - 2022-04-20 + +### 🚀 Features + +- Deno DB migration +- Show exited containers on UI & better UX +- Query container state periodically +- Install svelte-18n and init setup +- Umami service +- Coolify auto-updater +- Autoupdater +- Select base image for buildpacks + +### 🐛 Bug Fixes + +- Deno configurations +- Text on deno buildpack +- Correct branch shown in build logs +- Vscode permission fix +- I18n +- Locales +- Application logs is not reversed and queried better +- Do not activate i18n for now +- GitHub token cleanup on team switch +- No logs found +- Code cleanups +- Reactivate posgtres password +- Contribution guide +- Simplify list services +- Contribution +- Contribution guide +- Contribution guide +- Packagemanager finder + +### 💼 Other + +- Umami service +- Base image selector + +### 📚 Documentation + +- How to add new services +- Update +- Update + +### ⚙️ Miscellaneous Tasks + +- Version++ +- Version++ +- Version++ + +## [2.4.10] - 2022-04-17 + +### 🚀 Features + +- Add persistent storage for services +- Multiply dockerfile locations for docker buildpack +- Testing fluentd logging driver +- Fluentbit investigation +- Initial deno support + +### 🐛 Bug Fixes + +- Switch from bitnami/redis to normal redis +- Use redis-alpine +- Wordpress extra config +- Stop sFTP connection on wp stop +- Change user's id in sftp wp instance +- Use arm based certbot on arm +- Buildlog line number is not string +- Application logs paginated +- Switch to stream on applications logs +- Scroll to top for logs +- Pull new images for services all the time it's started. +- White-labeled custom logo +- Application logs + +### 💼 Other + +- Show extraconfig if wp is running + +### ⚙️ Miscellaneous Tasks + +- Version++ +- Version++ + +## [2.4.9] - 2022-04-14 + +### 🐛 Bug Fixes + +- Postgres root pw is pw field +- Teams view +- Improved tcp proxy monitoring for databases/ftp +- Add HTTP proxy checks +- Loading of new destinations +- Better performance for cleanup images +- Remove proxy container in case of dependent container is down +- Restart local docker coolify proxy in case of something happens to it +- Id of service container + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.4.8] - 2022-04-13 + +### 🐛 Bug Fixes + +- Register should happen if coolify proxy cannot be started +- GitLab typo +- Remove system wide pw reset + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.4.7] - 2022-04-13 + +### 🐛 Bug Fixes + +- Destinations to HAProxy + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.4.6] - 2022-04-13 + +### 🐛 Bug Fixes + +- Cleanup images older than a day +- Meilisearch service +- Load all branches, not just the first 30 +- ProjectID for Github +- DNS check before creating SSL cert +- Try catch me +- Restart policy for resources +- No permission on first registration +- Reverting postgres password for now + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.4.5] - 2022-04-12 + +### 🐛 Bug Fixes + +- Types +- Invitations +- Timeout values + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.4.4] - 2022-04-12 + +### 🐛 Bug Fixes + +- Haproxy build stuffs +- Proxy + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.4.3] - 2022-04-12 + +### 🐛 Bug Fixes + +- Remove unnecessary save button haha +- Update dockerfile + +### ⚙️ Miscellaneous Tasks + +- Update packages +- Version++ +- Update build scripts +- Update build packages + +## [2.4.2] - 2022-04-09 + +### 🐛 Bug Fixes + +- Missing install repositories GitHub +- Return own and other sources better +- Show config missing on sources + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.4.1] - 2022-04-09 + +### 🐛 Bug Fixes + +- Enable https for Ghost +- Postgres root passwor shown and set +- Able to change postgres user password from ui +- DB Connecting string generator + +### ⚙️ Miscellaneous Tasks + +- Version++ + +## [2.4.0] - 2022-04-08 + ### 🚀 Features - Use tags in update diff --git a/CLAUDE.md b/CLAUDE.md index b7c496e42..5cddb7fd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,6 +222,7 @@ ### Performance Considerations - Queue heavy operations - Optimize database queries with proper indexes - Use chunking for large data operations +- **CRITICAL**: Use `ownedByCurrentTeamCached()` instead of `ownedByCurrentTeam()->get()` ### Code Style - Follow PSR-12 coding standards @@ -317,4 +318,5 @@ ### Livewire & Frontend Random other things you should remember: -- App\Models\Application::team must return a relationship instance., always use team() \ No newline at end of file +- App\Models\Application::team must return a relationship instance., always use team() +- Always use `Model::ownedByCurrentTeamCached()` instead of `Model::ownedByCurrentTeam()->get()` for team-scoped queries to avoid duplicate database queries \ No newline at end of file diff --git a/app/Actions/Application/CleanupPreviewDeployment.php b/app/Actions/Application/CleanupPreviewDeployment.php new file mode 100644 index 000000000..74e2ff615 --- /dev/null +++ b/app/Actions/Application/CleanupPreviewDeployment.php @@ -0,0 +1,176 @@ + 0, + 'killed_containers' => 0, + 'status' => 'success', + ]; + + $server = $application->destination->server; + + if (! $server->isFunctional()) { + return [ + ...$result, + 'status' => 'failed', + 'message' => 'Server is not functional', + ]; + } + + // Step 1: Cancel all active deployments for this PR and kill helper containers + $result['cancelled_deployments'] = $this->cancelActiveDeployments( + $application, + $pull_request_id, + $server + ); + + // Step 2: Stop and remove all running PR containers + $result['killed_containers'] = $this->stopRunningContainers( + $application, + $pull_request_id, + $server + ); + + // Step 3: Find or use provided preview, then dispatch cleanup job for thorough cleanup + if (! $preview) { + $preview = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pull_request_id) + ->first(); + } + + if ($preview) { + DeleteResourceJob::dispatch($preview); + } + + return $result; + } + + /** + * Cancel all active (QUEUED/IN_PROGRESS) deployments for this PR. + */ + private function cancelActiveDeployments( + Application $application, + int $pull_request_id, + $server + ): int { + $activeDeployments = ApplicationDeploymentQueue::where('application_id', $application->id) + ->where('pull_request_id', $pull_request_id) + ->whereIn('status', [ + ApplicationDeploymentStatus::QUEUED->value, + ApplicationDeploymentStatus::IN_PROGRESS->value, + ]) + ->get(); + + $cancelled = 0; + foreach ($activeDeployments as $deployment) { + try { + // Mark deployment as cancelled + $deployment->update([ + 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + ]); + + // Add cancellation log entry + $deployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); + + // Try to kill helper container if it exists + $this->killHelperContainer($deployment->deployment_uuid, $server); + $cancelled++; + } catch (\Throwable $e) { + \Log::warning("Failed to cancel deployment {$deployment->id}: {$e->getMessage()}"); + } + } + + return $cancelled; + } + + /** + * Kill the helper container used during deployment. + */ + private function killHelperContainer(string $deployment_uuid, $server): void + { + try { + $escapedUuid = escapeshellarg($deployment_uuid); + $checkCommand = "docker ps -a --filter name={$escapedUuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); + + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + instant_remote_process(["docker rm -f {$escapedUuid}"], $server); + } + } catch (\Throwable $e) { + // Silently handle - container may already be gone + } + } + + /** + * Stop and remove all running containers for this PR. + */ + private function stopRunningContainers( + Application $application, + int $pull_request_id, + $server + ): int { + $killed = 0; + + try { + if ($server->isSwarm()) { + $escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}"); + instant_remote_process(["docker stack rm {$escapedStackName}"], $server); + $killed++; + } else { + $containers = getCurrentApplicationContainerStatus( + $server, + $application->id, + $pull_request_id + ); + + if ($containers->isNotEmpty()) { + foreach ($containers as $container) { + $containerName = data_get($container, 'Names'); + if ($containerName) { + $escapedContainerName = escapeshellarg($containerName); + instant_remote_process( + ["docker rm -f {$escapedContainerName}"], + $server + ); + $killed++; + } + } + } + } + } catch (\Throwable $e) { + \Log::warning("Error stopping containers for PR #{$pull_request_id}: {$e->getMessage()}"); + } + + return $killed; + } +} diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index ee3398b04..94651a3c1 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -39,7 +39,7 @@ public function handle(Application $application, bool $previewDeployments = fals foreach ($containersToStop as $containerName) { instant_remote_process(command: [ - "docker stop --time=30 $containerName", + "docker stop -t 30 $containerName", "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index 600b1cb9a..bf9fdee72 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -26,7 +26,7 @@ public function handle(Application $application, Server $server) if ($containerName) { instant_remote_process( [ - "docker stop --time=30 $containerName", + "docker stop -t 30 $containerName", "docker rm -f $containerName", ], $server diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 7fdfe9aeb..6da5465c6 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -105,7 +105,7 @@ public function handle(StandaloneClickhouse $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; - $this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index d1bb119af..cd820523d 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -192,7 +192,7 @@ public function handle(StandaloneDragonfly $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } - $this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 128469e24..863691e1e 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -208,7 +208,7 @@ public function handle(StandaloneKeydb $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } - $this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 29dd7b8fe..498ba0b0b 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -209,7 +209,7 @@ public function handle(StandaloneMariadb $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; - $this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 5982b68be..9565990c1 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -260,7 +260,7 @@ public function handle(StandaloneMongodb $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; - $this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index c1df8d6db..337516405 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -210,7 +210,7 @@ public function handle(StandaloneMysql $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; - $this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 1ae0d56a0..41e39c811 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -223,7 +223,7 @@ public function handle(StandalonePostgresql $database) $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; - $this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 4c99a0213..2eaf82fdd 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -205,7 +205,7 @@ public function handle(StandaloneRedis $database) if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } - $this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; + $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index 5c881e743..c024c14e1 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -49,7 +49,7 @@ private function stopContainer($database, string $containerName, int $timeout = { $server = $database->destination->server; instant_remote_process(command: [ - "docker stop --time=$timeout $containerName", + "docker stop -t $timeout $containerName", "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 61a3c4615..a1476e120 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -461,9 +461,10 @@ private function aggregateApplicationStatus($application, Collection $containerS } // Use ContainerStatusAggregator service for state machine logic + // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount); + return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true); } private function aggregateServiceContainerStatuses($services) @@ -518,8 +519,9 @@ private function aggregateServiceContainerStatuses($services) } // Use ContainerStatusAggregator service for state machine logic + // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, preserveRestarting: true); // Update service sub-resource status with aggregated result if ($aggregatedStatus) { diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index bfc65d8d2..20c997656 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -75,6 +75,10 @@ public function handle(Server $server, bool $async = true, bool $force = false, ' done', " echo 'Successfully stopped and removed existing coolify-proxy.'", 'fi', + ]); + // Ensure required networks exist BEFORE docker compose up (networks are declared as external) + $commands = $commands->merge(ensureProxyNetworksExist($server)); + $commands = $commands->merge([ "echo 'Starting coolify-proxy.'", 'docker compose up -d --wait --remove-orphans', "echo 'Successfully started coolify-proxy.'", diff --git a/app/Actions/Proxy/StopProxy.php b/app/Actions/Proxy/StopProxy.php index 8f1b8af1c..04d031ec6 100644 --- a/app/Actions/Proxy/StopProxy.php +++ b/app/Actions/Proxy/StopProxy.php @@ -24,7 +24,7 @@ public function handle(Server $server, bool $forceStop = true, int $timeout = 30 } instant_remote_process(command: [ - "docker stop --time=$timeout $containerName 2>/dev/null || true", + "docker stop -t=$timeout $containerName 2>/dev/null || true", "docker rm -f $containerName 2>/dev/null || true", '# Wait for container to be fully removed', 'for i in {1..10}; do', diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index 6823dfb92..f90e00708 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -13,6 +13,9 @@ class CheckUpdates public function handle(Server $server) { + $osId = 'unknown'; + $packageManager = null; + try { if ($server->serverStatus() === false) { return [ @@ -93,6 +96,16 @@ public function handle(Server $server) $out['osId'] = $osId; $out['package_manager'] = $packageManager; + return $out; + case 'pacman': + // Sync database first, then check for updates + // Using -Sy to refresh package database before querying available updates + instant_remote_process(['pacman -Sy'], $server); + $output = instant_remote_process(['pacman -Qu 2>/dev/null'], $server); + $out = $this->parsePacmanOutput($output); + $out['osId'] = $osId; + $out['package_manager'] = $packageManager; + return $out; default: return [ @@ -219,4 +232,45 @@ private function parseAptOutput(string $output): array 'updates' => $updates, ]; } + + private function parsePacmanOutput(string $output): array + { + $updates = []; + $unparsedLines = []; + $lines = explode("\n", $output); + + foreach ($lines as $line) { + if (empty($line)) { + continue; + } + // Format: package current_version -> new_version + if (preg_match('/^(\S+)\s+(\S+)\s+->\s+(\S+)$/', $line, $matches)) { + $updates[] = [ + 'package' => $matches[1], + 'current_version' => $matches[2], + 'new_version' => $matches[3], + 'architecture' => 'unknown', + 'repository' => 'unknown', + ]; + } else { + // Log unmatched lines for debugging purposes + $unparsedLines[] = $line; + } + } + + $result = [ + 'total_updates' => count($updates), + 'updates' => $updates, + ]; + + // Include unparsed lines in the result for debugging if any exist + if (! empty($unparsedLines)) { + $result['unparsed_lines'] = $unparsedLines; + \Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [ + 'unparsed_lines' => $unparsedLines, + ]); + } + + return $result; + } } diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 6bf094c32..65a41db18 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -13,7 +13,6 @@ class CleanupDocker public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false) { - $settings = instanceSettings(); $realtimeImage = config('constants.coolify.realtime_image'); $realtimeImageVersion = config('constants.coolify.realtime_version'); $realtimeImageWithVersion = "$realtimeImage:$realtimeImageVersion"; @@ -26,9 +25,31 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ $helperImageWithoutPrefix = 'coollabsio/coolify-helper'; $helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion"; + $cleanupLog = []; + + // Get all application image repositories to exclude from prune + $applications = $server->applications(); + $applicationImageRepos = collect($applications)->map(function ($app) { + return $app->docker_registry_image_name ?? $app->uuid; + })->unique()->values(); + + // Clean up old application images while preserving N most recent for rollback + $applicationCleanupLog = $this->cleanupApplicationImages($server, $applications); + $cleanupLog = array_merge($cleanupLog, $applicationCleanupLog); + + // Build image prune command that excludes application images and current Coolify infrastructure images + // This ensures we clean up non-Coolify images while preserving rollback images and current helper/realtime images + // Note: Only the current version is protected; old versions will be cleaned up by explicit commands below + // We pass the version strings so all registry variants are protected (ghcr.io, docker.io, no prefix) + $imagePruneCmd = $this->buildImagePruneCommand( + $applicationImageRepos, + $helperImageVersion, + $realtimeImageVersion + ); + $commands = [ 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', - 'docker image prune -af --filter "label!=coolify.managed=true"', + $imagePruneCmd, 'docker builder prune -af', "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f", @@ -44,7 +65,6 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ $commands[] = 'docker network prune -f'; } - $cleanupLog = []; foreach ($commands as $command) { $commandOutput = instant_remote_process([$command], $server, false); if ($commandOutput !== null) { @@ -57,4 +77,140 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ return $cleanupLog; } + + /** + * Build a docker image prune command that excludes application image repositories. + * + * Since docker image prune doesn't support excluding by repository name directly, + * we use a shell script approach to delete unused images while preserving application images. + */ + private function buildImagePruneCommand( + $applicationImageRepos, + string $helperImageVersion, + string $realtimeImageVersion + ): string { + // Step 1: Always prune dangling images (untagged) + $commands = ['docker image prune -f']; + + // Build grep pattern to exclude application image repositories (matches repo:tag and repo_service:tag) + $appExcludePatterns = $applicationImageRepos->map(function ($repo) { + // Escape special characters for grep extended regex (ERE) + // ERE special chars: . \ + * ? [ ^ ] $ ( ) { } | + return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo); + })->implode('|'); + + // Build grep pattern to exclude Coolify infrastructure images (current version only) + // This pattern matches the image name regardless of registry prefix: + // - ghcr.io/coollabsio/coolify-helper:1.0.12 + // - docker.io/coollabsio/coolify-helper:1.0.12 + // - coollabsio/coolify-helper:1.0.12 + // Pattern: (^|/)coollabsio/coolify-(helper|realtime):VERSION$ + $escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperImageVersion); + $escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeImageVersion); + $infraExcludePattern = "(^|/)coollabsio/coolify-helper:{$escapedHelperVersion}$|(^|/)coollabsio/coolify-realtime:{$escapedRealtimeVersion}$"; + + // Delete unused images that: + // - Are not application images (don't match app repos) + // - Are not current Coolify infrastructure images (any registry) + // - Don't have coolify.managed=true label + // Images in use by containers will fail silently with docker rmi + // Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build) + $grepCommands = "grep -v ''"; + + // Add application repo exclusion if there are applications + if ($applicationImageRepos->isNotEmpty()) { + $grepCommands .= " | grep -v -E '^({$appExcludePatterns})[_:].+'"; + } + + // Add infrastructure image exclusion (matches any registry prefix) + $grepCommands .= " | grep -v -E '{$infraExcludePattern}'"; + + $commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ". + $grepCommands.' | '. + "xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true"; + + return implode(' && ', $commands); + } + + private function cleanupApplicationImages(Server $server, $applications = null): array + { + $cleanupLog = []; + + if ($applications === null) { + $applications = $server->applications(); + } + + $disableRetention = $server->settings->disable_application_image_retention ?? false; + + foreach ($applications as $application) { + $imagesToKeep = $disableRetention ? 0 : ($application->settings->docker_images_to_keep ?? 2); + $imageRepository = $application->docker_registry_image_name ?? $application->uuid; + + // Get the currently running image tag + $currentTagCommand = "docker inspect --format='{{.Config.Image}}' {$application->uuid} 2>/dev/null | grep -oP '(?<=:)[^:]+$' || true"; + $currentTag = instant_remote_process([$currentTagCommand], $server, false); + $currentTag = trim($currentTag ?? ''); + + // List all images for this application with their creation timestamps + // Use wildcard to match both uuid:tag and uuid_servicename:tag (Docker Compose with build) + $listCommand = "docker images --format '{{.Repository}}:{{.Tag}}#{{.CreatedAt}}' --filter reference='{$imageRepository}*' 2>/dev/null || true"; + $output = instant_remote_process([$listCommand], $server, false); + + if (empty($output)) { + continue; + } + + $images = collect(explode("\n", trim($output))) + ->filter() + ->map(function ($line) { + $parts = explode('#', $line); + $imageRef = $parts[0] ?? ''; + $tagParts = explode(':', $imageRef); + + return [ + 'repository' => $tagParts[0] ?? '', + 'tag' => $tagParts[1] ?? '', + 'created_at' => $parts[1] ?? '', + 'image_ref' => $imageRef, + ]; + }) + ->filter(fn ($image) => ! empty($image['tag'])); + + // Separate images into categories + // PR images (pr-*) and build images (*-build) are excluded from retention + // Build images will be cleaned up by docker image prune -af + $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + // Always delete all PR images + foreach ($prImages as $image) { + $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true"; + $deleteOutput = instant_remote_process([$deleteCommand], $server, false); + $cleanupLog[] = [ + 'command' => $deleteCommand, + 'output' => $deleteOutput ?? 'PR image removed or was in use', + ]; + } + + // Filter out current running image from regular images and sort by creation date + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // Keep only N images (imagesToKeep), delete the rest + $imagesToDelete = $sortedRegularImages->skip($imagesToKeep); + + foreach ($imagesToDelete as $image) { + $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true"; + $deleteOutput = instant_remote_process([$deleteCommand], $server, false); + $cleanupLog[] = [ + 'command' => $deleteCommand, + 'output' => $deleteOutput ?? 'Image removed or was in use', + ]; + } + } + + return $cleanupLog; + } } diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 36c540950..eb53b32ee 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -78,6 +78,8 @@ public function handle(Server $server) $command = $command->merge([$this->getRhelDockerInstallCommand()]); } elseif ($supported_os_type->contains('sles')) { $command = $command->merge([$this->getSuseDockerInstallCommand()]); + } elseif ($supported_os_type->contains('arch')) { + $command = $command->merge([$this->getArchDockerInstallCommand()]); } else { $command = $command->merge([$this->getGenericDockerInstallCommand()]); } @@ -146,8 +148,19 @@ private function getSuseDockerInstallCommand(): string ')'; } + private function getArchDockerInstallCommand(): string + { + // Use -Syu to perform full system upgrade before installing Docker + // Partial upgrades (-Sy without -u) are discouraged on Arch Linux + // as they can lead to broken dependencies and system instability + // Use --needed to skip reinstalling packages that are already up-to-date (idempotent) + return 'pacman -Syu --noconfirm --needed docker docker-compose && '. + 'systemctl enable docker.service && '. + 'systemctl start docker.service'; + } + private function getGenericDockerInstallCommand(): string { - return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion}"; + return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}"; } } diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php index 1a7d3bbd9..84be7f206 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -46,6 +46,13 @@ public function handle(Server $server) 'command -v git >/dev/null || zypper install -y git', 'command -v jq >/dev/null || zypper install -y jq', ]); + } elseif ($supported_os_type->contains('arch')) { + // Use -Syu for full system upgrade to avoid partial upgrade issues on Arch Linux + // --needed flag skips packages that are already installed and up-to-date + $command = $command->merge([ + "echo 'Installing Prerequisites for Arch Linux...'", + 'pacman -Syu --noconfirm --needed curl wget git jq', + ]); } else { throw new \Exception('Unsupported OS type for prerequisites installation'); } diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index 0bf763d78..b5ebd92b2 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -3,6 +3,8 @@ namespace App\Actions\Server; use App\Models\Server; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Sleep; use Lorisleiva\Actions\Concerns\AsAction; @@ -28,8 +30,59 @@ public function handle($manual_update = false) if (! $this->server) { return; } - CleanupDocker::dispatch($this->server, false, false); - $this->latestVersion = get_latest_version_of_coolify(); + + // Fetch fresh version from CDN instead of using cache + try { + $response = Http::retry(3, 1000)->timeout(10) + ->get(config('constants.coolify.versions_url')); + + if ($response->successful()) { + $versions = $response->json(); + $this->latestVersion = data_get($versions, 'coolify.v4.version'); + } else { + // Fallback to cache if CDN unavailable + $cacheVersion = get_latest_version_of_coolify(); + + // Validate cache version against current running version + if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) { + Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [ + 'cached_version' => $cacheVersion, + 'current_version' => config('constants.coolify.version'), + ]); + throw new \Exception( + 'Cannot determine latest version: CDN unavailable and cache version '. + "({$cacheVersion}) is older than running version (".config('constants.coolify.version').')' + ); + } + + $this->latestVersion = $cacheVersion; + Log::warning('Failed to fetch fresh version from CDN (unsuccessful response), using validated cache', [ + 'version' => $cacheVersion, + ]); + } + } catch (\Throwable $e) { + $cacheVersion = get_latest_version_of_coolify(); + + // Validate cache version against current running version + if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) { + Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [ + 'error' => $e->getMessage(), + 'cached_version' => $cacheVersion, + 'current_version' => config('constants.coolify.version'), + ]); + throw new \Exception( + 'Cannot determine latest version: CDN unavailable and cache version '. + "({$cacheVersion}) is older than running version (".config('constants.coolify.version').')' + ); + } + + $this->latestVersion = $cacheVersion; + Log::warning('Failed to fetch fresh version from CDN, using validated cache', [ + 'error' => $e->getMessage(), + 'version' => $cacheVersion, + ]); + } + $this->currentVersion = config('constants.coolify.version'); if (! $manual_update) { if (! $settings->is_auto_update_enabled) { @@ -42,6 +95,20 @@ public function handle($manual_update = false) return; } } + + // ALWAYS check for downgrades (even for manual updates) + if (version_compare($this->latestVersion, $this->currentVersion, '<')) { + Log::error('Downgrade prevented', [ + 'target_version' => $this->latestVersion, + 'current_version' => $this->currentVersion, + 'manual_update' => $manual_update, + ]); + throw new \Exception( + "Cannot downgrade from {$this->currentVersion} to {$this->latestVersion}. ". + 'If you need to downgrade, please do so manually via Docker commands.' + ); + } + $this->update(); $settings->new_version_available = false; $settings->save(); @@ -49,16 +116,12 @@ public function handle($manual_update = false) private function update() { - $helperImage = config('constants.coolify.helper_image'); - $latest_version = getHelperVersion(); - instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false); - - $image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion; - instant_remote_process(["docker pull -q $image"], $this->server, false); + $latestHelperImageVersion = getHelperVersion(); + $upgradeScriptUrl = config('constants.coolify.upgrade_script_url'); remote_process([ - 'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh', - "bash /data/coolify/source/upgrade.sh $this->latestVersion", + "curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh", + "bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion", ], $this->server); } } diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index 75d931f93..ab0ca9494 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -20,18 +20,43 @@ public function handle(Server $server, string $osId, ?string $package = null, ?s 'error' => 'Server is not reachable or not ready.', ]; } + + // Validate that package name is provided when not updating all packages + if (! $all && ($package === null || $package === '')) { + return [ + 'error' => "Package name required when 'all' is false.", + ]; + } + + // Sanitize package name to prevent command injection + // Only allow alphanumeric characters, hyphens, underscores, periods, plus signs, and colons + // These are valid characters in package names across most package managers + $sanitizedPackage = ''; + if ($package !== null && ! $all) { + if (! preg_match('/^[a-zA-Z0-9._+:-]+$/', $package)) { + return [ + 'error' => 'Invalid package name. Package names can only contain alphanumeric characters, hyphens, underscores, periods, plus signs, and colons.', + ]; + } + $sanitizedPackage = escapeshellarg($package); + } + switch ($packageManager) { case 'zypper': $commandAll = 'zypper update -y'; - $commandInstall = 'zypper install -y '.$package; + $commandInstall = 'zypper install -y '.$sanitizedPackage; break; case 'dnf': $commandAll = 'dnf update -y'; - $commandInstall = 'dnf update -y '.$package; + $commandInstall = 'dnf update -y '.$sanitizedPackage; break; case 'apt': $commandAll = 'apt update && apt upgrade -y'; - $commandInstall = 'apt install -y '.$package; + $commandInstall = 'apt install -y '.$sanitizedPackage; + break; + case 'pacman': + $commandAll = 'pacman -Syu --noconfirm'; + $commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage; break; default: return [ diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 3f4e96479..675f0f955 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -3,10 +3,12 @@ namespace App\Actions\Service; use App\Actions\Server\CleanupDocker; +use App\Enums\ProcessStatus; use App\Events\ServiceStatusChanged; use App\Models\Server; use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; class StopService { @@ -17,6 +19,17 @@ class StopService public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true) { try { + // Cancel any in-progress deployment activities so status doesn't stay stuck at "starting" + Activity::where('properties->type_uuid', $service->uuid) + ->where(function ($q) { + $q->where('properties->status', ProcessStatus::IN_PROGRESS->value) + ->orWhere('properties->status', ProcessStatus::QUEUED->value); + }) + ->each(function ($activity) { + $activity->properties = $activity->properties->put('status', ProcessStatus::CANCELLED->value); + $activity->save(); + }); + $server = $service->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; @@ -54,7 +67,7 @@ private function stopContainersInParallel(array $containersToStop, Server $serve $timeout = count($containersToStop) > 5 ? 10 : 30; $commands = []; $containerList = implode(' ', $containersToStop); - $commands[] = "docker stop --time=$timeout $containerList"; + $commands[] = "docker stop -t $timeout $containerList"; $commands[] = "docker rm -f $containerList"; instant_remote_process( command: $commands, diff --git a/app/Console/Commands/CleanupNames.php b/app/Console/Commands/CleanupNames.php index 2992e32b9..2451dc3ed 100644 --- a/app/Console/Commands/CleanupNames.php +++ b/app/Console/Commands/CleanupNames.php @@ -63,8 +63,6 @@ class CleanupNames extends Command public function handle(): int { - $this->info('🔍 Scanning for invalid characters in name fields...'); - if ($this->option('backup') && ! $this->option('dry-run')) { $this->createBackup(); } @@ -75,7 +73,7 @@ public function handle(): int : $this->modelsToClean; if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) { - $this->error("❌ Unknown model: {$modelFilter}"); + $this->error("Unknown model: {$modelFilter}"); $this->info('Available models: '.implode(', ', array_keys($this->modelsToClean))); return self::FAILURE; @@ -88,19 +86,21 @@ public function handle(): int $this->processModel($modelName, $modelClass); } - $this->displaySummary(); - if (! $this->option('dry-run') && $this->totalCleaned > 0) { $this->logChanges(); } + if ($this->option('dry-run')) { + $this->info("Name cleanup: would sanitize {$this->totalCleaned} records"); + } else { + $this->info("Name cleanup: sanitized {$this->totalCleaned} records"); + } + return self::SUCCESS; } protected function processModel(string $modelName, string $modelClass): void { - $this->info("\n📋 Processing {$modelName}..."); - try { $records = $modelClass::all(['id', 'name']); $cleaned = 0; @@ -128,21 +128,17 @@ protected function processModel(string $modelName, string $modelClass): void $cleaned++; $this->totalCleaned++; - $this->warn(" 🧹 {$modelName} #{$record->id}:"); - $this->line(' From: '.$this->truncate($originalName, 80)); - $this->line(' To: '.$this->truncate($sanitizedName, 80)); + // Only log in dry-run mode to preview changes + if ($this->option('dry-run')) { + $this->warn(" 🧹 {$modelName} #{$record->id}:"); + $this->line(' From: '.$this->truncate($originalName, 80)); + $this->line(' To: '.$this->truncate($sanitizedName, 80)); + } } } - if ($cleaned > 0) { - $action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized'; - $this->info(" ✅ {$cleaned}/{$records->count()} records {$action}"); - } else { - $this->info(' ✨ No invalid characters found'); - } - } catch (\Exception $e) { - $this->error(" ❌ Error processing {$modelName}: ".$e->getMessage()); + $this->error("Error processing {$modelName}: ".$e->getMessage()); } } @@ -165,28 +161,6 @@ protected function sanitizeName(string $name): string return $sanitized; } - protected function displaySummary(): void - { - $this->info("\n".str_repeat('=', 60)); - $this->info('📊 CLEANUP SUMMARY'); - $this->info(str_repeat('=', 60)); - - $this->line("Records processed: {$this->totalProcessed}"); - $this->line("Records with invalid characters: {$this->totalCleaned}"); - - if ($this->option('dry-run')) { - $this->warn("\n🔍 DRY RUN - No changes were made to the database"); - $this->info('Run without --dry-run to apply these changes'); - } else { - if ($this->totalCleaned > 0) { - $this->info("\n✅ Database successfully sanitized!"); - $this->info('Changes logged to storage/logs/name-cleanup.log'); - } else { - $this->info("\n✨ No cleanup needed - all names are valid!"); - } - } - } - protected function logChanges(): void { $logFile = storage_path('logs/name-cleanup.log'); @@ -208,8 +182,6 @@ protected function logChanges(): void protected function createBackup(): void { - $this->info('💾 Creating database backup...'); - try { $backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql'); @@ -229,15 +201,9 @@ protected function createBackup(): void ); exec($command, $output, $returnCode); - - if ($returnCode === 0) { - $this->info("✅ Backup created: {$backupFile}"); - } else { - $this->warn('⚠️ Backup creation may have failed. Proceeding anyway...'); - } } catch (\Exception $e) { - $this->warn('⚠️ Could not create backup: '.$e->getMessage()); - $this->warn('Proceeding without backup...'); + // Log failure but continue - backup is optional safeguard + Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]); } } diff --git a/app/Console/Commands/CleanupRedis.php b/app/Console/Commands/CleanupRedis.php index abf8010c0..199e168fc 100644 --- a/app/Console/Commands/CleanupRedis.php +++ b/app/Console/Commands/CleanupRedis.php @@ -18,10 +18,6 @@ public function handle() $dryRun = $this->option('dry-run'); $skipOverlapping = $this->option('skip-overlapping'); - if ($dryRun) { - $this->info('DRY RUN MODE - No data will be deleted'); - } - $deletedCount = 0; $totalKeys = 0; @@ -29,8 +25,6 @@ public function handle() $keys = $redis->keys('*'); $totalKeys = count($keys); - $this->info("Scanning {$totalKeys} keys for cleanup..."); - foreach ($keys as $key) { $keyWithoutPrefix = str_replace($prefix, '', $key); $type = $redis->command('type', [$keyWithoutPrefix]); @@ -51,14 +45,12 @@ public function handle() // Clean up overlapping queues if not skipped if (! $skipOverlapping) { - $this->info('Cleaning up overlapping queues...'); $overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun); $deletedCount += $overlappingCleaned; } // Clean up stale cache locks (WithoutOverlapping middleware) if ($this->option('clear-locks')) { - $this->info('Cleaning up stale cache locks...'); $locksCleaned = $this->cleanupCacheLocks($dryRun); $deletedCount += $locksCleaned; } @@ -66,15 +58,14 @@ public function handle() // Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative) $isRestart = $this->option('restart'); if ($isRestart || $this->option('clear-locks')) { - $this->info($isRestart ? 'Cleaning up stuck jobs (RESTART MODE - aggressive)...' : 'Checking for stuck jobs (runtime mode - conservative)...'); $jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart); $deletedCount += $jobsCleaned; } if ($dryRun) { - $this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys"); + $this->info("Redis cleanup: would delete {$deletedCount} items"); } else { - $this->info("Deleted {$deletedCount} out of {$totalKeys} keys"); + $this->info("Redis cleanup: deleted {$deletedCount} items"); } } @@ -85,11 +76,8 @@ private function shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun) // Delete completed and failed jobs if (in_array($status, ['completed', 'failed'])) { - if ($dryRun) { - $this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})"); - } else { + if (! $dryRun) { $redis->command('del', [$keyWithoutPrefix]); - $this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})"); } return true; @@ -115,11 +103,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR foreach ($patterns as $pattern => $description) { if (str_contains($keyWithoutPrefix, $pattern)) { - if ($dryRun) { - $this->line("Would delete {$description}: {$keyWithoutPrefix}"); - } else { + if (! $dryRun) { $redis->command('del', [$keyWithoutPrefix]); - $this->line("Deleted {$description}: {$keyWithoutPrefix}"); } return true; @@ -132,11 +117,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR $weekAgo = now()->subDays(7)->timestamp; if ($timestamp < $weekAgo) { - if ($dryRun) { - $this->line("Would delete old timestamped data: {$keyWithoutPrefix}"); - } else { + if (! $dryRun) { $redis->command('del', [$keyWithoutPrefix]); - $this->line("Deleted old timestamped data: {$keyWithoutPrefix}"); } return true; @@ -160,8 +142,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun) } } - $this->info('Found '.count($queueKeys).' queue-related keys'); - // Group queues by name pattern to find duplicates $queueGroups = []; foreach ($queueKeys as $queueKey) { @@ -193,7 +173,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun) private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun) { $cleanedCount = 0; - $this->line("Processing queue group: {$baseName} (".count($keys).' keys)'); // Sort keys to keep the most recent one usort($keys, function ($a, $b) { @@ -244,11 +223,8 @@ private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun) } if ($shouldDelete) { - if ($dryRun) { - $this->line(" Would delete empty queue: {$redundantKey}"); - } else { + if (! $dryRun) { $redis->command('del', [$redundantKey]); - $this->line(" Deleted empty queue: {$redundantKey}"); } $cleanedCount++; } @@ -271,15 +247,12 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun) if (count($uniqueItems) < count($items)) { $duplicates = count($items) - count($uniqueItems); - if ($dryRun) { - $this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}"); - } else { + if (! $dryRun) { // Rebuild the list with unique items $redis->command('del', [$queueKey]); foreach (array_reverse($uniqueItems) as $item) { $redis->command('lpush', [$queueKey, $item]); } - $this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}"); } $cleanedCount += $duplicates; } @@ -307,13 +280,9 @@ private function cleanupCacheLocks(bool $dryRun): int } } if (empty($lockKeys)) { - $this->info(' No cache locks found.'); - return 0; } - $this->info(' Found '.count($lockKeys).' cache lock(s)'); - foreach ($lockKeys as $lockKey) { // Check TTL to identify stale locks $ttl = $redis->ttl($lockKey); @@ -326,18 +295,11 @@ private function cleanupCacheLocks(bool $dryRun): int $this->warn(" Would delete STALE lock (no expiration): {$lockKey}"); } else { $redis->del($lockKey); - $this->info(" ✓ Deleted STALE lock: {$lockKey}"); } $cleanedCount++; - } elseif ($ttl > 0) { - $this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}"); } } - if ($cleanedCount === 0) { - $this->info(' No stale locks found (all locks have expiration set)'); - } - return $cleanedCount; } @@ -453,17 +415,11 @@ private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $is $redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']); $redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]); $redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]); - - $this->info(" ✓ Marked as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1).' min) - '.$reason); } $cleanedCount++; } } - if ($cleanedCount === 0) { - $this->info($isRestart ? ' No jobs to clean up' : ' No stuck jobs found (all jobs running normally)'); - } - return $cleanedCount; } } diff --git a/app/Console/Commands/NotifyDemo.php b/app/Console/Commands/NotifyDemo.php index 990a03869..8e9251ac0 100644 --- a/app/Console/Commands/NotifyDemo.php +++ b/app/Console/Commands/NotifyDemo.php @@ -56,7 +56,7 @@ private function showHelp() php artisan app:demo-notify {channel}

-
Channels:
+
Channels:
  • email
  • discord
  • diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index e634feadb..0a98f1dc8 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -16,7 +16,7 @@ class SyncBunny extends Command * * @var string */ - protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}'; + protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}'; /** * The console command description. @@ -50,6 +50,7 @@ private function syncReleasesToGitHubRepo(): bool // Clone the repository $this->info('Cloning coolify-cdn repository...'); + $output = []; exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to clone repository: '.implode("\n", $output)); @@ -59,6 +60,7 @@ private function syncReleasesToGitHubRepo(): bool // Create feature branch $this->info('Creating feature branch...'); + $output = []; exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to create branch: '.implode("\n", $output)); @@ -70,12 +72,25 @@ private function syncReleasesToGitHubRepo(): bool // Write releases.json $this->info('Writing releases.json...'); $releasesPath = "$tmpDir/json/releases.json"; + $releasesDir = dirname($releasesPath); + + // Ensure directory exists + if (! is_dir($releasesDir)) { + $this->info("Creating directory: $releasesDir"); + if (! mkdir($releasesDir, 0755, true)) { + $this->error("Failed to create directory: $releasesDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + } + $jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $bytesWritten = file_put_contents($releasesPath, $jsonContent); if ($bytesWritten === false) { $this->error("Failed to write releases.json to: $releasesPath"); - $this->error('Possible reasons: directory does not exist, permission denied, or disk full.'); + $this->error('Possible reasons: permission denied or disk full.'); exec('rm -rf '.escapeshellarg($tmpDir)); return false; @@ -83,6 +98,7 @@ private function syncReleasesToGitHubRepo(): bool // Stage and commit $this->info('Committing changes...'); + $output = []; exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to stage changes: '.implode("\n", $output)); @@ -120,6 +136,7 @@ private function syncReleasesToGitHubRepo(): bool // Push to remote $this->info('Pushing branch to remote...'); + $output = []; exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to push branch: '.implode("\n", $output)); @@ -133,6 +150,7 @@ private function syncReleasesToGitHubRepo(): bool $prTitle = 'Update releases.json - '.date('Y-m-d H:i:s'); $prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API'; $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; + $output = []; exec($prCommand, $output, $returnCode); // Clean up @@ -158,6 +176,343 @@ private function syncReleasesToGitHubRepo(): bool } } + /** + * Sync both releases.json and versions.json to GitHub repository in one PR + */ + private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool + { + $this->info('Syncing releases.json and versions.json to GitHub repository...'); + try { + // 1. Fetch releases from GitHub API + $this->info('Fetching releases from GitHub API...'); + $response = Http::timeout(30) + ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ + 'per_page' => 30, + ]); + + if (! $response->successful()) { + $this->error('Failed to fetch releases from GitHub: '.$response->status()); + + return false; + } + + $releases = $response->json(); + + // 2. Read versions.json + if (! file_exists($versionsLocation)) { + $this->error("versions.json not found at: $versionsLocation"); + + return false; + } + + $file = file_get_contents($versionsLocation); + $versionsJson = json_decode($file, true); + $actualVersion = data_get($versionsJson, 'coolify.v4.version'); + + $timestamp = time(); + $tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp; + $branchName = 'update-releases-and-versions-'.$timestamp; + $versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json'; + + // 3. Clone the repository + $this->info('Cloning coolify-cdn repository...'); + $output = []; + exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to clone repository: '.implode("\n", $output)); + + return false; + } + + // 4. Create feature branch + $this->info('Creating feature branch...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to create branch: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // 5. Write releases.json + $this->info('Writing releases.json...'); + $releasesPath = "$tmpDir/json/releases.json"; + $releasesDir = dirname($releasesPath); + + if (! is_dir($releasesDir)) { + if (! mkdir($releasesDir, 0755, true)) { + $this->error("Failed to create directory: $releasesDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + } + + $releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (file_put_contents($releasesPath, $releasesJsonContent) === false) { + $this->error("Failed to write releases.json to: $releasesPath"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // 6. Write versions.json + $this->info('Writing versions.json...'); + $versionsPath = "$tmpDir/$versionsTargetPath"; + $versionsDir = dirname($versionsPath); + + if (! is_dir($versionsDir)) { + if (! mkdir($versionsDir, 0755, true)) { + $this->error("Failed to create directory: $versionsDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + } + + $versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (file_put_contents($versionsPath, $versionsJsonContent) === false) { + $this->error("Failed to write versions.json to: $versionsPath"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // 7. Stage both files + $this->info('Staging changes...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to stage changes: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // 8. Check for changes + $this->info('Checking for changes...'); + $statusOutput = []; + exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + if (empty(array_filter($statusOutput))) { + $this->info('Both files are already up to date. No changes to commit.'); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return true; + } + + // 9. Commit changes + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to commit changes: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // 10. Push to remote + $this->info('Pushing branch to remote...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to push branch: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // 11. Create pull request + $this->info('Creating pull request...'); + $prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); + $prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion"; + $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; + $output = []; + exec($prCommand, $output, $returnCode); + + // 12. Clean up + exec('rm -rf '.escapeshellarg($tmpDir)); + + if ($returnCode !== 0) { + $this->error('Failed to create PR: '.implode("\n", $output)); + + return false; + } + + $this->info('Pull request created successfully!'); + if (! empty($output)) { + $this->info('PR URL: '.implode("\n", $output)); + } + $this->info("Version synced: $actualVersion"); + $this->info('Total releases synced: '.count($releases)); + + return true; + } catch (\Throwable $e) { + $this->error('Error syncing to GitHub: '.$e->getMessage()); + + return false; + } + } + + /** + * Sync versions.json to GitHub repository via PR + */ + private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool + { + $this->info('Syncing versions.json to GitHub repository...'); + try { + if (! file_exists($versionsLocation)) { + $this->error("versions.json not found at: $versionsLocation"); + + return false; + } + + $file = file_get_contents($versionsLocation); + $json = json_decode($file, true); + $actualVersion = data_get($json, 'coolify.v4.version'); + + $timestamp = time(); + $tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp; + $branchName = 'update-versions-'.$timestamp; + $targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json'; + + // Clone the repository + $this->info('Cloning coolify-cdn repository...'); + exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to clone repository: '.implode("\n", $output)); + + return false; + } + + // Create feature branch + $this->info('Creating feature branch...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to create branch: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Write versions.json + $this->info('Writing versions.json...'); + $versionsPath = "$tmpDir/$targetPath"; + $versionsDir = dirname($versionsPath); + + // Ensure directory exists + if (! is_dir($versionsDir)) { + $this->info("Creating directory: $versionsDir"); + if (! mkdir($versionsDir, 0755, true)) { + $this->error("Failed to create directory: $versionsDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + } + + $jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $bytesWritten = file_put_contents($versionsPath, $jsonContent); + + if ($bytesWritten === false) { + $this->error("Failed to write versions.json to: $versionsPath"); + $this->error('Possible reasons: permission denied or disk full.'); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Stage and commit + $this->info('Committing changes...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to stage changes: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + $this->info('Checking for changes...'); + $statusOutput = []; + exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + if (empty(array_filter($statusOutput))) { + $this->info('versions.json is already up to date. No changes to commit.'); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return true; + } + + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to commit changes: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Push to remote + $this->info('Pushing branch to remote...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to push branch: '.implode("\n", $output)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // Create pull request + $this->info('Creating pull request...'); + $prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); + $prBody = "Automated update of $envLabel versions.json to version $actualVersion"; + $output = []; + $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; + exec($prCommand, $output, $returnCode); + + // Clean up + exec('rm -rf '.escapeshellarg($tmpDir)); + + if ($returnCode !== 0) { + $this->error('Failed to create PR: '.implode("\n", $output)); + + return false; + } + + $this->info('Pull request created successfully!'); + if (! empty($output)) { + $this->info('PR URL: '.implode("\n", $output)); + } + $this->info("Version synced: $actualVersion"); + + return true; + } catch (\Throwable $e) { + $this->error('Error syncing versions.json: '.$e->getMessage()); + + return false; + } + } + /** * Execute the console command. */ @@ -167,6 +522,7 @@ public function handle() $only_template = $this->option('templates'); $only_version = $this->option('release'); $only_github_releases = $this->option('github-releases'); + $only_github_versions = $this->option('github-versions'); $nightly = $this->option('nightly'); $bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn_path = 'coolify'; @@ -224,7 +580,7 @@ public function handle() $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } - if (! $only_template && ! $only_version && ! $only_github_releases) { + if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) { if ($nightly) { $this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.'); } else { @@ -250,25 +606,47 @@ public function handle() return; } elseif ($only_version) { if ($nightly) { - $this->info('About to sync NIGHLTY versions.json to BunnyCDN.'); + $this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.'); } else { - $this->info('About to sync PRODUCTION versions.json to BunnyCDN.'); + $this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.'); } $file = file_get_contents($versions_location); $json = json_decode($file, true); $actual_version = data_get($json, 'coolify.v4.version'); - $confirmed = confirm("Are you sure you want to sync to {$actual_version}?"); + $this->info("Version: {$actual_version}"); + $this->info('This will:'); + $this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)'); + $this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json'); + $this->newLine(); + + $confirmed = confirm('Are you sure you want to proceed?'); if (! $confirmed) { return; } - // Sync versions.json to BunnyCDN + // 1. Sync versions.json to BunnyCDN (deprecated but still needed) + $this->info('Step 1/2: Syncing versions.json to BunnyCDN...'); Http::pool(fn (Pool $pool) => [ $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), ]); - $this->info('versions.json uploaded & purged...'); + $this->info('✓ versions.json uploaded & purged to BunnyCDN'); + $this->newLine(); + + // 2. Create GitHub PR with both releases.json and versions.json + $this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...'); + $githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly); + if ($githubSuccess) { + $this->info('✓ GitHub PR created successfully with both files'); + } else { + $this->error('✗ Failed to create GitHub PR'); + } + $this->newLine(); + + $this->info('=== Summary ==='); + $this->info('BunnyCDN sync: ✓ Complete'); + $this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed')); return; } elseif ($only_github_releases) { @@ -281,6 +659,22 @@ public function handle() // Sync releases to GitHub repository $this->syncReleasesToGitHubRepo(); + return; + } elseif ($only_github_versions) { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $file = file_get_contents($versions_location); + $json = json_decode($file, true); + $actual_version = data_get($json, 'coolify.v4.version'); + + $this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository."); + $confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?'); + if (! $confirmed) { + return; + } + + // Sync versions.json to GitHub repository + $this->syncVersionsToGitHubRepo($versions_location, $nightly); + return; } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 832bed5ae..d82d3a1b9 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,11 +2,11 @@ namespace App\Console; -use App\Jobs\CheckAndStartSentinelJob; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CleanupInstanceStuffsJob; +use App\Jobs\CleanupOrphanedPreviewContainersJob; use App\Jobs\PullChangelog; use App\Jobs\PullTemplatesFromCDN; use App\Jobs\RegenerateSslCertJob; @@ -14,16 +14,11 @@ use App\Jobs\ServerManagerJob; use App\Jobs\UpdateCoolifyJob; use App\Models\InstanceSettings; -use App\Models\Server; -use App\Models\Team; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; -use Illuminate\Support\Facades\Log; class Kernel extends ConsoleKernel { - private $allServers; - private Schedule $scheduleInstance; private InstanceSettings $settings; @@ -35,8 +30,6 @@ class Kernel extends ConsoleKernel protected function schedule(Schedule $schedule): void { $this->scheduleInstance = $schedule; - $this->allServers = Server::where('ip', '!=', '1.2.3.4'); - $this->settings = instanceSettings(); $this->updateCheckFrequency = $this->settings->update_check_frequency ?: '0 * * * *'; @@ -88,29 +81,14 @@ protected function schedule(Schedule $schedule): void $this->scheduleInstance->command('cleanup:database --yes')->daily(); $this->scheduleInstance->command('uploads:clear')->everyTwoMinutes(); + + // Cleanup orphaned PR preview containers daily + $this->scheduleInstance->job(new CleanupOrphanedPreviewContainersJob)->daily()->onOneServer(); } } private function pullImages(): void { - if (isCloud()) { - $servers = $this->allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get(); - $own = Team::find(0)->servers; - $servers = $servers->merge($own); - } else { - $servers = $this->allServers->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get(); - } - foreach ($servers as $server) { - try { - if ($server->isSentinelEnabled()) { - $this->scheduleInstance->job(function () use ($server) { - CheckAndStartSentinelJob::dispatch($server); - })->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer(); - } - } catch (\Exception $e) { - Log::error('Error pulling images: '.$e->getMessage()); - } - } $this->scheduleInstance->job(new CheckHelperImageJob) ->cron($this->updateCheckFrequency) ->timezone($this->instanceTimezone) diff --git a/app/Events/ProxyStatusChangedUI.php b/app/Events/ProxyStatusChangedUI.php index bd99a0f3c..3994dc0f8 100644 --- a/app/Events/ProxyStatusChangedUI.php +++ b/app/Events/ProxyStatusChangedUI.php @@ -14,12 +14,15 @@ class ProxyStatusChangedUI implements ShouldBroadcast public ?int $teamId = null; - public function __construct(?int $teamId = null) + public ?int $activityId = null; + + public function __construct(?int $teamId = null, ?int $activityId = null) { if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { $teamId = auth()->user()->currentTeam()->id; } $this->teamId = $teamId; + $this->activityId = $activityId; } public function broadcastOn(): array diff --git a/app/Events/RestoreJobFinished.php b/app/Events/RestoreJobFinished.php index d3adb7798..8690e01f6 100644 --- a/app/Events/RestoreJobFinished.php +++ b/app/Events/RestoreJobFinished.php @@ -17,17 +17,23 @@ public function __construct($data) $tmpPath = data_get($data, 'tmpPath'); $container = data_get($data, 'container'); $serverId = data_get($data, 'serverId'); - if (filled($scriptPath) && filled($tmpPath) && filled($container) && filled($serverId)) { - if (str($tmpPath)->startsWith('/tmp/') - && str($scriptPath)->startsWith('/tmp/') - && ! str($tmpPath)->contains('..') - && ! str($scriptPath)->contains('..') - && strlen($tmpPath) > 5 // longer than just "/tmp/" - && strlen($scriptPath) > 5 - ) { - $commands[] = "docker exec {$container} sh -c 'rm {$scriptPath}'"; - $commands[] = "docker exec {$container} sh -c 'rm {$tmpPath}'"; - instant_remote_process($commands, Server::find($serverId), throwError: true); + + if (filled($container) && filled($serverId)) { + $commands = []; + + if (isSafeTmpPath($scriptPath)) { + $commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($scriptPath)." 2>/dev/null || true'"; + } + + if (isSafeTmpPath($tmpPath)) { + $commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($tmpPath)." 2>/dev/null || true'"; + } + + if (! empty($commands)) { + $server = Server::find($serverId); + if ($server) { + instant_remote_process($commands, $server, throwError: false); + } } } } diff --git a/app/Events/S3RestoreJobFinished.php b/app/Events/S3RestoreJobFinished.php new file mode 100644 index 000000000..e1f844558 --- /dev/null +++ b/app/Events/S3RestoreJobFinished.php @@ -0,0 +1,56 @@ +/dev/null || true'; + } + + // Clean up server temp file if still exists (should already be cleaned) + if (isSafeTmpPath($serverTmpPath)) { + $commands[] = 'rm -f '.escapeshellarg($serverTmpPath).' 2>/dev/null || true'; + } + + // Clean up any remaining files in database container (may already be cleaned) + if (filled($container)) { + if (isSafeTmpPath($containerTmpPath)) { + $commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($containerTmpPath).' 2>/dev/null || true'; + } + if (isSafeTmpPath($scriptPath)) { + $commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($scriptPath).' 2>/dev/null || true'; + } + } + + if (! empty($commands)) { + $server = Server::find($serverId); + if ($server) { + instant_remote_process($commands, $server, throwError: false); + } + } + } + } +} diff --git a/app/Exceptions/RateLimitException.php b/app/Exceptions/RateLimitException.php new file mode 100644 index 000000000..fde0235dd --- /dev/null +++ b/app/Exceptions/RateLimitException.php @@ -0,0 +1,15 @@ +settings->force_disabled) { throw new \RuntimeException('Server is disabled.'); @@ -168,7 +168,7 @@ public static function generateSshCommand(Server $server, string $command) $ssh_command = "timeout $timeout ssh "; $multiplexingSuccessful = false; - if (self::isMultiplexingEnabled()) { + if (! $disableMultiplexing && self::isMultiplexingEnabled()) { try { $multiplexingSuccessful = self::ensureMultiplexedConnection($server); if ($multiplexingSuccessful) { diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 6b4f1efee..92c5f04a2 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -192,6 +192,7 @@ public function applications(Request $request) 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], + 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], ], ) ), @@ -342,6 +343,7 @@ public function create_public_application(Request $request) 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], + 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], ], ) ), @@ -492,6 +494,7 @@ public function create_private_gh_app_application(Request $request) 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], + 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], ], ) ), @@ -626,6 +629,7 @@ public function create_private_deploy_key_application(Request $request) 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], + 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], ], ) ), @@ -757,6 +761,7 @@ public function create_dockerfile_application(Request $request) 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], + 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], ], ) ), @@ -927,7 +932,7 @@ private function create_application(Request $request, $type) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -940,6 +945,7 @@ private function create_application(Request $request, $type) 'is_http_basic_auth_enabled' => 'boolean', 'http_basic_auth_username' => 'string|nullable', 'http_basic_auth_password' => 'string|nullable', + 'autogenerate_domain' => 'boolean', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -964,6 +970,7 @@ private function create_application(Request $request, $type) } $serverUuid = $request->server_uuid; $fqdn = $request->domains; + $autogenerateDomain = $request->boolean('autogenerate_domain', true); $instantDeploy = $request->instant_deploy; $githubAppUuid = $request->github_app_uuid; $useBuildServer = $request->use_build_server; @@ -1087,6 +1094,11 @@ private function create_application(Request $request, $type) $application->settings->save(); } $application->refresh(); + // Auto-generate domain if requested and no custom domain provided + if ($autogenerateDomain && blank($fqdn)) { + $application->fqdn = generateUrl(server: $server, random: $application->uuid); + $application->save(); + } if ($application->settings->is_container_label_readonly_enabled) { $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); @@ -1115,7 +1127,7 @@ private function create_application(Request $request, $type) return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), - 'domains' => data_get($application, 'domains'), + 'domains' => data_get($application, 'fqdn'), ]))->setStatusCode(201); } elseif ($type === 'private-gh-app') { $validationRules = [ @@ -1238,6 +1250,11 @@ private function create_application(Request $request, $type) $application->save(); $application->refresh(); + // Auto-generate domain if requested and no custom domain provided + if ($autogenerateDomain && blank($fqdn)) { + $application->fqdn = generateUrl(server: $server, random: $application->uuid); + $application->save(); + } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -1270,7 +1287,7 @@ private function create_application(Request $request, $type) return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), - 'domains' => data_get($application, 'domains'), + 'domains' => data_get($application, 'fqdn'), ]))->setStatusCode(201); } elseif ($type === 'private-deploy-key') { @@ -1367,6 +1384,11 @@ private function create_application(Request $request, $type) $application->environment_id = $environment->id; $application->save(); $application->refresh(); + // Auto-generate domain if requested and no custom domain provided + if ($autogenerateDomain && blank($fqdn)) { + $application->fqdn = generateUrl(server: $server, random: $application->uuid); + $application->save(); + } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -1399,7 +1421,7 @@ private function create_application(Request $request, $type) return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), - 'domains' => data_get($application, 'domains'), + 'domains' => data_get($application, 'fqdn'), ]))->setStatusCode(201); } elseif ($type === 'dockerfile') { $validationRules = [ @@ -1461,6 +1483,11 @@ private function create_application(Request $request, $type) $application->git_branch = 'main'; $application->save(); $application->refresh(); + // Auto-generate domain if requested and no custom domain provided + if ($autogenerateDomain && blank($fqdn)) { + $application->fqdn = generateUrl(server: $server, random: $application->uuid); + $application->save(); + } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -1489,7 +1516,7 @@ private function create_application(Request $request, $type) return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), - 'domains' => data_get($application, 'domains'), + 'domains' => data_get($application, 'fqdn'), ]))->setStatusCode(201); } elseif ($type === 'dockerimage') { $validationRules = [ @@ -1554,6 +1581,11 @@ private function create_application(Request $request, $type) $application->git_branch = 'main'; $application->save(); $application->refresh(); + // Auto-generate domain if requested and no custom domain provided + if ($autogenerateDomain && blank($fqdn)) { + $application->fqdn = generateUrl(server: $server, random: $application->uuid); + $application->save(); + } if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); @@ -1582,7 +1614,7 @@ private function create_application(Request $request, $type) return response()->json(serializeApiResponse([ 'uuid' => data_get($application, 'uuid'), - 'domains' => data_get($application, 'domains'), + 'domains' => data_get($application, 'fqdn'), ]))->setStatusCode(201); } elseif ($type === 'dockercompose') { $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override']; @@ -1652,6 +1684,10 @@ private function create_application(Request $request, $type) $service->save(); $service->parse(isNew: true); + + // Apply service-specific application prerequisites + applyServiceApplicationPrerequisites($service); + if ($instantDeploy) { StartService::dispatch($service); } diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php new file mode 100644 index 000000000..5a03fe59a --- /dev/null +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -0,0 +1,531 @@ +makeHidden([ + 'id', + 'token', + ]); + + return serializeApiResponse($token); + } + + /** + * Validate a provider token against the provider's API. + * + * @return array{valid: bool, error: string|null} + */ + private function validateProviderToken(string $provider, string $token): array + { + try { + $response = match ($provider) { + 'hetzner' => Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'), + 'digitalocean' => Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.digitalocean.com/v2/account'), + default => null, + }; + + if ($response === null) { + return ['valid' => false, 'error' => 'Unsupported provider.']; + } + + if ($response->successful()) { + return ['valid' => true, 'error' => null]; + } + + return ['valid' => false, 'error' => "Invalid {$provider} token. Please check your API token."]; + } catch (\Throwable $e) { + Log::error('Failed to validate cloud provider token', [ + 'provider' => $provider, + 'exception' => $e->getMessage(), + ]); + + return ['valid' => false, 'error' => 'Failed to validate token with provider API.']; + } + } + + #[OA\Get( + summary: 'List Cloud Provider Tokens', + description: 'List all cloud provider tokens for the authenticated team.', + path: '/cloud-tokens', + operationId: 'list-cloud-tokens', + security: [ + ['bearerAuth' => []], + ], + tags: ['Cloud Tokens'], + responses: [ + new OA\Response( + response: 200, + description: 'Get all cloud provider tokens.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']], + 'team_id' => ['type' => 'integer'], + 'servers_count' => ['type' => 'integer'], + 'created_at' => ['type' => 'string'], + 'updated_at' => ['type' => 'string'], + ] + ) + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + ] + )] + public function index(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $tokens = CloudProviderToken::whereTeamId($teamId) + ->withCount('servers') + ->get() + ->map(function ($token) { + return $this->removeSensitiveData($token); + }); + + return response()->json($tokens); + } + + #[OA\Get( + summary: 'Get Cloud Provider Token', + description: 'Get cloud provider token by UUID.', + path: '/cloud-tokens/{uuid}', + operationId: 'get-cloud-token-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Cloud Tokens'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get cloud provider token by UUID', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'provider' => ['type' => 'string'], + 'team_id' => ['type' => 'integer'], + 'servers_count' => ['type' => 'integer'], + 'created_at' => ['type' => 'string'], + 'updated_at' => ['type' => 'string'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function show(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($request->uuid) + ->withCount('servers') + ->first(); + + if (is_null($token)) { + return response()->json(['message' => 'Cloud provider token not found.'], 404); + } + + return response()->json($this->removeSensitiveData($token)); + } + + #[OA\Post( + summary: 'Create Cloud Provider Token', + description: 'Create a new cloud provider token. The token will be validated before being stored.', + path: '/cloud-tokens', + operationId: 'create-cloud-token', + security: [ + ['bearerAuth' => []], + ], + tags: ['Cloud Tokens'], + requestBody: new OA\RequestBody( + required: true, + description: 'Cloud provider token details', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['provider', 'token', 'name'], + properties: [ + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], + 'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'], + 'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Cloud provider token created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the token.'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function store(Request $request) + { + $allowedFields = ['provider', 'token', 'name']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + // Use request body only (excludes any route parameters) + $body = $request->json()->all(); + + $validator = customApiValidator($body, [ + 'provider' => 'required|string|in:hetzner,digitalocean', + 'token' => 'required|string', + 'name' => 'required|string|max:255', + ]); + + $extraFields = array_diff(array_keys($body), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + // Validate token with the provider's API + $validation = $this->validateProviderToken($body['provider'], $body['token']); + + if (! $validation['valid']) { + return response()->json(['message' => $validation['error']], 400); + } + + $cloudProviderToken = CloudProviderToken::create([ + 'team_id' => $teamId, + 'provider' => $body['provider'], + 'token' => $body['token'], + 'name' => $body['name'], + ]); + + return response()->json([ + 'uuid' => $cloudProviderToken->uuid, + ])->setStatusCode(201); + } + + #[OA\Patch( + summary: 'Update Cloud Provider Token', + description: 'Update cloud provider token name.', + path: '/cloud-tokens/{uuid}', + operationId: 'update-cloud-token-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Cloud Tokens'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + description: 'Cloud provider token updated.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The friendly name for the token.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 200, + description: 'Cloud provider token updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update(Request $request) + { + $allowedFields = ['name']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + // Use request body only (excludes route parameters like uuid) + $body = $request->json()->all(); + + $validator = customApiValidator($body, [ + 'name' => 'required|string|max:255', + ]); + + $extraFields = array_diff(array_keys($body), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + // Use route parameter for UUID lookup + $token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first(); + if (! $token) { + return response()->json(['message' => 'Cloud provider token not found.'], 404); + } + + $token->update(array_intersect_key($body, array_flip($allowedFields))); + + return response()->json([ + 'uuid' => $token->uuid, + ]); + } + + #[OA\Delete( + summary: 'Delete Cloud Provider Token', + description: 'Delete cloud provider token by UUID. Cannot delete if token is used by any servers.', + path: '/cloud-tokens/{uuid}', + operationId: 'delete-cloud-token-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Cloud Tokens'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the cloud provider token.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Cloud provider token deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Cloud provider token deleted.'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function destroy(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + if (! $request->uuid) { + return response()->json(['message' => 'UUID is required.'], 422); + } + + $token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first(); + + if (! $token) { + return response()->json(['message' => 'Cloud provider token not found.'], 404); + } + + if ($token->hasServers()) { + return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400); + } + + $token->delete(); + + return response()->json(['message' => 'Cloud provider token deleted.']); + } + + #[OA\Post( + summary: 'Validate Cloud Provider Token', + description: 'Validate a cloud provider token against the provider API.', + path: '/cloud-tokens/{uuid}/validate', + operationId: 'validate-cloud-token-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Cloud Tokens'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Token validation result.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'valid' => ['type' => 'boolean', 'example' => true], + 'message' => ['type' => 'string', 'example' => 'Token is valid.'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function validateToken(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $cloudToken = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first(); + + if (! $cloudToken) { + return response()->json(['message' => 'Cloud provider token not found.'], 404); + } + + $validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token); + + return response()->json([ + 'valid' => $validation['valid'], + 'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'], + ]); + } +} diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 16a7b6f71..136fcf557 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -388,7 +388,11 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p continue; } } - ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr); + $result = $this->deploy_resource($resource, $force, $pr); + if (isset($result['status']) && $result['status'] === 429) { + return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60); + } + ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result; if ($deployment_uuid) { $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]); } else { @@ -430,7 +434,11 @@ public function by_tags(string $tags, int $team_id, bool $force = false) continue; } foreach ($applications as $resource) { - ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force); + $result = $this->deploy_resource($resource, $force); + if (isset($result['status']) && $result['status'] === 429) { + return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60); + } + ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result; if ($deployment_uuid) { $deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]); } @@ -474,8 +482,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar deployment_uuid: $deployment_uuid, force_rebuild: $force, pull_request_id: $pr, + is_api: true, ); - if ($result['status'] === 'skipped') { + if ($result['status'] === 'queue_full') { + return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429]; + } elseif ($result['status'] === 'skipped') { $message = $result['message']; } else { $message = "Application {$resource->name} deployment queued."; diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php new file mode 100644 index 000000000..2645c2df1 --- /dev/null +++ b/app/Http/Controllers/Api/HetznerController.php @@ -0,0 +1,738 @@ +cloud_provider_token_uuid ?? $request->cloud_provider_token_id; + } + + #[OA\Get( + summary: 'Get Hetzner Locations', + description: 'Get all available Hetzner datacenter locations.', + path: '/hetzner/locations', + operationId: 'get-hetzner-locations', + security: [ + ['bearerAuth' => []], + ], + tags: ['Hetzner'], + parameters: [ + new OA\Parameter( + name: 'cloud_provider_token_uuid', + in: 'query', + required: false, + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'cloud_provider_token_id', + in: 'query', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of Hetzner locations.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'country' => ['type' => 'string'], + 'city' => ['type' => 'string'], + 'latitude' => ['type' => 'number'], + 'longitude' => ['type' => 'number'], + ] + ) + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function locations(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $tokenUuid = $this->getCloudProviderTokenUuid($request); + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); + } + + try { + $hetznerService = new HetznerService($token->token); + $locations = $hetznerService->getLocations(); + + return response()->json($locations); + } catch (\Throwable $e) { + return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500); + } + } + + #[OA\Get( + summary: 'Get Hetzner Server Types', + description: 'Get all available Hetzner server types (instance sizes).', + path: '/hetzner/server-types', + operationId: 'get-hetzner-server-types', + security: [ + ['bearerAuth' => []], + ], + tags: ['Hetzner'], + parameters: [ + new OA\Parameter( + name: 'cloud_provider_token_uuid', + in: 'query', + required: false, + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'cloud_provider_token_id', + in: 'query', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of Hetzner server types.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'cores' => ['type' => 'integer'], + 'memory' => ['type' => 'number'], + 'disk' => ['type' => 'integer'], + 'prices' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'location' => ['type' => 'string', 'description' => 'Datacenter location name'], + 'price_hourly' => [ + 'type' => 'object', + 'properties' => [ + 'net' => ['type' => 'string'], + 'gross' => ['type' => 'string'], + ], + ], + 'price_monthly' => [ + 'type' => 'object', + 'properties' => [ + 'net' => ['type' => 'string'], + 'gross' => ['type' => 'string'], + ], + ], + ], + ], + ], + ] + ) + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function serverTypes(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $tokenUuid = $this->getCloudProviderTokenUuid($request); + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); + } + + try { + $hetznerService = new HetznerService($token->token); + $serverTypes = $hetznerService->getServerTypes(); + + return response()->json($serverTypes); + } catch (\Throwable $e) { + return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500); + } + } + + #[OA\Get( + summary: 'Get Hetzner Images', + description: 'Get all available Hetzner system images (operating systems).', + path: '/hetzner/images', + operationId: 'get-hetzner-images', + security: [ + ['bearerAuth' => []], + ], + tags: ['Hetzner'], + parameters: [ + new OA\Parameter( + name: 'cloud_provider_token_uuid', + in: 'query', + required: false, + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'cloud_provider_token_id', + in: 'query', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of Hetzner images.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'type' => ['type' => 'string'], + 'os_flavor' => ['type' => 'string'], + 'os_version' => ['type' => 'string'], + 'architecture' => ['type' => 'string'], + ] + ) + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function images(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $tokenUuid = $this->getCloudProviderTokenUuid($request); + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); + } + + try { + $hetznerService = new HetznerService($token->token); + $images = $hetznerService->getImages(); + + // Filter out deprecated images (same as UI) + $filtered = array_filter($images, function ($image) { + if (isset($image['type']) && $image['type'] !== 'system') { + return false; + } + + if (isset($image['deprecated']) && $image['deprecated'] === true) { + return false; + } + + return true; + }); + + return response()->json(array_values($filtered)); + } catch (\Throwable $e) { + return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500); + } + } + + #[OA\Get( + summary: 'Get Hetzner SSH Keys', + description: 'Get all SSH keys stored in the Hetzner account.', + path: '/hetzner/ssh-keys', + operationId: 'get-hetzner-ssh-keys', + security: [ + ['bearerAuth' => []], + ], + tags: ['Hetzner'], + parameters: [ + new OA\Parameter( + name: 'cloud_provider_token_uuid', + in: 'query', + required: false, + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'cloud_provider_token_id', + in: 'query', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of Hetzner SSH keys.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'fingerprint' => ['type' => 'string'], + 'public_key' => ['type' => 'string'], + ] + ) + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function sshKeys(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $tokenUuid = $this->getCloudProviderTokenUuid($request); + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); + } + + try { + $hetznerService = new HetznerService($token->token); + $sshKeys = $hetznerService->getSshKeys(); + + return response()->json($sshKeys); + } catch (\Throwable $e) { + return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500); + } + } + + #[OA\Post( + summary: 'Create Hetzner Server', + description: 'Create a new server on Hetzner and register it in Coolify.', + path: '/servers/hetzner', + operationId: 'create-hetzner-server', + security: [ + ['bearerAuth' => []], + ], + tags: ['Hetzner'], + requestBody: new OA\RequestBody( + required: true, + description: 'Hetzner server creation parameters', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['location', 'server_type', 'image', 'private_key_uuid'], + properties: [ + 'cloud_provider_token_uuid' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'], + 'cloud_provider_token_id' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', 'deprecated' => true], + 'location' => ['type' => 'string', 'example' => 'nbg1', 'description' => 'Hetzner location name'], + 'server_type' => ['type' => 'string', 'example' => 'cx11', 'description' => 'Hetzner server type name'], + 'image' => ['type' => 'integer', 'example' => 15512617, 'description' => 'Hetzner image ID'], + 'name' => ['type' => 'string', 'example' => 'my-server', 'description' => 'Server name (auto-generated if not provided)'], + 'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'], + 'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'], + 'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'], + 'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'], + 'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'], + 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Hetzner server created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the server.'], + 'hetzner_server_id' => ['type' => 'integer', 'description' => 'The Hetzner server ID.'], + 'ip' => ['type' => 'string', 'description' => 'The server IP address.'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + new OA\Response( + response: 429, + ref: '#/components/responses/429', + ), + ] + )] + public function createServer(Request $request) + { + $allowedFields = [ + 'cloud_provider_token_uuid', + 'cloud_provider_token_id', + 'location', + 'server_type', + 'image', + 'name', + 'private_key_uuid', + 'enable_ipv4', + 'enable_ipv6', + 'hetzner_ssh_key_ids', + 'cloud_init_script', + 'instant_validate', + ]; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + 'location' => 'required|string', + 'server_type' => 'required|string', + 'image' => 'required|integer', + 'name' => ['nullable', 'string', 'max:253', new ValidHostname], + 'private_key_uuid' => 'required|string', + 'enable_ipv4' => 'nullable|boolean', + 'enable_ipv6' => 'nullable|boolean', + 'hetzner_ssh_key_ids' => 'nullable|array', + 'hetzner_ssh_key_ids.*' => 'integer', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'instant_validate' => 'nullable|boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + // Check server limit + if (Team::serverLimitReached()) { + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); + } + + // Set defaults + if (! $request->name) { + $request->offsetSet('name', generate_random_name()); + } + if (is_null($request->enable_ipv4)) { + $request->offsetSet('enable_ipv4', true); + } + if (is_null($request->enable_ipv6)) { + $request->offsetSet('enable_ipv6', true); + } + if (is_null($request->hetzner_ssh_key_ids)) { + $request->offsetSet('hetzner_ssh_key_ids', []); + } + if (is_null($request->instant_validate)) { + $request->offsetSet('instant_validate', false); + } + + // Validate cloud provider token + $tokenUuid = $this->getCloudProviderTokenUuid($request); + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); + } + + // Validate private key + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); + if (! $privateKey) { + return response()->json(['message' => 'Private key not found.'], 404); + } + + try { + $hetznerService = new HetznerService($token->token); + + // Get public key and MD5 fingerprint + $publicKey = $privateKey->getPublicKey(); + $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); + + // Check if SSH key already exists on Hetzner + $existingSshKeys = $hetznerService->getSshKeys(); + $existingKey = null; + + foreach ($existingSshKeys as $key) { + if ($key['fingerprint'] === $md5Fingerprint) { + $existingKey = $key; + break; + } + } + + // Upload SSH key if it doesn't exist + if ($existingKey) { + $sshKeyId = $existingKey['id']; + } else { + $sshKeyName = $privateKey->name; + $uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey); + $sshKeyId = $uploadedKey['id']; + } + + // Normalize server name to lowercase for RFC 1123 compliance + $normalizedServerName = strtolower(trim($request->name)); + + // Prepare SSH keys array: Coolify key + user-selected Hetzner keys + $sshKeys = array_merge( + [$sshKeyId], + $request->hetzner_ssh_key_ids + ); + + // Remove duplicates + $sshKeys = array_unique($sshKeys); + $sshKeys = array_values($sshKeys); + + // Prepare server creation parameters + $params = [ + 'name' => $normalizedServerName, + 'server_type' => $request->server_type, + 'image' => $request->image, + 'location' => $request->location, + 'start_after_create' => true, + 'ssh_keys' => $sshKeys, + 'public_net' => [ + 'enable_ipv4' => $request->enable_ipv4, + 'enable_ipv6' => $request->enable_ipv6, + ], + ]; + + // Add cloud-init script if provided + if (! empty($request->cloud_init_script)) { + $params['user_data'] = $request->cloud_init_script; + } + + // Create server on Hetzner + $hetznerServer = $hetznerService->createServer($params); + + // Determine IP address to use (prefer IPv4, fallback to IPv6) + $ipAddress = null; + if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { + $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; + } elseif ($request->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { + $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; + } + + if (! $ipAddress) { + throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.'); + } + + // Create server in Coolify database + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'hetzner_server_id' => $hetznerServer['id'], + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + // Validate server if requested + if ($request->instant_validate) { + \App\Actions\Server\ValidateServer::dispatch($server); + } + + return response()->json([ + 'uuid' => $server->uuid, + 'hetzner_server_id' => $hetznerServer['id'], + 'ip' => $ipAddress, + ])->setStatusCode(201); + } catch (RateLimitException $e) { + $response = response()->json(['message' => $e->getMessage()], 429); + if ($e->retryAfter !== null) { + $response->header('Retry-After', $e->retryAfter); + } + + return $response; + } catch (\Throwable $e) { + return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500); + } + } +} diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php index 69f71feaf..33d21ba5d 100644 --- a/app/Http/Controllers/Api/OpenApi.php +++ b/app/Http/Controllers/Api/OpenApi.php @@ -61,6 +61,22 @@ ), ] )), + new OA\Response( + response: 429, + description: 'Rate limit exceeded.', + headers: [ + new OA\Header( + header: 'Retry-After', + description: 'Number of seconds to wait before retrying.', + schema: new OA\Schema(type: 'integer', example: 60) + ), + ], + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Rate limit exceeded. Please try again later.'), + ] + )), ], )] class OpenApi diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 2c4d0d361..587f49fa5 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -351,7 +351,7 @@ public function create_service(Request $request) 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]; - if ($oneClickServiceName === 'cloudflared') { + if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) { data_set($servicePayload, 'connect_to_docker_network', true); } $service = Service::create($servicePayload); @@ -376,6 +376,10 @@ public function create_service(Request $request) }); } $service->parse(isNew: true); + + // Apply service-specific application prerequisites + applyServiceApplicationPrerequisites($service); + if ($instantDeploy) { StartService::dispatch($service); } diff --git a/app/Http/Controllers/MagicController.php b/app/Http/Controllers/MagicController.php deleted file mode 100644 index 59c9b8b94..000000000 --- a/app/Http/Controllers/MagicController.php +++ /dev/null @@ -1,84 +0,0 @@ -json([ - 'servers' => Server::isUsable()->get(), - ]); - } - - public function destinations() - { - return response()->json([ - 'destinations' => Server::destinationsByServer(request()->query('server_id'))->sortBy('name'), - ]); - } - - public function projects() - { - return response()->json([ - 'projects' => Project::ownedByCurrentTeam()->get(), - ]); - } - - public function environments() - { - $project = Project::ownedByCurrentTeam()->whereUuid(request()->query('project_uuid'))->first(); - if (! $project) { - return response()->json([ - 'environments' => [], - ]); - } - - return response()->json([ - 'environments' => $project->environments, - ]); - } - - public function newProject() - { - $project = Project::firstOrCreate( - ['name' => request()->query('name') ?? generate_random_name()], - ['team_id' => currentTeam()->id] - ); - - return response()->json([ - 'project_uuid' => $project->uuid, - ]); - } - - public function newEnvironment() - { - $environment = Environment::firstOrCreate( - ['name' => request()->query('name') ?? generate_random_name()], - ['project_id' => Project::ownedByCurrentTeam()->whereUuid(request()->query('project_uuid'))->firstOrFail()->id] - ); - - return response()->json([ - 'environment_name' => $environment->name, - ]); - } - - public function newTeam() - { - $team = Team::create( - [ - 'name' => request()->query('name') ?? generate_random_name(), - 'personal_team' => false, - ], - ); - auth()->user()->teams()->attach($team, ['role' => 'admin']); - refreshSession(); - - return redirect(request()->header('Referer')); - } -} diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 078494f82..183186711 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -2,8 +2,8 @@ namespace App\Http\Controllers\Webhook; +use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; -use App\Livewire\Project\Service\Storage; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -15,23 +15,6 @@ class Bitbucket extends Controller public function manual(Request $request) { try { - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Bitbicket::manual_bitbucket", $json); - - return; - } $return_payloads = collect([]); $payload = $request->collect(); $headers = $request->headers->all(); @@ -107,7 +90,9 @@ public function manual(Request $request) force_rebuild: false, is_webhook: true ); - if ($result['status'] === 'skipped') { + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } elseif ($result['status'] === 'skipped') { $return_payloads->push([ 'application' => $application->name, 'status' => 'skipped', @@ -161,7 +146,9 @@ public function manual(Request $request) is_webhook: true, git_type: 'bitbucket' ); - if ($result['status'] === 'skipped') { + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } elseif ($result['status'] === 'skipped') { $return_payloads->push([ 'application' => $application->name, 'status' => 'skipped', @@ -185,9 +172,10 @@ public function manual(Request $request) if ($x_bitbucket_event === 'pullrequest:rejected' || $x_bitbucket_event === 'pullrequest:fulfilled') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - $found->delete(); - $container_name = generateApplicationContainerName($application, $pull_request_id); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); + $return_payloads->push([ 'application' => $application->name, 'status' => 'success', diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 3e0c5a0b6..b46db0b59 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -2,12 +2,12 @@ namespace App\Http\Controllers\Webhook; +use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -18,30 +18,6 @@ public function manual(Request $request) try { $return_payloads = collect([]); $x_gitea_delivery = request()->header('X-Gitea-Delivery'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $files = Storage::disk('webhooks-during-maintenance')->files(); - $gitea_delivery_found = collect($files)->filter(function ($file) use ($x_gitea_delivery) { - return Str::contains($file, $x_gitea_delivery); - })->first(); - if ($gitea_delivery_found) { - return; - } - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Gitea::manual_{$x_gitea_delivery}", $json); - - return; - } $x_gitea_event = Str::lower($request->header('X-Gitea-Event')); $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); $content_type = $request->header('Content-Type'); @@ -123,7 +99,9 @@ public function manual(Request $request) commit: data_get($payload, 'after', 'HEAD'), is_webhook: true, ); - if ($result['status'] === 'skipped') { + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } elseif ($result['status'] === 'skipped') { $return_payloads->push([ 'application' => $application->name, 'status' => 'skipped', @@ -193,7 +171,9 @@ public function manual(Request $request) is_webhook: true, git_type: 'gitea' ); - if ($result['status'] === 'skipped') { + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } elseif ($result['status'] === 'skipped') { $return_payloads->push([ 'application' => $application->name, 'status' => 'skipped', @@ -217,9 +197,10 @@ public function manual(Request $request) if ($action === 'closed') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - $found->delete(); - $container_name = generateApplicationContainerName($application, $pull_request_id); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); + $return_payloads->push([ 'application' => $application->name, 'status' => 'success', diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index a1fcaa7f5..c8402cbf4 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Webhook; +use App\Actions\Application\CleanupPreviewDeployment; use App\Enums\ProcessStatus; use App\Http\Controllers\Controller; use App\Jobs\ApplicationPullRequestUpdateJob; -use App\Jobs\DeleteResourceJob; use App\Jobs\GithubAppPermissionJob; use App\Models\Application; use App\Models\ApplicationPreview; @@ -14,7 +14,6 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -25,30 +24,6 @@ public function manual(Request $request) try { $return_payloads = collect([]); $x_github_delivery = request()->header('X-GitHub-Delivery'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $files = Storage::disk('webhooks-during-maintenance')->files(); - $github_delivery_found = collect($files)->filter(function ($file) use ($x_github_delivery) { - return Str::contains($file, $x_github_delivery); - })->first(); - if ($github_delivery_found) { - return; - } - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::manual_{$x_github_delivery}", $json); - - return; - } $x_github_event = Str::lower($request->header('X-GitHub-Event')); $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); $content_type = $request->header('Content-Type'); @@ -136,7 +111,9 @@ public function manual(Request $request) commit: data_get($payload, 'after', 'HEAD'), is_webhook: true, ); - if ($result['status'] === 'skipped') { + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } elseif ($result['status'] === 'skipped') { $return_payloads->push([ 'application' => $application->name, 'status' => 'skipped', @@ -222,7 +199,9 @@ public function manual(Request $request) is_webhook: true, git_type: 'github' ); - if ($result['status'] === 'skipped') { + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } elseif ($result['status'] === 'skipped') { $return_payloads->push([ 'application' => $application->name, 'status' => 'skipped', @@ -246,41 +225,10 @@ public function manual(Request $request) if ($action === 'closed') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - // Cancel any active deployments for this PR immediately - $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id) - ->where('pull_request_id', $pull_request_id) - ->whereIn('status', [ - \App\Enums\ApplicationDeploymentStatus::QUEUED->value, - \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, - ]) - ->first(); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); - if ($activeDeployment) { - try { - // Mark deployment as cancelled - $activeDeployment->update([ - 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value, - ]); - - // Add cancellation log entry - $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); - - // Check if helper container exists and kill it - $deployment_uuid = $activeDeployment->deployment_uuid; - $server = $application->destination->server; - $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); - - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - instant_remote_process(["docker rm -f {$deployment_uuid}"], $server); - $activeDeployment->addLogEntry('Deployment container stopped.'); - } - } catch (\Throwable $e) { - // Silently handle errors during deployment cancellation - } - } - - DeleteResourceJob::dispatch($found); $return_payloads->push([ 'application' => $application->name, 'status' => 'success', @@ -310,30 +258,6 @@ public function normal(Request $request) $return_payloads = collect([]); $id = null; $x_github_delivery = $request->header('X-GitHub-Delivery'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $files = Storage::disk('webhooks-during-maintenance')->files(); - $github_delivery_found = collect($files)->filter(function ($file) use ($x_github_delivery) { - return Str::contains($file, $x_github_delivery); - })->first(); - if ($github_delivery_found) { - return; - } - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::normal_{$x_github_delivery}", $json); - - return; - } $x_github_event = Str::lower($request->header('X-GitHub-Event')); $x_github_hook_installation_target_id = $request->header('X-GitHub-Hook-Installation-Target-Id'); $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); @@ -385,7 +309,9 @@ public function normal(Request $request) if (! $id || ! $branch) { return response('Nothing to do. No id or branch found.'); } - $applications = Application::where('repository_project_id', $id)->whereRelation('source', 'is_public', false); + $applications = Application::where('repository_project_id', $id) + ->where('source_id', $github_app->id) + ->whereRelation('source', 'is_public', false); if ($x_github_event === 'push') { $applications = $applications->where('git_branch', $branch)->get(); if ($applications->isEmpty()) { @@ -427,12 +353,15 @@ public function normal(Request $request) force_rebuild: false, is_webhook: true, ); + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } $return_payloads->push([ 'status' => $result['status'], 'message' => $result['message'], 'application_uuid' => $application->uuid, 'application_name' => $application->name, - 'deployment_uuid' => $result['deployment_uuid'], + 'deployment_uuid' => $result['deployment_uuid'] ?? null, ]); } else { $paths = str($application->watch_paths)->explode("\n"); @@ -491,7 +420,9 @@ public function normal(Request $request) is_webhook: true, git_type: 'github' ); - if ($result['status'] === 'skipped') { + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } elseif ($result['status'] === 'skipped') { $return_payloads->push([ 'application' => $application->name, 'status' => 'skipped', @@ -515,53 +446,12 @@ public function normal(Request $request) if ($action === 'closed' || $action === 'close') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - // Cancel any active deployments for this PR immediately - $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id) - ->where('pull_request_id', $pull_request_id) - ->whereIn('status', [ - \App\Enums\ApplicationDeploymentStatus::QUEUED->value, - \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, - ]) - ->first(); - - if ($activeDeployment) { - try { - // Mark deployment as cancelled - $activeDeployment->update([ - 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value, - ]); - - // Add cancellation log entry - $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); - - // Check if helper container exists and kill it - $deployment_uuid = $activeDeployment->deployment_uuid; - $server = $application->destination->server; - $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); - - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - instant_remote_process(["docker rm -f {$deployment_uuid}"], $server); - $activeDeployment->addLogEntry('Deployment container stopped.'); - } - - } catch (\Throwable $e) { - // Silently handle errors during deployment cancellation - } - } - - // Clean up any deployed containers - $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id); - if ($containers->isNotEmpty()) { - $containers->each(function ($container) use ($application) { - $container_name = data_get($container, 'Names'); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); - }); - } - + // Delete the PR comment on GitHub (GitHub-specific feature) ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED); - DeleteResourceJob::dispatch($found); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); $return_payloads->push([ 'application' => $application->name, @@ -624,23 +514,6 @@ public function install(Request $request) { try { $installation_id = $request->get('installation_id'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::install_{$installation_id}", $json); - - return; - } $source = $request->get('source'); $setup_action = $request->get('setup_action'); $github_app = GithubApp::where('uuid', $source)->firstOrFail(); diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 3187663d4..5b8dd5686 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -2,12 +2,12 @@ namespace App\Http\Controllers\Webhook; +use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -16,24 +16,6 @@ class Gitlab extends Controller public function manual(Request $request) { try { - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Gitlab::manual_gitlab", $json); - - return; - } - $return_payloads = collect([]); $payload = $request->collect(); $headers = $request->headers->all(); @@ -149,7 +131,9 @@ public function manual(Request $request) force_rebuild: false, is_webhook: true, ); - if ($result['status'] === 'skipped') { + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } elseif ($result['status'] === 'skipped') { $return_payloads->push([ 'status' => $result['status'], 'message' => $result['message'], @@ -220,7 +204,9 @@ public function manual(Request $request) is_webhook: true, git_type: 'gitlab' ); - if ($result['status'] === 'skipped') { + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } elseif ($result['status'] === 'skipped') { $return_payloads->push([ 'application' => $application->name, 'status' => 'skipped', @@ -243,22 +229,22 @@ public function manual(Request $request) } elseif ($action === 'closed' || $action === 'close' || $action === 'merge') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - $found->delete(); - $container_name = generateApplicationContainerName($application, $pull_request_id); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); + $return_payloads->push([ 'application' => $application->name, 'status' => 'success', - 'message' => 'Preview Deployment closed', + 'message' => 'Preview deployment closed.', + ]); + } else { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'No preview deployment found.', ]); - - return response($return_payloads); } - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'No Preview Deployment found', - ]); } else { $return_payloads->push([ 'application' => $application->name, diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php index ae50aac42..d59adf0ca 100644 --- a/app/Http/Controllers/Webhook/Stripe.php +++ b/app/Http/Controllers/Webhook/Stripe.php @@ -6,7 +6,6 @@ use App\Jobs\StripeProcessJob; use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; class Stripe extends Controller { @@ -20,23 +19,6 @@ public function events(Request $request) $signature, $webhookSecret ); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Stripe::events_stripe", $json); - - return response('Webhook received. Cool cool cool cool cool.', 200); - } StripeProcessJob::dispatch($event); return response('Webhook received. Cool cool cool cool cool.', 200); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 297585562..cc1a44f9a 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -486,15 +486,38 @@ private function decide_what_to_do() private function post_deployment() { - GetContainersStatus::dispatch($this->server); + // Mark deployment as complete FIRST, before any other operations + // This ensures the deployment status is FINISHED even if subsequent operations fail $this->completeDeployment(); + + // Then handle side effects - these should not fail the deployment + try { + GetContainersStatus::dispatch($this->server); + } catch (\Exception $e) { + \Log::warning('Failed to dispatch GetContainersStatus for deployment '.$this->deployment_uuid.': '.$e->getMessage()); + } + if ($this->pull_request_id !== 0) { if ($this->application->is_github_based()) { - ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED); + try { + ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED); + } catch (\Exception $e) { + \Log::warning('Failed to dispatch PR update for deployment '.$this->deployment_uuid.': '.$e->getMessage()); + } } } - $this->run_post_deployment_command(); - $this->application->isConfigurationChanged(true); + + try { + $this->run_post_deployment_command(); + } catch (\Exception $e) { + \Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage()); + } + + try { + $this->application->isConfigurationChanged(true); + } catch (\Exception $e) { + \Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage()); + } } private function deploy_simple_dockerfile() @@ -620,7 +643,7 @@ private function deploy_docker_compose_buildpack() $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.'); } } else { - $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); + $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit); // Always add .env file to services $services = collect(data_get($composeFile, 'services', [])); $services = $services->map(function ($service, $name) { @@ -670,13 +693,20 @@ private function deploy_docker_compose_buildpack() $build_command = "DOCKER_BUILDKIT=1 {$build_command}"; } - // Append build arguments if not using build secrets (matching default behavior) + // Inject build arguments after build subcommand if not using build secrets if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) { $build_args_string = $this->build_args->implode(' '); // Escape single quotes for bash -c context used by executeInDocker $build_args_string = str_replace("'", "'\\''", $build_args_string); - $build_command .= " {$build_args_string}"; - $this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.'); + + // Inject build args right after 'build' subcommand (not at the end) + $original_command = $build_command; + $build_command = injectDockerComposeBuildArgs($build_command, $build_args_string); + + // Only log if build args were actually injected (command was modified) + if ($build_command !== $original_command) { + $this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.'); + } } $this->execute_remote_command( @@ -1363,7 +1393,7 @@ private function save_runtime_environment_variables() $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->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for container.', hidden: true); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"), @@ -1401,15 +1431,44 @@ private function generate_buildtime_environment_variables() $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); } - $envs = collect([]); - $coolify_envs = $this->generate_coolify_env_variables(); + // Use associative array for automatic deduplication + $envs_dict = []; - // Add COOLIFY variables - $coolify_envs->each(function ($item, $key) use ($envs) { - $envs->push($key.'='.escapeBashEnvValue($item)); - }); + // 1. Add nixpacks plan variables FIRST (lowest priority - can be overridden) + if ($this->build_pack === 'nixpacks' && + isset($this->nixpacks_plan_json) && + $this->nixpacks_plan_json->isNotEmpty()) { - // Add SERVICE_NAME variables for Docker Compose builds + $planVariables = data_get($this->nixpacks_plan_json, 'variables', []); + + if (! empty($planVariables)) { + if (isDev()) { + $this->application_deployment_queue->addLogEntry('[DEBUG] Adding '.count($planVariables).' nixpacks plan variables to buildtime.env'); + } + + foreach ($planVariables as $key => $value) { + // Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority + if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) { + continue; + } + + $escapedValue = escapeBashEnvValue($value); + $envs_dict[$key] = $escapedValue; + + if (isDev()) { + $this->application_deployment_queue->addLogEntry("[DEBUG] Nixpacks var: {$key}={$escapedValue}"); + } + } + } + } + + // 2. Add COOLIFY variables (can override nixpacks, but shouldn't happen in practice) + $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); + foreach ($coolify_envs as $key => $item) { + $envs_dict[$key] = escapeBashEnvValue($item); + } + + // 3. Add SERVICE_NAME, SERVICE_FQDN, SERVICE_URL 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 @@ -1420,7 +1479,7 @@ private function generate_buildtime_environment_variables() } $services = data_get($dockerCompose, 'services', []); foreach ($services as $serviceName => $_) { - $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.escapeBashEnvValue($serviceName)); + $envs_dict['SERVICE_NAME_'.str($serviceName)->upper()] = escapeBashEnvValue($serviceName); } // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments @@ -1433,8 +1492,8 @@ private function generate_buildtime_environment_variables() $coolifyScheme = $coolifyUrl->getScheme(); $coolifyFqdn = $coolifyUrl->getHost(); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString())); - $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn)); + $envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString()); + $envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn); } } } else { @@ -1442,7 +1501,7 @@ private function generate_buildtime_environment_variables() $rawDockerCompose = Yaml::parse($this->application->docker_compose_raw); $rawServices = data_get($rawDockerCompose, 'services', []); foreach ($rawServices as $rawServiceName => $_) { - $envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id))); + $envs_dict['SERVICE_NAME_'.str($rawServiceName)->upper()] = escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)); } // Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains @@ -1455,17 +1514,16 @@ private function generate_buildtime_environment_variables() $coolifyScheme = $coolifyUrl->getScheme(); $coolifyFqdn = $coolifyUrl->getHost(); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString())); - $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn)); + $envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString()); + $envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn); } } } } - // Add build-time user variables only + // 4. Add user-defined build-time variables LAST (highest priority - can override everything) 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(); @@ -1483,7 +1541,12 @@ private function generate_buildtime_environment_variables() // Strip outer quotes from real_value and apply proper bash escaping $value = trim($env->real_value, "'"); $escapedValue = escapeBashEnvValue($value); - $envs->push($env->key.'='.$escapedValue); + + if (isDev() && isset($envs_dict[$env->key])) { + $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})"); + } + + $envs_dict[$env->key] = $escapedValue; if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); @@ -1495,7 +1558,12 @@ private function generate_buildtime_environment_variables() } else { // For normal vars, use double quotes to allow $VAR expansion $escapedValue = escapeBashDoubleQuoted($env->real_value); - $envs->push($env->key.'='.$escapedValue); + + if (isDev() && isset($envs_dict[$env->key])) { + $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})"); + } + + $envs_dict[$env->key] = $escapedValue; if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); @@ -1507,7 +1575,6 @@ private function generate_buildtime_environment_variables() } } 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(); @@ -1525,7 +1592,12 @@ private function generate_buildtime_environment_variables() // Strip outer quotes from real_value and apply proper bash escaping $value = trim($env->real_value, "'"); $escapedValue = escapeBashEnvValue($value); - $envs->push($env->key.'='.$escapedValue); + + if (isDev() && isset($envs_dict[$env->key])) { + $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})"); + } + + $envs_dict[$env->key] = $escapedValue; if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); @@ -1537,7 +1609,12 @@ private function generate_buildtime_environment_variables() } else { // For normal vars, use double quotes to allow $VAR expansion $escapedValue = escapeBashDoubleQuoted($env->real_value); - $envs->push($env->key.'='.$escapedValue); + + if (isDev() && isset($envs_dict[$env->key])) { + $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})"); + } + + $envs_dict[$env->key] = $escapedValue; if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); @@ -1549,6 +1626,12 @@ private function generate_buildtime_environment_variables() } } + // Convert dictionary back to collection in KEY=VALUE format + $envs = collect([]); + foreach ($envs_dict as $key => $value) { + $envs->push($key.'='.$value); + } + // Return the generated environment variables if (isDev()) { $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); @@ -1753,9 +1836,9 @@ private function health_check() $this->application->update(['status' => 'running']); $this->application_deployment_queue->addLogEntry('New container is healthy.'); break; - } - if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') { + } elseif (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') { $this->newVersionIsHealthy = false; + $this->application_deployment_queue->addLogEntry('New container is unhealthy.', type: 'error'); $this->query_logs(); break; } @@ -1957,7 +2040,12 @@ private function deploy_to_additional_destinations() private function set_coolify_variables() { - $this->coolify_variables = "SOURCE_COMMIT={$this->commit} "; + $this->coolify_variables = ''; + + // Only include SOURCE_COMMIT in build context if enabled in settings + if ($this->application->settings->include_source_commit_in_build) { + $this->coolify_variables .= "SOURCE_COMMIT={$this->commit} "; + } if ($this->pull_request_id === 0) { $fqdn = $this->application->fqdn; } else { @@ -1979,7 +2067,6 @@ private function set_coolify_variables() $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() @@ -2217,38 +2304,44 @@ private function generate_nixpacks_env_variables() $this->env_nixpacks_args = collect([]); if ($this->pull_request_id === 0) { foreach ($this->application->nixpacks_environment_variables as $env) { - if (! is_null($env->real_value)) { + if (! is_null($env->real_value) && $env->real_value !== '') { $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}"); } } } else { foreach ($this->application->nixpacks_environment_variables_preview as $env) { - if (! is_null($env->real_value)) { + if (! is_null($env->real_value) && $env->real_value !== '') { $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}"); } } } // Add COOLIFY_* environment variables to Nixpacks build context - $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { - $this->env_nixpacks_args->push("--env {$key}={$value}"); + // Only add environment variables with non-null and non-empty values + if (! is_null($value) && $value !== '') { + $this->env_nixpacks_args->push("--env {$key}={$value}"); + } }); $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } - private function generate_coolify_env_variables(): Collection + private function generate_coolify_env_variables(bool $forBuildTime = false): Collection { $coolify_envs = collect([]); $local_branch = $this->branch; if ($this->pull_request_id !== 0) { - // Add SOURCE_COMMIT if not exists - if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) { - if (! is_null($this->commit)) { - $coolify_envs->put('SOURCE_COMMIT', $this->commit); - } else { - $coolify_envs->put('SOURCE_COMMIT', 'unknown'); + // Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time + // SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build + if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) { + if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) { + if (! is_null($this->commit)) { + $coolify_envs->put('SOURCE_COMMIT', $this->commit); + } else { + $coolify_envs->put('SOURCE_COMMIT', 'unknown'); + } } } if ($this->application->environment_variables_preview->where('key', 'COOLIFY_FQDN')->isEmpty()) { @@ -2273,20 +2366,26 @@ private function generate_coolify_env_variables(): Collection if ($this->application->environment_variables_preview->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) { $coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid); } - if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { - $coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name); + // Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache + if (! $forBuildTime) { + if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { + $coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name); + } } } add_coolify_default_environment_variables($this->application, $coolify_envs, $this->application->environment_variables_preview); } else { - // Add SOURCE_COMMIT if not exists - if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) { - if (! is_null($this->commit)) { - $coolify_envs->put('SOURCE_COMMIT', $this->commit); - } else { - $coolify_envs->put('SOURCE_COMMIT', 'unknown'); + // Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time + // SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build + if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) { + if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) { + if (! is_null($this->commit)) { + $coolify_envs->put('SOURCE_COMMIT', $this->commit); + } else { + $coolify_envs->put('SOURCE_COMMIT', 'unknown'); + } } } if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) { @@ -2311,8 +2410,11 @@ private function generate_coolify_env_variables(): Collection if ($this->application->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) { $coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid); } - if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { - $coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name); + // Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache + if (! $forBuildTime) { + if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { + $coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name); + } } } @@ -2326,9 +2428,13 @@ private function generate_coolify_env_variables(): Collection private function generate_env_variables() { $this->env_args = collect([]); - $this->env_args->put('SOURCE_COMMIT', $this->commit); - $coolify_envs = $this->generate_coolify_env_variables(); + // Only include SOURCE_COMMIT in build args if enabled in settings + if ($this->application->settings->include_source_commit_in_build) { + $this->env_args->put('SOURCE_COMMIT', $this->commit); + } + + $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { $this->env_args->put($key, $value); }); @@ -2748,7 +2854,7 @@ private function build_image() } else { // Traditional build args approach - generate COOLIFY_ variables locally // Generate COOLIFY_ variables locally for build args - $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { $this->build_args->push("--build-arg '{$key}'"); }); @@ -3070,7 +3176,7 @@ private function graceful_shutdown_container(string $containerName) try { $timeout = isDev() ? 1 : 30; $this->execute_remote_command( - ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], + ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true], ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ); } catch (Exception $error) { @@ -3107,6 +3213,18 @@ private function stop_running_container(bool $force = false) $this->graceful_shutdown_container($this->container_name); } } catch (Exception $e) { + // If new version is healthy, this is just cleanup - don't fail the deployment + if ($this->newVersionIsHealthy || $force) { + $this->application_deployment_queue->addLogEntry( + "Warning: Could not remove old container: {$e->getMessage()}", + 'stderr', + hidden: true + ); + + return; // Don't re-throw - cleanup failures shouldn't fail successful deployments + } + + // Only re-throw if deployment hasn't succeeded yet throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e); } } @@ -3294,7 +3412,9 @@ private function generate_build_secrets(Collection $variables) private function generate_secrets_hash($variables) { if (! $this->secrets_hash_key) { - $this->secrets_hash_key = bin2hex(random_bytes(32)); + // Use APP_KEY as deterministic hash key to preserve Docker build cache + // Random keys would change every deployment, breaking cache even when secrets haven't changed + $this->secrets_hash_key = config('app.key'); } if ($variables instanceof Collection) { @@ -3337,100 +3457,121 @@ private function add_build_env_variables_to_dockerfile() { if ($this->dockerBuildkitSupported) { // We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets + return; + } + + // Skip ARG injection if disabled by user - preserves Docker build cache + if ($this->application->settings->inject_build_args_to_dockerfile === false) { + $this->application_deployment_queue->addLogEntry('Skipping Dockerfile ARG injection (disabled in settings).', hidden: true); + + return; + } + + $this->execute_remote_command([ + 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")); + + // Find all FROM instruction positions + $fromLines = $this->findFromInstructionLines($dockerfile); + + // If no FROM instructions found, skip ARG insertion + if (empty($fromLines)) { + return; + } + + // Collect all ARG statements to insert + $argsToInsert = collect(); + + if ($this->pull_request_id === 0) { + // Only add environment variables that are available during build + $envs = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + foreach ($envs as $env) { + if (data_get($env, 'is_multiline') === true) { + $argsToInsert->push("ARG {$env->key}"); + } else { + $argsToInsert->push("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}"; + }); + $argsToInsert = $argsToInsert->merge($coolify_vars); + } } else { - $this->execute_remote_command([ + // Only add preview environment variables that are available during build + $envs = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + foreach ($envs as $env) { + if (data_get($env, 'is_multiline') === true) { + $argsToInsert->push("ARG {$env->key}"); + } else { + $argsToInsert->push("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}"; + }); + $argsToInsert = $argsToInsert->merge($coolify_vars); + } + } + + // Development logging to show what ARGs are being injected + if (isDev()) { + $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); + $this->application_deployment_queue->addLogEntry('[DEBUG] Dockerfile ARG Injection'); + $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); + $this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToInsert->count()); + foreach ($argsToInsert as $arg) { + // Only show ARG key, not the value (for security) + $argKey = str($arg)->after('ARG ')->before('=')->toString(); + $this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}"); + } + } + + // Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers) + if ($argsToInsert->isNotEmpty()) { + foreach (array_reverse($fromLines) as $fromLineIndex) { + // Insert all ARGs after this FROM instruction + foreach ($argsToInsert->reverse() as $arg) { + $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); + } + } + $envs_mapped = $envs->mapWithKeys(function ($env) { + return [$env->key => $env->real_value]; + }); + $secrets_hash = $this->generate_secrets_hash($envs_mapped); + $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); + } + + $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); + $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true); + $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, - 'save' => 'dockerfile', 'ignore_errors' => true, ]); - $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); - - // Find all FROM instruction positions - $fromLines = $this->findFromInstructionLines($dockerfile); - - // If no FROM instructions found, skip ARG insertion - if (empty($fromLines)) { - return; - } - - // Collect all ARG statements to insert - $argsToInsert = collect(); - - if ($this->pull_request_id === 0) { - // Only add environment variables that are available during build - $envs = $this->application->environment_variables() - ->where('key', 'not like', 'NIXPACKS_%') - ->where('is_buildtime', true) - ->get(); - foreach ($envs as $env) { - if (data_get($env, 'is_multiline') === true) { - $argsToInsert->push("ARG {$env->key}"); - } else { - $argsToInsert->push("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}"; - }); - $argsToInsert = $argsToInsert->merge($coolify_vars); - } - } else { - // Only add preview environment variables that are available during build - $envs = $this->application->environment_variables_preview() - ->where('key', 'not like', 'NIXPACKS_%') - ->where('is_buildtime', true) - ->get(); - foreach ($envs as $env) { - if (data_get($env, 'is_multiline') === true) { - $argsToInsert->push("ARG {$env->key}"); - } else { - $argsToInsert->push("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}"; - }); - $argsToInsert = $argsToInsert->merge($coolify_vars); - } - } - - // Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers) - if ($argsToInsert->isNotEmpty()) { - foreach (array_reverse($fromLines) as $fromLineIndex) { - // Insert all ARGs after this FROM instruction - foreach ($argsToInsert->reverse() as $arg) { - $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); - } - } - $envs_mapped = $envs->mapWithKeys(function ($env) { - return [$env->key => $env->real_value]; - }); - $secrets_hash = $this->generate_secrets_hash($envs_mapped); - $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); - } - - $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); - $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true); - $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, - ]); - } } private function modify_dockerfile_for_secrets($dockerfile_path) @@ -3503,6 +3644,13 @@ private function modify_dockerfiles_for_compose($composeFile) return; } + // Skip ARG injection if disabled by user - preserves Docker build cache + if ($this->application->settings->inject_build_args_to_dockerfile === false) { + $this->application_deployment_queue->addLogEntry('Skipping Docker Compose Dockerfile ARG injection (disabled in settings).', hidden: true); + + return; + } + // 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()) { @@ -3593,6 +3741,18 @@ private function modify_dockerfiles_for_compose($composeFile) continue; } + // Development logging to show what ARGs are being injected for Docker Compose + if (isDev()) { + $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); + $this->application_deployment_queue->addLogEntry("[DEBUG] Docker Compose ARG Injection - Service: {$serviceName}"); + $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); + $this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToAdd->count()); + foreach ($argsToAdd as $arg) { + $argKey = str($arg)->after('ARG ')->toString(); + $this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}"); + } + } + $totalAdded = 0; $offset = 0; @@ -3797,13 +3957,17 @@ private function transitionToStatus(ApplicationDeploymentStatus $status): void } /** - * Check if deployment is in a terminal state (FAILED or CANCELLED). + * Check if deployment is in a terminal state (FINISHED, FAILED or CANCELLED). * Terminal states cannot be changed. */ private function isInTerminalState(): bool { $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FINISHED->value) { + return true; + } + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) { return true; } @@ -3843,6 +4007,15 @@ private function handleStatusTransition(ApplicationDeploymentStatus $status): vo */ private function handleSuccessfulDeployment(): void { + // Reset restart count after successful deployment + // This is done here (not in Livewire) to avoid race conditions + // with GetContainersStatus reading old container restart counts + $this->application->update([ + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); + event(new ApplicationConfigurationChanged($this->application->team()->id)); if (! $this->only_this_server) { diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php index 4f2bfa68c..8da2426da 100644 --- a/app/Jobs/CheckForUpdatesJob.php +++ b/app/Jobs/CheckForUpdatesJob.php @@ -10,6 +10,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue { @@ -22,20 +23,60 @@ public function handle(): void return; } $settings = instanceSettings(); - $response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json'); + $response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url')); if ($response->successful()) { $versions = $response->json(); $latest_version = data_get($versions, 'coolify.v4.version'); $current_version = config('constants.coolify.version'); + // Read existing cached version + $existingVersions = null; + $existingCoolifyVersion = null; + if (File::exists(base_path('versions.json'))) { + $existingVersions = json_decode(File::get(base_path('versions.json')), true); + $existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version'); + } + + // Determine the BEST version to use (CDN, cache, or current) + $bestVersion = $latest_version; + + // Check if cache has newer version than CDN + if ($existingCoolifyVersion && version_compare($existingCoolifyVersion, $bestVersion, '>')) { + Log::warning('CDN served older Coolify version than cache', [ + 'cdn_version' => $latest_version, + 'cached_version' => $existingCoolifyVersion, + 'current_version' => $current_version, + ]); + $bestVersion = $existingCoolifyVersion; + } + + // CRITICAL: Never allow bestVersion to be older than currently running version + if (version_compare($bestVersion, $current_version, '<')) { + Log::warning('Version downgrade prevented in CheckForUpdatesJob', [ + 'cdn_version' => $latest_version, + 'cached_version' => $existingCoolifyVersion, + 'current_version' => $current_version, + 'attempted_best' => $bestVersion, + 'using' => $current_version, + ]); + $bestVersion = $current_version; + } + + // Use data_set() for safe mutation (fixes #3) + data_set($versions, 'coolify.v4.version', $bestVersion); + $latest_version = $bestVersion; + + // ALWAYS write versions.json (for Sentinel, Helper, Traefik updates) + File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT)); + + // Invalidate cache to ensure fresh data is loaded + invalidate_versions_cache(); + + // Only mark new version available if Coolify version actually increased if (version_compare($latest_version, $current_version, '>')) { // New version available $settings->update(['new_version_available' => true]); - File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT)); - - // Invalidate cache to ensure fresh data is loaded - invalidate_versions_cache(); } else { $settings->update(['new_version_available' => false]); } diff --git a/app/Jobs/CheckHelperImageJob.php b/app/Jobs/CheckHelperImageJob.php index 6abb8a150..6d76da8eb 100644 --- a/app/Jobs/CheckHelperImageJob.php +++ b/app/Jobs/CheckHelperImageJob.php @@ -21,7 +21,7 @@ public function __construct() {} public function handle(): void { try { - $response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json'); + $response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url')); if ($response->successful()) { $versions = $response->json(); $settings = instanceSettings(); diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 88484bcce..92ec4cbd4 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; use Illuminate\Bus\Queueable; @@ -38,6 +39,8 @@ public function handle(): void $this->server->update(['detected_traefik_version' => $currentVersion]); if (! $currentVersion) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } @@ -48,16 +51,22 @@ public function handle(): void // Handle empty/null response from SSH command if (empty(trim($imageTag))) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } if (str_contains(strtolower(trim($imageTag)), ':latest')) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } // Parse current version to extract major.minor.patch $current = ltrim($currentVersion, 'v'); if (! preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches)) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } @@ -77,6 +86,8 @@ public function handle(): void $this->server->update(['traefik_outdated_info' => null]); } + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } @@ -96,6 +107,9 @@ public function handle(): void // Fully up to date $this->server->update(['traefik_outdated_info' => null]); } + + // Dispatch UI update event so warning state refreshes in real-time + ProxyStatusChangedUI::dispatch($this->server->team_id); } /** diff --git a/app/Jobs/CleanupOrphanedPreviewContainersJob.php b/app/Jobs/CleanupOrphanedPreviewContainersJob.php new file mode 100644 index 000000000..5d3bed457 --- /dev/null +++ b/app/Jobs/CleanupOrphanedPreviewContainersJob.php @@ -0,0 +1,214 @@ +expireAfter(600)->dontRelease()]; + } + + public function handle(): void + { + try { + $servers = $this->getServersToCheck(); + + foreach ($servers as $server) { + $this->cleanupOrphanedContainersOnServer($server); + } + } catch (\Throwable $e) { + Log::error('CleanupOrphanedPreviewContainersJob failed: '.$e->getMessage()); + send_internal_notification('CleanupOrphanedPreviewContainersJob failed with error: '.$e->getMessage()); + } + } + + /** + * Get all functional servers to check for orphaned containers. + */ + private function getServersToCheck(): \Illuminate\Support\Collection + { + $query = Server::whereRelation('settings', 'is_usable', true) + ->whereRelation('settings', 'is_reachable', true) + ->where('ip', '!=', '1.2.3.4'); + + if (isCloud()) { + $query = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true); + } + + return $query->get()->filter(fn ($server) => $server->isFunctional()); + } + + /** + * Find and clean up orphaned PR containers on a specific server. + */ + private function cleanupOrphanedContainersOnServer(Server $server): void + { + try { + $prContainers = $this->getPRContainersOnServer($server); + + if ($prContainers->isEmpty()) { + return; + } + + $orphanedCount = 0; + foreach ($prContainers as $container) { + if ($this->isOrphanedContainer($container)) { + $this->removeContainer($container, $server); + $orphanedCount++; + } + } + + if ($orphanedCount > 0) { + Log::info("CleanupOrphanedPreviewContainersJob - Removed {$orphanedCount} orphaned PR containers", [ + 'server' => $server->name, + ]); + } + } catch (\Throwable $e) { + Log::warning("CleanupOrphanedPreviewContainersJob - Error on server {$server->name}: {$e->getMessage()}"); + } + } + + /** + * Get all PR containers on a server (containers with pullRequestId > 0). + */ + private function getPRContainersOnServer(Server $server): \Illuminate\Support\Collection + { + try { + $output = instant_remote_process([ + "docker ps -a --filter 'label=coolify.pullRequestId' --format '{{json .}}'", + ], $server, false); + + if (empty($output)) { + return collect(); + } + + return format_docker_command_output_to_json($output) + ->filter(function ($container) { + // Only include PR containers (pullRequestId > 0) + $prId = $this->extractPullRequestId($container); + + return $prId !== null && $prId > 0; + }); + } catch (\Throwable $e) { + Log::debug("Failed to get PR containers on server {$server->name}: {$e->getMessage()}"); + + return collect(); + } + } + + /** + * Extract pull request ID from container labels. + */ + private function extractPullRequestId($container): ?int + { + $labels = data_get($container, 'Labels', ''); + if (preg_match('/coolify\.pullRequestId=(\d+)/', $labels, $matches)) { + return (int) $matches[1]; + } + + return null; + } + + /** + * Extract application ID from container labels. + */ + private function extractApplicationId($container): ?int + { + $labels = data_get($container, 'Labels', ''); + if (preg_match('/coolify\.applicationId=(\d+)/', $labels, $matches)) { + return (int) $matches[1]; + } + + return null; + } + + /** + * Check if a container is orphaned (no corresponding ApplicationPreview record). + */ + private function isOrphanedContainer($container): bool + { + $applicationId = $this->extractApplicationId($container); + $pullRequestId = $this->extractPullRequestId($container); + + if ($applicationId === null || $pullRequestId === null) { + return false; + } + + // Check if ApplicationPreview record exists (including soft-deleted) + $previewExists = ApplicationPreview::withTrashed() + ->where('application_id', $applicationId) + ->where('pull_request_id', $pullRequestId) + ->exists(); + + // If preview exists (even soft-deleted), container should be handled by DeleteResourceJob + // If preview doesn't exist at all, it's truly orphaned + return ! $previewExists; + } + + /** + * Remove an orphaned container from the server. + */ + private function removeContainer($container, Server $server): void + { + $containerName = data_get($container, 'Names'); + + if (empty($containerName)) { + Log::warning('CleanupOrphanedPreviewContainersJob - Cannot remove container: missing container name', [ + 'container_data' => $container, + 'server' => $server->name, + ]); + + return; + } + + $applicationId = $this->extractApplicationId($container); + $pullRequestId = $this->extractPullRequestId($container); + + Log::info('CleanupOrphanedPreviewContainersJob - Removing orphaned container', [ + 'container' => $containerName, + 'application_id' => $applicationId, + 'pull_request_id' => $pullRequestId, + 'server' => $server->name, + ]); + + $escapedContainerName = escapeshellarg($containerName); + + try { + instant_remote_process( + ["docker rm -f {$escapedContainerName}"], + $server, + false + ); + } catch (\Throwable $e) { + Log::warning("Failed to remove orphaned container {$containerName}: {$e->getMessage()}"); + } + } +} diff --git a/app/Jobs/CoolifyTask.php b/app/Jobs/CoolifyTask.php index d6dc6fa05..ce535e036 100755 --- a/app/Jobs/CoolifyTask.php +++ b/app/Jobs/CoolifyTask.php @@ -90,5 +90,22 @@ public function failed(?\Throwable $exception): void 'failed_at' => now()->toIso8601String(), ]); $this->activity->save(); + + // Dispatch cleanup event on failure (same as on success) + if ($this->call_event_on_finish) { + try { + $eventClass = "App\\Events\\$this->call_event_on_finish"; + if (! is_null($this->call_event_data)) { + event(new $eventClass($this->call_event_data)); + } else { + event(new $eventClass($this->activity->causer_id)); + } + Log::info('Cleanup event dispatched after job failure', [ + 'event' => $this->call_event_on_finish, + ]); + } catch (\Throwable $e) { + Log::error('Error dispatching cleanup event on failure: '.$e->getMessage()); + } + } } } diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 8766a1afc..a585baa69 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -121,7 +121,7 @@ public function handle(): void $this->container_name = "{$this->database->name}-$serviceUuid"; $this->directory_name = $serviceName.'-'.$this->container_name; $commands[] = "docker exec $this->container_name env | grep POSTGRES_"; - $envs = instant_remote_process($commands, $this->server); + $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); $user = $envs->filter(function ($env) { @@ -152,7 +152,7 @@ public function handle(): void $this->container_name = "{$this->database->name}-$serviceUuid"; $this->directory_name = $serviceName.'-'.$this->container_name; $commands[] = "docker exec $this->container_name env | grep MYSQL_"; - $envs = instant_remote_process($commands, $this->server); + $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); $rootPassword = $envs->filter(function ($env) { @@ -175,7 +175,7 @@ public function handle(): void $this->container_name = "{$this->database->name}-$serviceUuid"; $this->directory_name = $serviceName.'-'.$this->container_name; $commands[] = "docker exec $this->container_name env"; - $envs = instant_remote_process($commands, $this->server); + $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); $rootPassword = $envs->filter(function ($env) { return str($env)->startsWith('MARIADB_ROOT_PASSWORD='); @@ -217,7 +217,7 @@ public function handle(): void try { $commands = []; $commands[] = "docker exec $this->container_name env | grep MONGO_INITDB_"; - $envs = instant_remote_process($commands, $this->server); + $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); if (filled($envs)) { $envs = str($envs)->explode("\n"); @@ -489,21 +489,26 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi $collectionsToExclude = collect(); } $commands[] = 'mkdir -p '.$this->backup_dir; + + // Validate and escape database name to prevent command injection + validateShellSafePath($databaseName, 'database name'); + $escapedDatabaseName = escapeshellarg($databaseName); + if ($collectionsToExclude->count() === 0) { if (str($this->database->image)->startsWith('mongo:4')) { $commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location"; } else { - $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --archive > $this->backup_location"; + $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location"; } } else { if (str($this->database->image)->startsWith('mongo:4')) { $commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location"; } else { - $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location"; + $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location"; } } } - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -525,11 +530,14 @@ private function backup_standalone_postgresql(string $database): void if ($this->backup->dump_all) { $backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location"; } else { - $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $database > $this->backup_location"; + // Validate and escape database name to prevent command injection + validateShellSafePath($database, 'database name'); + $escapedDatabase = escapeshellarg($database); + $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location"; } $commands[] = $backupCommand; - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -547,9 +555,12 @@ private function backup_standalone_mysql(string $database): void if ($this->backup->dump_all) { $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location"; } else { - $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $database > $this->backup_location"; + // Validate and escape database name to prevent command injection + validateShellSafePath($database, 'database name'); + $escapedDatabase = escapeshellarg($database); + $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location"; } - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -567,9 +578,12 @@ private function backup_standalone_mariadb(string $database): void if ($this->backup->dump_all) { $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location"; } else { - $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $database > $this->backup_location"; + // Validate and escape database name to prevent command injection + validateShellSafePath($database, 'database name'); + $escapedDatabase = escapeshellarg($database); + $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location"; } - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -600,7 +614,7 @@ private function add_to_error_output($output): void private function calculate_size() { - return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false); + return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false, false, null, disableMultiplexing: true); } private function upload_to_s3(): void @@ -623,9 +637,9 @@ private function upload_to_s3(): void $fullImageName = $this->getFullImageName(); - $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false); + $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true); if (filled($containerExists)) { - instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false); + instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true); } if (isDev()) { @@ -639,9 +653,15 @@ private function upload_to_s3(): void } else { $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; } - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\""; + + // Escape S3 credentials to prevent command injection + $escapedEndpoint = escapeshellarg($endpoint); + $escapedKey = escapeshellarg($key); + $escapedSecret = escapeshellarg($secret); + + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; - instant_remote_process($commands, $this->server); + instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $this->s3_uploaded = true; } catch (\Throwable $e) { @@ -650,7 +670,7 @@ private function upload_to_s3(): void throw $e; } finally { $command = "docker rm -f backup-of-{$this->backup_log_uuid}"; - instant_remote_process([$command], $this->server); + instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true); } } diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index c4358570e..825604910 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -191,7 +191,7 @@ private function stopPreviewContainers(array $containers, $server, int $timeout $containerList = implode(' ', array_map('escapeshellarg', $containerNames)); $commands = [ - "docker stop --time=$timeout $containerList", + "docker stop -t $timeout $containerList", "docker rm -f $containerList", ]; instant_remote_process( diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 9d44e08f9..e6c64ada7 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -300,8 +300,9 @@ private function aggregateMultiContainerStatuses() } // Use ContainerStatusAggregator service for state machine logic + // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); // Update application status with aggregated result if ($aggregatedStatus && $application->status !== $aggregatedStatus) { @@ -360,8 +361,9 @@ private function aggregateServiceContainerStatuses() // Use ContainerStatusAggregator service for state machine logic // NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0 + // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); // Update service sub-resource status with aggregated result if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php index e3e809c8d..2815c73bc 100644 --- a/app/Jobs/RestartProxyJob.php +++ b/app/Jobs/RestartProxyJob.php @@ -2,9 +2,12 @@ namespace App\Jobs; -use App\Actions\Proxy\StartProxy; -use App\Actions\Proxy\StopProxy; +use App\Actions\Proxy\GetProxyConfiguration; +use App\Actions\Proxy\SaveProxyConfiguration; +use App\Enums\ProxyTypes; +use App\Events\ProxyStatusChangedUI; use App\Models\Server; +use App\Services\ProxyDashboardCacheService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -19,11 +22,13 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue public $tries = 1; - public $timeout = 60; + public $timeout = 120; + + public ?int $activity_id = null; public function middleware(): array { - return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(60)->dontRelease()]; + return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(120)->dontRelease()]; } public function __construct(public Server $server) {} @@ -31,15 +36,125 @@ public function __construct(public Server $server) {} public function handle() { try { - StopProxy::run($this->server, restarting: true); - + // Set status to restarting + $this->server->proxy->status = 'restarting'; $this->server->proxy->force_stop = false; $this->server->save(); - StartProxy::run($this->server, force: true, restarting: true); + // Build combined stop + start commands for a single activity + $commands = $this->buildRestartCommands(); + + // Create activity and dispatch immediately - returns Activity right away + // The remote_process runs asynchronously, so UI gets activity ID instantly + $activity = remote_process( + $commands, + $this->server, + callEventOnFinish: 'ProxyStatusChanged', + callEventData: $this->server->id + ); + + // Store activity ID and notify UI immediately with it + $this->activity_id = $activity->id; + ProxyStatusChangedUI::dispatch($this->server->team_id, $this->activity_id); } catch (\Throwable $e) { + // Set error status + $this->server->proxy->status = 'error'; + $this->server->save(); + + // Notify UI of error + ProxyStatusChangedUI::dispatch($this->server->team_id); + + // Clear dashboard cache on error + ProxyDashboardCacheService::clearCache($this->server); + return handleError($e); } } + + /** + * Build combined stop + start commands for proxy restart. + * This creates a single command sequence that shows all logs in one activity. + */ + private function buildRestartCommands(): array + { + $proxyType = $this->server->proxyType(); + $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; + $proxy_path = $this->server->proxyPath(); + $stopTimeout = 30; + + // Get proxy configuration + $configuration = GetProxyConfiguration::run($this->server); + if (! $configuration) { + throw new \Exception('Configuration is not synced'); + } + SaveProxyConfiguration::run($this->server, $configuration); + $docker_compose_yml_base64 = base64_encode($configuration); + $this->server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); + $this->server->save(); + + $commands = collect([]); + + // === STOP PHASE === + $commands = $commands->merge([ + "echo 'Stopping proxy...'", + "docker stop -t=$stopTimeout $containerName 2>/dev/null || true", + "docker rm -f $containerName 2>/dev/null || true", + '# Wait for container to be fully removed', + 'for i in {1..15}; do', + " if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then", + " echo 'Container removed successfully.'", + ' break', + ' fi', + ' echo "Waiting for container to be removed... ($i/15)"', + ' sleep 1', + ' # Force remove on each iteration in case it got stuck', + " docker rm -f $containerName 2>/dev/null || true", + 'done', + '# Final verification and force cleanup', + "if docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then", + " echo 'Container still exists after wait, forcing removal...'", + " docker rm -f $containerName 2>/dev/null || true", + ' sleep 2', + 'fi', + "echo 'Proxy stopped successfully.'", + ]); + + // === START PHASE === + if ($this->server->isSwarmManager()) { + $commands = $commands->merge([ + "echo 'Starting proxy (Swarm mode)...'", + "mkdir -p $proxy_path/dynamic", + "cd $proxy_path", + "echo 'Creating required Docker Compose file.'", + "echo 'Starting coolify-proxy.'", + 'docker stack deploy --detach=true -c docker-compose.yml coolify-proxy', + "echo 'Successfully started coolify-proxy.'", + ]); + } else { + if (isDev() && $proxyType === ProxyTypes::CADDY->value) { + $proxy_path = '/data/coolify/proxy/caddy'; + } + $caddyfile = 'import /dynamic/*.caddy'; + $commands = $commands->merge([ + "echo 'Starting proxy...'", + "mkdir -p $proxy_path/dynamic", + "cd $proxy_path", + "echo '$caddyfile' > $proxy_path/dynamic/Caddyfile", + "echo 'Creating required Docker Compose file.'", + "echo 'Pulling docker image.'", + 'docker compose pull', + ]); + // Ensure required networks exist BEFORE docker compose up + $commands = $commands->merge(ensureProxyNetworksExist($this->server)); + $commands = $commands->merge([ + "echo 'Starting coolify-proxy.'", + 'docker compose up -d --wait --remove-orphans', + "echo 'Successfully started coolify-proxy.'", + ]); + $commands = $commands->merge(connectProxyToNetworks($this->server)); + } + + return $commands->toArray(); + } } diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index e55db5440..b21bc11a1 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -139,7 +139,9 @@ public function handle(): void if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; $exec = "docker exec {$containerName} {$cmd}"; - $this->task_output = instant_remote_process([$exec], $this->server, true); + // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently + // See: https://github.com/coollabsio/coolify/issues/6736 + $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->task_log->update([ 'status' => 'success', 'message' => $this->task_output, diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 45ab1dde8..a4619354d 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -111,34 +111,48 @@ private function processScheduledTasks(Collection $servers): void private function processServerTasks(Server $server): void { + // Get server timezone (used for all scheduled tasks) + $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); + if (validate_timezone($serverTimezone) === false) { + $serverTimezone = config('app.timezone'); + } + // Check if we should run sentinel-based checks $lastSentinelUpdate = $server->sentinel_updated_at; $waitTime = $server->waitBeforeDoingSshCheck(); - $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($waitTime)); + $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->copy()->subSeconds($waitTime)); if ($sentinelOutOfSync) { - // Dispatch jobs if Sentinel is out of sync - if ($this->shouldRunNow($this->checkFrequency)) { + // Dispatch ServerCheckJob if Sentinel is out of sync + if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) { ServerCheckJob::dispatch($server); } + } - // Dispatch ServerStorageCheckJob if due - $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *'); + $isSentinelEnabled = $server->isSentinelEnabled(); + $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); + // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) + + if ($shouldRestartSentinel) { + dispatch(function () use ($server) { + $server->restartContainer('coolify-sentinel'); + }); + } + + // Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled) + // When Sentinel is active, PushServerUpdateJob handles storage checks with real-time data + if ($sentinelOutOfSync) { + $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'); if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) { $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency]; } - $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency); + $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone); if ($shouldRunStorageCheck) { ServerStorageCheckJob::dispatch($server); } } - $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); - } - // Dispatch ServerPatchCheckJob if due (weekly) $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone); @@ -146,14 +160,10 @@ private function processServerTasks(Server $server): void ServerPatchCheckJob::dispatch($server); } - // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) - $isSentinelEnabled = $server->isSentinelEnabled(); - $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); - - if ($shouldRestartSentinel) { - dispatch(function () use ($server) { - $server->restartContainer('coolify-sentinel'); - }); + // Sentinel update checks (hourly) - check for updates to Sentinel version + // No timezone needed for hourly - runs at top of every hour + if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) { + CheckAndStartSentinelJob::dispatch($server); } } diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php index ff5c2e4f5..b5e1929de 100644 --- a/app/Jobs/ValidateAndInstallServerJob.php +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -168,6 +168,9 @@ public function handle(): void if (! $this->server->isBuildServer()) { $proxyShouldRun = CheckProxy::run($this->server, true); if ($proxyShouldRun) { + // Ensure networks exist BEFORE dispatching async proxy startup + // This prevents race condition where proxy tries to start before networks are created + instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false); StartProxy::dispatch($this->server); } } diff --git a/app/Listeners/MaintenanceModeDisabledNotification.php b/app/Listeners/MaintenanceModeDisabledNotification.php deleted file mode 100644 index 6c3ab83d8..000000000 --- a/app/Listeners/MaintenanceModeDisabledNotification.php +++ /dev/null @@ -1,48 +0,0 @@ -files(); - $files = collect($files); - $files = $files->sort(); - foreach ($files as $file) { - $content = Storage::disk('webhooks-during-maintenance')->get($file); - $data = json_decode($content, true); - $symfonyRequest = new SymfonyRequest( - $data['query'], - $data['request'], - $data['attributes'], - $data['cookies'], - $data['files'], - $data['server'], - $data['content'] - ); - - foreach ($data['headers'] as $key => $value) { - $symfonyRequest->headers->set($key, $value); - } - $request = Request::createFromBase($symfonyRequest); - $endpoint = str($file)->after('_')->beforeLast('_')->value(); - $class = "App\Http\Controllers\Webhook\\".ucfirst(str($endpoint)->before('::')->value()); - $method = str($endpoint)->after('::')->value(); - try { - $instance = new $class; - $instance->$method($request); - } catch (\Throwable $th) { - } finally { - Storage::disk('webhooks-during-maintenance')->delete($file); - } - } - } -} diff --git a/app/Listeners/MaintenanceModeEnabledNotification.php b/app/Listeners/MaintenanceModeEnabledNotification.php deleted file mode 100644 index 5aab248ea..000000000 --- a/app/Listeners/MaintenanceModeEnabledNotification.php +++ /dev/null @@ -1,21 +0,0 @@ -setupDynamicProxyConfiguration(); $server->proxy->force_stop = false; $server->save(); + + // Check Traefik version after proxy is running + if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { + $traefikVersions = get_traefik_versions(); + if ($traefikVersions !== null) { + CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions); + } else { + Log::warning('Traefik version check skipped after proxy status change: versions.json data unavailable', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + ]); + } + } } if ($status === 'created') { instant_remote_process([ diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php index d01b55afb..bc310e715 100644 --- a/app/Livewire/ActivityMonitor.php +++ b/app/Livewire/ActivityMonitor.php @@ -10,7 +10,7 @@ class ActivityMonitor extends Component { public ?string $header = null; - public $activityId; + public $activityId = null; public $eventToDispatch = 'activityFinished'; @@ -49,9 +49,24 @@ public function newMonitorActivity($activityId, $eventToDispatch = 'activityFini public function hydrateActivity() { + if ($this->activityId === null) { + $this->activity = null; + + return; + } + $this->activity = Activity::find($this->activityId); } + public function updatedActivityId($value) + { + if ($value) { + $this->hydrateActivity(); + $this->isPollingActive = true; + self::$eventDispatched = false; + } + } + public function polling() { $this->hydrateActivity(); diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 57ecaa8a2..8c2be9ab6 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -18,9 +18,9 @@ class Dashboard extends Component public function mount() { - $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get(); - $this->servers = Server::ownedByCurrentTeam()->get(); - $this->projects = Project::ownedByCurrentTeam()->get(); + $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); + $this->servers = Server::ownedByCurrentTeamCached(); + $this->projects = Project::ownedByCurrentTeam()->with('environments')->get(); } public function render() diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php index ac9cfd1c2..5c945ac01 100644 --- a/app/Livewire/DeploymentsIndicator.php +++ b/app/Livewire/DeploymentsIndicator.php @@ -14,7 +14,7 @@ class DeploymentsIndicator extends Component #[Computed] public function deployments() { - $servers = Server::ownedByCurrentTeam()->get(); + $servers = Server::ownedByCurrentTeamCached(); return ApplicationDeploymentQueue::with(['application.environment.project']) ->whereIn('status', ['in_progress', 'queued']) @@ -38,6 +38,12 @@ public function deploymentCount() return $this->deployments->count(); } + #[Computed] + public function shouldReduceOpacity(): bool + { + return request()->routeIs('project.application.deployment.*'); + } + public function toggleExpanded() { $this->expanded = ! $this->expanded; diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index e97cceb0d..9508c2adc 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -2,10 +2,8 @@ namespace App\Livewire; -use App\Models\InstanceSettings; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; use Livewire\Component; class NavbarDeleteTeam extends Component @@ -19,12 +17,8 @@ public function mount() public function delete($password) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } $currentTeam = currentTeam(); diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index ed15ab258..cf7ef3e0b 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -37,6 +37,12 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $disableBuildCache = false; + #[Validate(['boolean'])] + public bool $injectBuildArgsToDockerfile = true; + + #[Validate(['boolean'])] + public bool $includeSourceCommitInBuild = false; + #[Validate(['boolean'])] public bool $isLogDrainEnabled = false; @@ -110,6 +116,8 @@ public function syncData(bool $toModel = false) $this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled; $this->application->settings->connect_to_docker_network = $this->isConnectToDockerNetworkEnabled; $this->application->settings->disable_build_cache = $this->disableBuildCache; + $this->application->settings->inject_build_args_to_dockerfile = $this->injectBuildArgsToDockerfile; + $this->application->settings->include_source_commit_in_build = $this->includeSourceCommitInBuild; $this->application->settings->save(); } else { $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); @@ -134,6 +142,8 @@ public function syncData(bool $toModel = false) $this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled; $this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network; $this->disableBuildCache = $this->application->settings->disable_build_cache; + $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; + $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; } } diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index cdac47d3d..8c0ee1a3f 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -18,12 +18,15 @@ class Show extends Component public $isKeepAliveOn = true; + public bool $is_debug_enabled = false; + + public bool $fullscreen = false; + + private bool $deploymentFinishedDispatched = false; + public function getListeners() { - $teamId = auth()->user()->currentTeam()->id; - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', 'refreshQueue', ]; } @@ -56,9 +59,23 @@ public function mount() $this->application_deployment_queue = $application_deployment_queue; $this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus(); $this->deployment_uuid = $deploymentUuid; + $this->is_debug_enabled = $this->application->settings->is_debug_enabled; $this->isKeepAliveOn(); } + public function toggleDebug() + { + try { + $this->authorize('update', $this->application); + $this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled; + $this->application->settings->save(); + $this->is_debug_enabled = $this->application->settings->is_debug_enabled; + $this->application_deployment_queue->refresh(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function refreshQueue() { $this->application_deployment_queue->refresh(); @@ -75,10 +92,15 @@ private function isKeepAliveOn() public function polling() { - $this->dispatch('deploymentFinished'); $this->application_deployment_queue->refresh(); $this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus(); $this->isKeepAliveOn(); + + // Dispatch event when deployment finishes to stop auto-scroll (only once) + if (! $this->isKeepAliveOn && ! $this->deploymentFinishedDispatched) { + $this->deploymentFinishedDispatched = true; + $this->dispatch('deploymentFinished'); + } } public function getLogLinesProperty() diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 71ca9720e..c84de9d8d 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -521,7 +521,7 @@ public function instantSave() } } - public function loadComposeFile($isInit = false, $showToast = true) + public function loadComposeFile($isInit = false, $showToast = true, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null) { try { $this->authorize('update', $this->application); @@ -530,7 +530,7 @@ public function loadComposeFile($isInit = false, $showToast = true) return; } - ['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit); + ['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit, $restoreBaseDirectory, $restoreDockerComposeLocation); if (is_null($this->parsedServices)) { $showToast && $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.'); @@ -606,13 +606,6 @@ public function generateDomain(string $serviceName) } } - public function updatedBaseDirectory() - { - if ($this->buildPack === 'dockercompose') { - $this->loadComposeFile(); - } - } - public function updatedIsStatic($value) { if ($value) { @@ -786,11 +779,13 @@ public function submit($showToaster = true) try { $this->authorize('update', $this->application); + $this->resetErrorBag(); $this->validate(); $oldPortsExposes = $this->application->ports_exposes; $oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled; $oldDockerComposeLocation = $this->initialDockerComposeLocation; + $oldBaseDirectory = $this->application->base_directory; // Process FQDN with intermediate variable to avoid Collection/string confusion $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); @@ -821,6 +816,42 @@ public function submit($showToaster = true) return; // Stop if there are conflicts and user hasn't confirmed } + // Normalize paths BEFORE validation + if ($this->baseDirectory && $this->baseDirectory !== '/') { + $this->baseDirectory = rtrim($this->baseDirectory, '/'); + $this->application->base_directory = $this->baseDirectory; + } + if ($this->publishDirectory && $this->publishDirectory !== '/') { + $this->publishDirectory = rtrim($this->publishDirectory, '/'); + $this->application->publish_directory = $this->publishDirectory; + } + + // Validate docker compose file path BEFORE saving to database + // This prevents invalid paths from being persisted when validation fails + if ($this->buildPack === 'dockercompose' && + ($oldDockerComposeLocation !== $this->dockerComposeLocation || + $oldBaseDirectory !== $this->baseDirectory)) { + // Pass original values to loadComposeFile so it can restore them on failure + // The finally block in Application::loadComposeFile will save these original + // values if validation fails, preventing invalid paths from being persisted + $compose_return = $this->loadComposeFile( + isInit: false, + showToast: false, + restoreBaseDirectory: $oldBaseDirectory, + restoreDockerComposeLocation: $oldDockerComposeLocation + ); + if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) { + // Validation failed - restore original values to component properties + $this->baseDirectory = $oldBaseDirectory; + $this->dockerComposeLocation = $oldDockerComposeLocation; + // The model was saved by loadComposeFile's finally block with original values + // Refresh to sync component with database state + $this->application->refresh(); + + return; + } + } + $this->application->save(); if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && ! $this->application->settings->is_container_label_readonly_enabled) { $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); @@ -828,13 +859,6 @@ public function submit($showToaster = true) $this->application->save(); } - if ($this->buildPack === 'dockercompose' && $oldDockerComposeLocation !== $this->dockerComposeLocation) { - $compose_return = $this->loadComposeFile(showToast: false); - if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) { - return; - } - } - if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) { $this->resetDefaultLabels(); } @@ -855,14 +879,6 @@ public function submit($showToaster = true) $this->application->ports_exposes = $port; } } - if ($this->baseDirectory && $this->baseDirectory !== '/') { - $this->baseDirectory = rtrim($this->baseDirectory, '/'); - $this->application->base_directory = $this->baseDirectory; - } - if ($this->publishDirectory && $this->publishDirectory !== '/') { - $this->publishDirectory = rtrim($this->publishDirectory, '/'); - $this->application->publish_directory = $this->publishDirectory; - } if ($this->buildPack === 'dockercompose') { $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); if ($this->application->isDirty('docker_compose_domains')) { @@ -1018,11 +1034,27 @@ public function getDockerComposeBuildCommandPreviewProperty(): string // Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml) // Actual deployment uses absolute path: /artifacts/{deployment_uuid}{base_directory}{docker_compose_location} // Build-time env path references ApplicationDeploymentJob::BUILD_TIME_ENV_PATH as source of truth - return injectDockerComposeFlags( + $command = injectDockerComposeFlags( $this->dockerComposeCustomBuildCommand, ".{$normalizedBase}{$this->dockerComposeLocation}", \App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH ); + + // Inject build args if not using build secrets + if (! $this->application->settings->use_build_secrets) { + $buildTimeEnvs = $this->application->environment_variables() + ->where('is_buildtime', true) + ->get(); + + if ($buildTimeEnvs->isNotEmpty()) { + $buildArgs = generateDockerBuildArgs($buildTimeEnvs); + $buildArgsString = $buildArgs->implode(' '); + + $command = injectDockerComposeBuildArgs($command, $buildArgsString); + } + } + + return $command; } public function getDockerComposeStartCommandPreviewProperty(): string diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index fc63c7f4b..a46b2f19c 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -100,19 +100,17 @@ public function deploy(bool $force_rebuild = false) deployment_uuid: $this->deploymentUuid, force_rebuild: $force_rebuild, ); + if ($result['status'] === 'queue_full') { + $this->dispatch('error', 'Deployment queue full', $result['message']); + + return; + } if ($result['status'] === 'skipped') { $this->dispatch('error', 'Deployment skipped', $result['message']); return; } - // Reset restart count on successful deployment - $this->application->update([ - 'restart_count' => 0, - 'last_restart_at' => null, - 'last_restart_type' => null, - ]); - return $this->redirectRoute('project.application.deployment.show', [ 'project_uuid' => $this->parameters['project_uuid'], 'application_uuid' => $this->parameters['application_uuid'], @@ -151,19 +149,17 @@ public function restart() deployment_uuid: $this->deploymentUuid, restart_only: true, ); + if ($result['status'] === 'queue_full') { + $this->dispatch('error', 'Deployment queue full', $result['message']); + + return; + } if ($result['status'] === 'skipped') { $this->dispatch('success', 'Deployment skipped', $result['message']); return; } - // Reset restart count on manual restart - $this->application->update([ - 'restart_count' => 0, - 'last_restart_at' => now(), - 'last_restart_type' => 'manual', - ]); - return $this->redirectRoute('project.application.deployment.show', [ 'project_uuid' => $this->parameters['project_uuid'], 'application_uuid' => $this->parameters['application_uuid'], diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index e28c8142d..41f352c14 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -249,6 +249,11 @@ public function deploy(int $pull_request_id, ?string $pull_request_html_url = nu pull_request_id: $pull_request_id, git_type: $found->git_type ?? null, ); + if ($result['status'] === 'queue_full') { + $this->dispatch('error', 'Deployment queue full', $result['message']); + + return; + } if ($result['status'] === 'skipped') { $this->dispatch('success', 'Deployment skipped', $result['message']); @@ -278,7 +283,7 @@ private function stopContainers(array $containers, $server) foreach ($containersToStop as $containerName) { instant_remote_process(command: [ - "docker stop --time=30 $containerName", + "docker stop -t 30 $containerName", "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php index 942dfeb37..85ba2328e 100644 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ b/app/Livewire/Project/Application/PreviewsCompose.php @@ -96,8 +96,7 @@ public function generate() $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn); - $preview_fqdns[] = "$schema://$preview_fqdn"; + $preview_fqdns[] = "$schema://$preview_fqdn{$port}"; } $preview_fqdn = implode(',', $preview_fqdns); diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index da67a5707..e53784db5 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -4,6 +4,7 @@ use App\Models\Application; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Validate; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -19,9 +20,30 @@ class Rollback extends Component public array $parameters; + #[Validate(['integer', 'min:0', 'max:100'])] + public int $dockerImagesToKeep = 2; + + public bool $serverRetentionDisabled = false; + public function mount() { $this->parameters = get_route_parameters(); + $this->dockerImagesToKeep = $this->application->settings->docker_images_to_keep ?? 2; + $server = $this->application->destination->server; + $this->serverRetentionDisabled = $server->settings->disable_application_image_retention ?? false; + } + + public function saveSettings() + { + try { + $this->authorize('update', $this->application); + $this->validate(); + $this->application->settings->docker_images_to_keep = $this->dockerImagesToKeep; + $this->application->settings->save(); + $this->dispatch('success', 'Settings saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function rollbackImage($commit) @@ -30,7 +52,7 @@ public function rollbackImage($commit) $deployment_uuid = new Cuid2; - queue_application_deployment( + $result = queue_application_deployment( application: $this->application, deployment_uuid: $deployment_uuid, commit: $commit, @@ -38,6 +60,12 @@ public function rollbackImage($commit) force_rebuild: false, ); + if ($result['status'] === 'queue_full') { + $this->dispatch('error', 'Deployment queue full', $result['message']); + + return; + } + return redirect()->route('project.application.deployment.show', [ 'project_uuid' => $this->parameters['project_uuid'], 'application_uuid' => $this->parameters['application_uuid'], @@ -66,14 +94,12 @@ public function loadImages($showToast = false) return str($item)->contains($image); })->map(function ($item) { $item = str($item)->explode('#'); - if ($item[1] === $this->current) { - // $is_current = true; - } + $is_current = $item[1] === $this->current; return [ 'tag' => $item[1], 'created_at' => $item[2], - 'is_current' => $is_current ?? null, + 'is_current' => $is_current, ]; })->toArray(); } diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index da543a049..d70c52411 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -2,12 +2,9 @@ namespace App\Livewire\Project\Database; -use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; @@ -107,6 +104,25 @@ public function syncData(bool $toModel = false) $this->backup->save_s3 = $this->saveS3; $this->backup->disable_local_backup = $this->disableLocalBackup; $this->backup->s3_storage_id = $this->s3StorageId; + + // Validate databases_to_backup to prevent command injection + if (filled($this->databasesToBackup)) { + $databases = str($this->databasesToBackup)->explode(','); + foreach ($databases as $index => $db) { + $dbName = trim($db); + try { + validateShellSafePath($dbName, 'database name'); + } catch (\Exception $e) { + // Provide specific error message indicating which database failed validation + $position = $index + 1; + throw new \Exception( + "Database #{$position} ('{$dbName}') validation failed: ". + $e->getMessage() + ); + } + } + } + $this->backup->databases_to_backup = $this->databasesToBackup; $this->backup->dump_all = $this->dumpAll; $this->backup->timeout = $this->timeout; @@ -135,12 +151,8 @@ public function delete($password) { $this->authorize('manageBackups', $this->backup->database); - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } try { diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 0b6d8338b..44f903fcc 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -2,11 +2,9 @@ namespace App\Livewire\Project\Database; -use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Component; class BackupExecutions extends Component @@ -69,12 +67,8 @@ public function cleanupDeleted() public function deleteBackup($executionId, $password) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } $execution = $this->backup->executions()->where('id', $executionId)->first(); diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 7d6ac3131..26feb1a5e 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Database; +use App\Models\S3Storage; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; @@ -12,6 +13,92 @@ class Import extends Component { use AuthorizesRequests; + /** + * Validate that a string is safe for use as an S3 bucket name. + * Allows alphanumerics, dots, dashes, and underscores. + */ + private function validateBucketName(string $bucket): bool + { + return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1; + } + + /** + * Validate that a string is safe for use as an S3 path. + * Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters. + */ + private function validateS3Path(string $path): bool + { + // Must not be empty + if (empty($path)) { + return false; + } + + // Must not contain dangerous shell metacharacters or command injection patterns + $dangerousPatterns = [ + '..', // Directory traversal + '$(', // Command substitution + '`', // Backtick command substitution + '|', // Pipe + ';', // Command separator + '&', // Background/AND + '>', // Redirect + '<', // Redirect + "\n", // Newline + "\r", // Carriage return + "\0", // Null byte + "'", // Single quote + '"', // Double quote + '\\', // Backslash + ]; + + foreach ($dangerousPatterns as $pattern) { + if (str_contains($path, $pattern)) { + return false; + } + } + + // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at + return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1; + } + + /** + * Validate that a string is safe for use as a file path on the server. + */ + private function validateServerPath(string $path): bool + { + // Must be an absolute path + if (! str_starts_with($path, '/')) { + return false; + } + + // Must not contain dangerous shell metacharacters or command injection patterns + $dangerousPatterns = [ + '..', // Directory traversal + '$(', // Command substitution + '`', // Backtick command substitution + '|', // Pipe + ';', // Command separator + '&', // Background/AND + '>', // Redirect + '<', // Redirect + "\n", // Newline + "\r", // Carriage return + "\0", // Null byte + "'", // Single quote + '"', // Double quote + '\\', // Backslash + ]; + + foreach ($dangerousPatterns as $pattern) { + if (str_contains($path, $pattern)) { + return false; + } + } + + // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces + return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1; + } + public bool $unsupported = false; public $resource; @@ -46,6 +133,8 @@ class Import extends Component public string $customLocation = ''; + public ?int $activityId = null; + public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB'; public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; @@ -54,22 +143,35 @@ class Import extends Component public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; + // S3 Restore properties + public $availableS3Storages = []; + + public ?int $s3StorageId = null; + + public string $s3Path = ''; + + public ?int $s3FileSize = null; + public function getListeners() { $userId = Auth::id(); return [ "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + 'slideOverClosed' => 'resetActivityId', ]; } + public function resetActivityId() + { + $this->activityId = null; + } + public function mount() { - if (isDev()) { - $this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz'; - } $this->parameters = get_route_parameters(); $this->getContainers(); + $this->loadAvailableS3Storages(); } public function updatedDumpAll($value) @@ -152,8 +254,16 @@ public function getContainers() public function checkFile() { if (filled($this->customLocation)) { + // Validate the custom location to prevent command injection + if (! $this->validateServerPath($this->customLocation)) { + $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return; + } + try { - $result = instant_remote_process(["ls -l {$this->customLocation}"], $this->server, throwError: false); + $escapedPath = escapeshellarg($this->customLocation); + $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false); if (blank($result)) { $this->dispatch('error', 'The file does not exist or has been deleted.'); @@ -179,59 +289,35 @@ public function runImport() try { $this->importRunning = true; $this->importCommands = []; - if (filled($this->customLocation)) { - $backupFileName = '/tmp/restore_'.$this->resource->uuid; - $this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$backupFileName}"; - $tmpPath = $backupFileName; - } else { - $backupFileName = "upload/{$this->resource->uuid}/restore"; - $path = Storage::path($backupFileName); - if (! Storage::exists($backupFileName)) { - $this->dispatch('error', 'The file does not exist or has been deleted.'); + $backupFileName = "upload/{$this->resource->uuid}/restore"; - return; - } + // Check if an uploaded file exists first (takes priority over custom location) + if (Storage::exists($backupFileName)) { + $path = Storage::path($backupFileName); $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid; instant_scp($path, $tmpPath, $this->server); Storage::delete($backupFileName); $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; + } elseif (filled($this->customLocation)) { + // Validate the custom location to prevent command injection + if (! $this->validateServerPath($this->customLocation)) { + $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.'); + + return; + } + $tmpPath = '/tmp/restore_'.$this->resource->uuid; + $escapedCustomLocation = escapeshellarg($this->customLocation); + $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}"; + } else { + $this->dispatch('error', 'The file does not exist or has been deleted.'); + + return; } // Copy the restore command to a script file $scriptPath = "/tmp/restore_{$this->resource->uuid}.sh"; - switch ($this->resource->getMorphClass()) { - case \App\Models\StandaloneMariadb::class: - $restoreCommand = $this->mariadbRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD"; - } else { - $restoreCommand .= " < {$tmpPath}"; - } - break; - case \App\Models\StandaloneMysql::class: - $restoreCommand = $this->mysqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD"; - } else { - $restoreCommand .= " < {$tmpPath}"; - } - break; - case \App\Models\StandalonePostgresql::class: - $restoreCommand = $this->postgresqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres"; - } else { - $restoreCommand .= " {$tmpPath}"; - } - break; - case \App\Models\StandaloneMongodb::class: - $restoreCommand = $this->mongodbRestoreCommand; - if ($this->dumpAll === false) { - $restoreCommand .= "{$tmpPath}"; - } - break; - } + $restoreCommand = $this->buildRestoreCommand($tmpPath); $restoreCommandBase64 = base64_encode($restoreCommand); $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; @@ -248,7 +334,13 @@ public function runImport() 'container' => $this->container, 'serverId' => $this->server->id, ]); + + // Track the activity ID + $this->activityId = $activity->id; + + // Dispatch activity to the monitor and open slide-over $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); } } catch (\Throwable $e) { return handleError($e, $this); @@ -257,4 +349,267 @@ public function runImport() $this->importCommands = []; } } + + public function loadAvailableS3Storages() + { + try { + $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description']) + ->where('is_usable', true) + ->get(); + } catch (\Throwable $e) { + $this->availableS3Storages = collect(); + } + } + + public function updatedS3Path($value) + { + // Reset validation state when path changes + $this->s3FileSize = null; + + // Ensure path starts with a slash + if ($value !== null && $value !== '') { + $this->s3Path = str($value)->trim()->start('/')->value(); + } + } + + public function updatedS3StorageId() + { + // Reset validation state when storage changes + $this->s3FileSize = null; + } + + public function checkS3File() + { + if (! $this->s3StorageId) { + $this->dispatch('error', 'Please select an S3 storage.'); + + return; + } + + if (blank($this->s3Path)) { + $this->dispatch('error', 'Please provide an S3 path.'); + + return; + } + + // Clean the path (remove leading slash if present) + $cleanPath = ltrim($this->s3Path, '/'); + + // Validate the S3 path early to prevent command injection in subsequent operations + if (! $this->validateS3Path($cleanPath)) { + $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return; + } + + try { + $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); + + // Validate bucket name early + if (! $this->validateBucketName($s3Storage->bucket)) { + $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); + + return; + } + + // Test connection + $s3Storage->testConnection(); + + // Build S3 disk configuration + $disk = Storage::build([ + 'driver' => 's3', + 'region' => $s3Storage->region, + 'key' => $s3Storage->key, + 'secret' => $s3Storage->secret, + 'bucket' => $s3Storage->bucket, + 'endpoint' => $s3Storage->endpoint, + 'use_path_style_endpoint' => true, + ]); + + // Check if file exists + if (! $disk->exists($cleanPath)) { + $this->dispatch('error', 'File not found in S3. Please check the path.'); + + return; + } + + // Get file size + $this->s3FileSize = $disk->size($cleanPath); + + $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize)); + } catch (\Throwable $e) { + $this->s3FileSize = null; + + return handleError($e, $this); + } + } + + public function restoreFromS3() + { + $this->authorize('update', $this->resource); + + if (! $this->s3StorageId || blank($this->s3Path)) { + $this->dispatch('error', 'Please select S3 storage and provide a path first.'); + + return; + } + + if (is_null($this->s3FileSize)) { + $this->dispatch('error', 'Please check the file first by clicking "Check File".'); + + return; + } + + try { + $this->importRunning = true; + + $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId); + + $key = $s3Storage->key; + $secret = $s3Storage->secret; + $bucket = $s3Storage->bucket; + $endpoint = $s3Storage->endpoint; + + // Validate bucket name to prevent command injection + if (! $this->validateBucketName($bucket)) { + $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.'); + + return; + } + + // Clean the S3 path + $cleanPath = ltrim($this->s3Path, '/'); + + // Validate the S3 path to prevent command injection + if (! $this->validateS3Path($cleanPath)) { + $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).'); + + return; + } + + // Get helper image + $helperImage = config('constants.coolify.helper_image'); + $latestVersion = getHelperVersion(); + $fullImageName = "{$helperImage}:{$latestVersion}"; + + // Get the database destination network + $destinationNetwork = $this->resource->destination->network ?? 'coolify'; + + // Generate unique names for this operation + $containerName = "s3-restore-{$this->resource->uuid}"; + $helperTmpPath = '/tmp/'.basename($cleanPath); + $serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath); + $containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath); + $scriptPath = "/tmp/restore_{$this->resource->uuid}.sh"; + + // Prepare all commands in sequence + $commands = []; + + // 1. Clean up any existing helper container and temp files from previous runs + $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; + $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; + $commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true"; + + // 2. Start helper container on the database network + $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600"; + + // 3. Configure S3 access in helper container + $escapedEndpoint = escapeshellarg($endpoint); + $escapedKey = escapeshellarg($key); + $escapedSecret = escapeshellarg($secret); + $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; + + // 4. Check file exists in S3 (bucket and path already validated above) + $escapedBucket = escapeshellarg($bucket); + $escapedCleanPath = escapeshellarg($cleanPath); + $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}"); + $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}"; + + // 5. Download from S3 to helper container (progress shown by default) + $escapedHelperTmpPath = escapeshellarg($helperTmpPath); + $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}"; + + // 6. Copy from helper to server, then immediately to database container + $commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}"; + $commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}"; + + // 7. Cleanup helper container and server temp file immediately (no longer needed) + $commands[] = "docker rm -f {$containerName} 2>/dev/null || true"; + $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true"; + + // 8. Build and execute restore command inside database container + $restoreCommand = $this->buildRestoreCommand($containerTmpPath); + + $restoreCommandBase64 = base64_encode($restoreCommand); + $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}"; + $commands[] = "chmod +x {$scriptPath}"; + $commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; + + // 9. Execute restore and cleanup temp files immediately after completion + $commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'"; + $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'"; + + // Execute all commands with cleanup event (as safety net for edge cases) + $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [ + 'containerName' => $containerName, + 'serverTmpPath' => $serverTmpPath, + 'scriptPath' => $scriptPath, + 'containerTmpPath' => $containerTmpPath, + 'container' => $this->container, + 'serverId' => $this->server->id, + ]); + + // Track the activity ID + $this->activityId = $activity->id; + + // Dispatch activity to the monitor and open slide-over + $this->dispatch('activityMonitor', $activity->id); + $this->dispatch('databaserestore'); + $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); + } catch (\Throwable $e) { + $this->importRunning = false; + + return handleError($e, $this); + } + } + + public function buildRestoreCommand(string $tmpPath): string + { + switch ($this->resource->getMorphClass()) { + case \App\Models\StandaloneMariadb::class: + $restoreCommand = $this->mariadbRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD"; + } else { + $restoreCommand .= " < {$tmpPath}"; + } + break; + case \App\Models\StandaloneMysql::class: + $restoreCommand = $this->mysqlRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD"; + } else { + $restoreCommand .= " < {$tmpPath}"; + } + break; + case \App\Models\StandalonePostgresql::class: + $restoreCommand = $this->postgresqlRestoreCommand; + if ($this->dumpAll) { + $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres"; + } else { + $restoreCommand .= " {$tmpPath}"; + } + break; + case \App\Models\StandaloneMongodb::class: + $restoreCommand = $this->mongodbRestoreCommand; + if ($this->dumpAll === false) { + $restoreCommand .= "{$tmpPath}"; + } + break; + default: + $restoreCommand = ''; + } + + return $restoreCommand; + } } diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 3240aadd2..7ef2cdc4f 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -328,12 +328,15 @@ public function save_init_script($script) $configuration_dir = database_configuration_dir().'/'.$container_name; if ($oldScript && $oldScript['filename'] !== $script['filename']) { - $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}"; - $delete_command = "rm -f $old_file_path"; try { + // Validate and escape filename to prevent command injection + validateShellSafePath($oldScript['filename'], 'init script filename'); + $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}"; + $escapedOldPath = escapeshellarg($old_file_path); + $delete_command = "rm -f {$escapedOldPath}"; instant_remote_process([$delete_command], $this->server); } catch (Exception $e) { - $this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage()); + $this->dispatch('error', $e->getMessage()); return; } @@ -370,13 +373,17 @@ public function delete_init_script($script) if ($found) { $container_name = $this->database->uuid; $configuration_dir = database_configuration_dir().'/'.$container_name; - $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}"; - $command = "rm -f $file_path"; try { + // Validate and escape filename to prevent command injection + validateShellSafePath($script['filename'], 'init script filename'); + $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}"; + $escapedPath = escapeshellarg($file_path); + + $command = "rm -f {$escapedPath}"; instant_remote_process([$command], $this->server); } catch (Exception $e) { - $this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage()); + $this->dispatch('error', $e->getMessage()); return; } @@ -405,6 +412,16 @@ public function save_new_init_script() 'new_filename' => 'required|string', 'new_content' => 'required|string', ]); + + try { + // Validate filename to prevent command injection + validateShellSafePath($this->new_filename, 'init script filename'); + } catch (Exception $e) { + $this->dispatch('error', $e->getMessage()); + + return; + } + $found = collect($this->initScripts)->firstWhere('filename', $this->new_filename); if ($found) { $this->dispatch('error', 'Filename already exists.'); diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 0e4f15a5c..7aa8dfc49 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -17,9 +17,9 @@ class Index extends Component public function mount() { - $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); - $this->projects = Project::ownedByCurrentTeam()->get(); - $this->servers = Server::ownedByCurrentTeam()->count(); + $this->private_keys = PrivateKey::ownedByCurrentTeamCached(); + $this->projects = Project::ownedByCurrentTeamCached(); + $this->servers = Server::ownedByCurrentTeamCached(); } public function render() diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index a88a62d88..18bb237af 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -74,6 +74,9 @@ public function submit() } $service->parse(isNew: true); + // Apply service-specific application prerequisites + applyServiceApplicationPrerequisites($service); + return redirect()->route('project.service.configuration', [ 'service_uuid' => $service->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 27ecacb99..40d2674e2 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -75,16 +75,6 @@ public function mount() $this->github_apps = GithubApp::private(); } - public function updatedBaseDirectory() - { - if ($this->base_directory) { - $this->base_directory = rtrim($this->base_directory, '/'); - if (! str($this->base_directory)->startsWith('/')) { - $this->base_directory = '/'.$this->base_directory; - } - } - } - public function updatedBuildPack() { if ($this->build_pack === 'nixpacks') { @@ -138,6 +128,7 @@ public function loadBranches() $this->loadBranchByPage(); } } + $this->branches = sortBranchesByPriority($this->branches); $this->selected_branch_name = data_get($this->branches, '0.name', 'main'); } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 89814ee7f..2fffff6b9 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -107,26 +107,6 @@ public function mount() $this->query = request()->query(); } - public function updatedBaseDirectory() - { - if ($this->base_directory) { - $this->base_directory = rtrim($this->base_directory, '/'); - if (! str($this->base_directory)->startsWith('/')) { - $this->base_directory = '/'.$this->base_directory; - } - } - } - - public function updatedDockerComposeLocation() - { - if ($this->docker_compose_location) { - $this->docker_compose_location = rtrim($this->docker_compose_location, '/'); - if (! str($this->docker_compose_location)->startsWith('/')) { - $this->docker_compose_location = '/'.$this->docker_compose_location; - } - } - } - public function updatedBuildPack() { if ($this->build_pack === 'nixpacks') { diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index cdf95d2e4..1158fb3f7 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -81,7 +81,7 @@ public function mount() 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]; - if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') { + if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) { data_set($service_payload, 'connect_to_docker_network', true); } $service = Service::create($service_payload); @@ -104,6 +104,9 @@ public function mount() } $service->parse(isNew: true); + // Apply service-specific application prerequisites + applyServiceApplicationPrerequisites($service); + return redirect()->route('project.service.configuration', [ 'service_uuid' => $service->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/Service/Database.php b/app/Livewire/Project/Service/Database.php index 4bcf866d3..1e183c6bc 100644 --- a/app/Livewire/Project/Service/Database.php +++ b/app/Livewire/Project/Service/Database.php @@ -4,12 +4,9 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Models\InstanceSettings; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; use Livewire\Component; class Database extends Component @@ -96,12 +93,8 @@ public function delete($password) try { $this->authorize('delete', $this->database); - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } $this->database->delete(); diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 2ce4374a0..079115bb6 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -3,7 +3,6 @@ namespace App\Livewire\Project\Service; use App\Models\Application; -use App\Models\InstanceSettings; use App\Models\LocalFileVolume; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; @@ -16,8 +15,6 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Validate; use Livewire\Component; @@ -62,7 +59,7 @@ public function mount() $this->fs_path = $this->fileStorage->fs_path; } - $this->isReadOnly = $this->fileStorage->isReadOnlyVolume(); + $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI(); $this->syncData(); } @@ -104,7 +101,8 @@ public function convertToDirectory() public function loadStorageOnServer() { try { - $this->authorize('update', $this->resource); + // Loading content is a read operation, so we use 'view' permission + $this->authorize('view', $this->resource); $this->fileStorage->loadStorageOnServer(); $this->syncData(); @@ -140,12 +138,8 @@ public function delete($password) { $this->authorize('update', $this->resource); - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } try { diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 259b9dbec..4302c05fb 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -2,12 +2,9 @@ namespace App\Livewire\Project\Service; -use App\Models\InstanceSettings; use App\Models\ServiceApplication; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Validate; use Livewire\Component; use Spatie\Url\Url; @@ -82,6 +79,21 @@ public function instantSave() } } + public function instantSaveSettings() + { + try { + $this->authorize('update', $this->application); + // Save checkbox states without port validation + $this->application->is_gzip_enabled = $this->isGzipEnabled; + $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled; + $this->application->exclude_from_status = $this->excludeFromStatus; + $this->application->save(); + $this->dispatch('success', 'Settings saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function instantSaveAdvanced() { try { @@ -113,12 +125,8 @@ public function delete($password) try { $this->authorize('delete', $this->application); - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } $this->application->delete(); diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index db171db24..12d8bcbc3 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -67,7 +67,7 @@ public function refreshStoragesFromEvent() public function refreshStorages() { $this->fileStorage = $this->resource->fileStorages()->get(); - $this->resource->refresh(); + $this->resource->load('persistentStorages.resource'); } public function getFilesProperty() @@ -179,6 +179,10 @@ public function submitFileStorageDirectory() $this->file_storage_directory_destination = trim($this->file_storage_directory_destination); $this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value(); + // Validate paths to prevent command injection + validateShellSafePath($this->file_storage_directory_source, 'storage source path'); + validateShellSafePath($this->file_storage_directory_destination, 'storage destination path'); + \App\Models\LocalFileVolume::create([ 'fs_path' => $this->file_storage_directory_source, 'mount_path' => $this->file_storage_directory_destination, diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 0ed1347f8..8bf3c7438 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -3,13 +3,10 @@ namespace App\Livewire\Project\Shared; use App\Jobs\DeleteResourceJob; -use App\Models\InstanceSettings; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -93,12 +90,8 @@ public function mount() public function delete($password) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } if (! $this->resource) { diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 40291d2b0..ffd18b35c 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -5,12 +5,9 @@ use App\Actions\Application\StopApplicationOneServer; use App\Actions\Docker\GetContainersStatus; use App\Events\ApplicationStatusChanged; -use App\Models\InstanceSettings; use App\Models\Server; use App\Models\StandaloneDocker; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -89,6 +86,11 @@ public function redeploy(int $network_id, int $server_id) only_this_server: true, no_questions_asked: true, ); + if ($result['status'] === 'queue_full') { + $this->dispatch('error', 'Deployment queue full', $result['message']); + + return; + } if ($result['status'] === 'skipped') { $this->dispatch('success', 'Deployment skipped', $result['message']); @@ -135,12 +137,8 @@ public function addServer(int $network_id, int $server_id) public function removeServer(int $network_id, int $server_id, $password) { try { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 5f5e12e0a..fa65e8bd2 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -2,8 +2,11 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Environment; +use App\Models\Project; use App\Traits\EnvironmentVariableAnalyzer; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Computed; use Livewire\Component; class Add extends Component @@ -56,6 +59,72 @@ public function mount() $this->problematicVariables = self::getProblematicVariablesForFrontend(); } + #[Computed] + public function availableSharedVariables(): array + { + $team = currentTeam(); + $result = [ + 'team' => [], + 'project' => [], + 'environment' => [], + ]; + + // Early return if no team + if (! $team) { + return $result; + } + + // Check if user can view team variables + try { + $this->authorize('view', $team); + $result['team'] = $team->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view team variables + } + + // Get project variables if we have a project_uuid in route + $projectUuid = data_get($this->parameters, 'project_uuid'); + if ($projectUuid) { + $project = Project::where('team_id', $team->id) + ->where('uuid', $projectUuid) + ->first(); + + if ($project) { + try { + $this->authorize('view', $project); + $result['project'] = $project->environment_variables() + ->pluck('key') + ->toArray(); + + // Get environment variables if we have an environment_uuid in route + $environmentUuid = data_get($this->parameters, 'environment_uuid'); + if ($environmentUuid) { + $environment = $project->environments() + ->where('uuid', $environmentUuid) + ->first(); + + if ($environment) { + try { + $this->authorize('view', $environment); + $result['environment'] = $environment->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view environment variables + } + } + } + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view project variables + } + } + } + + return $result; + } + public function submit() { $this->validate(); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 3b8d244cc..2030f631e 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -2,11 +2,14 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Environment; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; +use App\Models\Project; use App\Models\SharedEnvironmentVariable; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Computed; use Livewire\Component; class Show extends Component @@ -184,6 +187,7 @@ public function submit() $this->serialize(); $this->syncData(true); + $this->syncData(false); $this->dispatch('success', 'Environment variable updated.'); $this->dispatch('envsUpdated'); $this->dispatch('configurationChanged'); @@ -192,6 +196,72 @@ public function submit() } } + #[Computed] + public function availableSharedVariables(): array + { + $team = currentTeam(); + $result = [ + 'team' => [], + 'project' => [], + 'environment' => [], + ]; + + // Early return if no team + if (! $team) { + return $result; + } + + // Check if user can view team variables + try { + $this->authorize('view', $team); + $result['team'] = $team->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view team variables + } + + // Get project variables if we have a project_uuid in route + $projectUuid = data_get($this->parameters, 'project_uuid'); + if ($projectUuid) { + $project = Project::where('team_id', $team->id) + ->where('uuid', $projectUuid) + ->first(); + + if ($project) { + try { + $this->authorize('view', $project); + $result['project'] = $project->environment_variables() + ->pluck('key') + ->toArray(); + + // Get environment variables if we have an environment_uuid in route + $environmentUuid = data_get($this->parameters, 'environment_uuid'); + if ($environmentUuid) { + $environment = $project->environments() + ->where('uuid', $environmentUuid) + ->first(); + + if ($environment) { + try { + $this->authorize('view', $environment); + $result['environment'] = $environment->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view environment variables + } + } + } + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view project variables + } + } + } + + return $result; + } + public function delete() { try { diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 304f7b411..f57563330 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -43,6 +43,10 @@ class GetLogs extends Component public ?int $numberOfLines = 100; + public bool $expandByDefault = false; + + public bool $collapsible = true; + public function mount() { if (! is_null($this->resource)) { @@ -92,12 +96,33 @@ public function instantSave() } } + public function toggleTimestamps() + { + $previousValue = $this->showTimeStamps; + $this->showTimeStamps = ! $this->showTimeStamps; + + try { + $this->instantSave(); + $this->getLogs(true); + } catch (\Throwable $e) { + // Revert the flag to its previous value on failure + $this->showTimeStamps = $previousValue; + + return handleError($e, $this); + } + } + + public function toggleStreamLogs() + { + $this->streamLogs = ! $this->streamLogs; + } + public function getLogs($refresh = false) { if (! $this->server->isFunctional()) { return; } - if (! $refresh && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) { + if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) { return; } if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) { diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 47b3534a2..4ba961dfd 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -36,7 +36,7 @@ public function mount() $parameters = get_route_parameters(); $this->projectUuid = data_get($parameters, 'project_uuid'); $this->environmentUuid = data_get($parameters, 'environment_uuid'); - $this->projects = Project::ownedByCurrentTeam()->get(); + $this->projects = Project::ownedByCurrentTeamCached(); $this->servers = currentTeam()->servers->filter(fn ($server) => ! $server->isBuildServer()); } diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index d7210c15d..2d6b76c25 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -41,7 +41,7 @@ class Add extends Component 'command' => 'required|string', 'frequency' => 'required|string', 'container' => 'nullable|string', - 'timeout' => 'required|integer|min:60|max:3600', + 'timeout' => 'required|integer|min:60|max:36000', ]; protected $validationAttributes = [ diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 088de0a76..b1b34dd71 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -40,7 +40,7 @@ class Show extends Component #[Validate(['string', 'nullable'])] public ?string $container = null; - #[Validate(['integer', 'required', 'min:60', 'max:3600'])] + #[Validate(['integer', 'required', 'min:60', 'max:36000'])] public $timeout = 300; #[Locked] @@ -72,7 +72,7 @@ public function mount(string $task_uuid, string $project_uuid, string $environme } elseif ($service_uuid) { $this->type = 'service'; $this->service_uuid = $service_uuid; - $this->resource = Service::ownedByCurrentTeam()->where('uuid', $service_uuid)->firstOrFail(); + $this->resource = Service::ownedByCurrentTeamCached()->where('uuid', $service_uuid)->firstOrFail(); } $this->parameters = [ 'environment_uuid' => $environment_uuid, diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 5970ec904..2091eca14 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -2,11 +2,8 @@ namespace App\Livewire\Project\Shared\Storages; -use App\Models\InstanceSettings; use App\Models\LocalPersistentVolume; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Component; class Show extends Component @@ -67,7 +64,7 @@ private function syncData(bool $toModel = false): void public function mount() { $this->syncData(false); - $this->isReadOnly = $this->storage->isReadOnlyVolume(); + $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); } public function submit() @@ -84,12 +81,8 @@ public function delete($password) { $this->authorize('update', $this->resource); - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } $this->storage->delete(); diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index de2deeed4..3c2abc84c 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -11,20 +11,6 @@ class Terminal extends Component { public bool $hasShell = true; - public function getListeners() - { - $teamId = auth()->user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ApplicationStatusChanged" => 'closeTerminal', - ]; - } - - public function closeTerminal() - { - $this->dispatch('reloadWindow'); - } - private function checkShellAvailability(Server $server, string $container): bool { $escapedContainer = escapeshellarg($container); diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index 8d17bb557..dba1b4903 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -24,6 +24,9 @@ class Advanced extends Component #[Validate(['integer', 'min:1'])] public int $dynamicTimeout = 1; + #[Validate(['integer', 'min:1'])] + public int $deploymentQueueLimit = 25; + public function mount(string $server_uuid) { try { @@ -43,12 +46,14 @@ public function syncData(bool $toModel = false) $this->validate(); $this->server->settings->concurrent_builds = $this->concurrentBuilds; $this->server->settings->dynamic_timeout = $this->dynamicTimeout; + $this->server->settings->deployment_queue_limit = $this->deploymentQueueLimit; $this->server->settings->server_disk_usage_notification_threshold = $this->serverDiskUsageNotificationThreshold; $this->server->settings->server_disk_usage_check_frequency = $this->serverDiskUsageCheckFrequency; $this->server->settings->save(); } else { $this->concurrentBuilds = $this->server->settings->concurrent_builds; $this->dynamicTimeout = $this->server->settings->dynamic_timeout; + $this->deploymentQueueLimit = $this->server->settings->deployment_queue_limit; $this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold; $this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency; } diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index cf77664fe..5fd2ea4f7 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -17,7 +17,7 @@ class Create extends Component public function mount() { - $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); + $this->private_keys = PrivateKey::ownedByCurrentTeamCached(); if (! isCloud()) { $this->limit_reached = false; diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index 8c2c54c99..27a6e7aca 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -3,11 +3,8 @@ namespace App\Livewire\Server; use App\Actions\Server\DeleteServer; -use App\Models\InstanceSettings; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Component; class Delete extends Component @@ -29,12 +26,8 @@ public function mount(string $server_uuid) public function delete($password) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } try { $this->authorize('delete', $this->server); diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 764e583cd..92094c950 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -31,6 +31,9 @@ class DockerCleanup extends Component #[Validate('boolean')] public bool $deleteUnusedNetworks = false; + #[Validate('boolean')] + public bool $disableApplicationImageRetention = false; + public function mount(string $server_uuid) { try { @@ -52,6 +55,7 @@ public function syncData(bool $toModel = false) $this->server->settings->docker_cleanup_threshold = $this->dockerCleanupThreshold; $this->server->settings->delete_unused_volumes = $this->deleteUnusedVolumes; $this->server->settings->delete_unused_networks = $this->deleteUnusedNetworks; + $this->server->settings->disable_application_image_retention = $this->disableApplicationImageRetention; $this->server->settings->save(); } else { $this->forceDockerCleanup = $this->server->settings->force_docker_cleanup; @@ -59,6 +63,7 @@ public function syncData(bool $toModel = false) $this->dockerCleanupThreshold = $this->server->settings->docker_cleanup_threshold; $this->deleteUnusedVolumes = $this->server->settings->delete_unused_volumes; $this->deleteUnusedNetworks = $this->server->settings->delete_unused_networks; + $this->disableApplicationImageRetention = $this->server->settings->disable_application_image_retention; } } diff --git a/app/Livewire/Server/Index.php b/app/Livewire/Server/Index.php index 74764960a..eb832d72f 100644 --- a/app/Livewire/Server/Index.php +++ b/app/Livewire/Server/Index.php @@ -12,7 +12,7 @@ class Index extends Component public function mount() { - $this->servers = Server::ownedByCurrentTeam()->get(); + $this->servers = Server::ownedByCurrentTeamCached(); } public function render() diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index 4e3481912..cd9cfcba6 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -6,7 +6,7 @@ use App\Actions\Proxy\StartProxy; use App\Actions\Proxy\StopProxy; use App\Enums\ProxyTypes; -use App\Jobs\CheckTraefikVersionForServerJob; +use App\Jobs\RestartProxyJob; use App\Models\Server; use App\Services\ProxyDashboardCacheService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -28,6 +28,10 @@ class Navbar extends Component public ?string $proxyStatus = 'unknown'; + public ?string $lastNotifiedStatus = null; + + public bool $restartInitiated = false; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -62,19 +66,19 @@ public function restart() { try { $this->authorize('manageProxy', $this->server); - StopProxy::run($this->server, restarting: true); - $this->server->proxy->force_stop = false; - $this->server->save(); - - $activity = StartProxy::run($this->server, force: true, restarting: true); - $this->dispatch('activityMonitor', $activity->id); - - // Check Traefik version after restart to provide immediate feedback - if ($this->server->proxyType() === ProxyTypes::TRAEFIK->value) { - CheckTraefikVersionForServerJob::dispatch($this->server, get_traefik_versions()); + // Prevent duplicate restart calls + if ($this->restartInitiated) { + return; } + $this->restartInitiated = true; + + // Always use background job for all servers + RestartProxyJob::dispatch($this->server); + } catch (\Throwable $e) { + $this->restartInitiated = false; + return handleError($e, $this); } } @@ -128,12 +132,27 @@ public function checkProxyStatus() } } - public function showNotification() + public function showNotification($event = null) { $previousStatus = $this->proxyStatus; $this->server->refresh(); $this->proxyStatus = $this->server->proxy->status ?? 'unknown'; + // If event contains activityId, open activity monitor + if ($event && isset($event['activityId'])) { + $this->dispatch('activityMonitor', $event['activityId']); + } + + // Reset restart flag when proxy reaches a stable state + if (in_array($this->proxyStatus, ['running', 'exited', 'error'])) { + $this->restartInitiated = false; + } + + // Skip notification if we already notified about this status (prevents duplicates) + if ($this->lastNotifiedStatus === $this->proxyStatus) { + return; + } + switch ($this->proxyStatus) { case 'running': $this->loadProxyConfiguration(); @@ -141,6 +160,7 @@ public function showNotification() // Don't show during normal start/restart flows (starting, restarting, stopping) if (in_array($previousStatus, ['exited', 'stopped', 'unknown', null])) { $this->dispatch('success', 'Proxy is running.'); + $this->lastNotifiedStatus = $this->proxyStatus; } break; case 'exited': @@ -148,19 +168,30 @@ public function showNotification() // Don't show during normal stop/restart flows (stopping, restarting) if (in_array($previousStatus, ['running'])) { $this->dispatch('info', 'Proxy has exited.'); + $this->lastNotifiedStatus = $this->proxyStatus; } break; case 'stopping': - $this->dispatch('info', 'Proxy is stopping.'); + // $this->dispatch('info', 'Proxy is stopping.'); + $this->lastNotifiedStatus = $this->proxyStatus; break; case 'starting': - $this->dispatch('info', 'Proxy is starting.'); + // $this->dispatch('info', 'Proxy is starting.'); + $this->lastNotifiedStatus = $this->proxyStatus; + break; + case 'restarting': + // $this->dispatch('info', 'Proxy is restarting.'); + $this->lastNotifiedStatus = $this->proxyStatus; + break; + case 'error': + $this->dispatch('error', 'Proxy restart failed. Check logs.'); + $this->lastNotifiedStatus = $this->proxyStatus; break; case 'unknown': - $this->dispatch('info', 'Proxy status is unknown.'); + // Don't notify for unknown status - too noisy break; default: - $this->dispatch('info', 'Proxy status updated.'); + // Don't notify for other statuses break; } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index c92f73f17..1a14baf89 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -79,20 +79,19 @@ protected function getTraefikVersions(): ?array // Load from global cached helper (Redis + filesystem) $versionsData = get_versions_data(); - $this->cachedVersionsFile = $versionsData; - if (! $versionsData) { return null; } + $this->cachedVersionsFile = $versionsData; $traefikVersions = data_get($versionsData, 'traefik'); return is_array($traefikVersions) ? $traefikVersions : null; } - public function getConfigurationFilePathProperty() + public function getConfigurationFilePathProperty(): string { - return $this->server->proxyPath().'docker-compose.yml'; + return rtrim($this->server->proxyPath(), '/').'/docker-compose.yml'; } public function changeProxy() diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php index f377bbeb9..c67591cf5 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php @@ -25,13 +25,25 @@ public function delete(string $fileName) $this->authorize('update', $this->server); $proxy_path = $this->server->proxyPath(); $proxy_type = $this->server->proxyType(); + + // Decode filename: pipes are used to encode dots for Livewire property binding + // (e.g., 'my|service.yaml' -> 'my.service.yaml') + // This must happen BEFORE validation because validateShellSafePath() correctly + // rejects pipe characters as dangerous shell metacharacters $file = str_replace('|', '.', $fileName); + + // Validate filename to prevent command injection + validateShellSafePath($file, 'proxy configuration filename'); + if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { $this->dispatch('error', 'Cannot delete Caddyfile.'); return; } - instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $this->server); + + $fullPath = "{$proxy_path}/dynamic/{$file}"; + $escapedPath = escapeshellarg($fullPath); + instant_remote_process(["rm -f {$escapedPath}"], $this->server); if ($proxy_type === 'CADDY') { $this->server->reloadCaddy(); } diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php index eb2db1cbb..31a1dfc7e 100644 --- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php +++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php @@ -4,6 +4,7 @@ use App\Enums\ProxyTypes; use App\Models\Server; +use App\Rules\ValidProxyConfigFilename; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Symfony\Component\Yaml\Yaml; @@ -38,9 +39,13 @@ public function addDynamicConfiguration() try { $this->authorize('update', $this->server); $this->validate([ - 'fileName' => 'required', + 'fileName' => ['required', new ValidProxyConfigFilename], 'value' => 'required', ]); + + // Additional security validation to prevent command injection + validateShellSafePath($this->fileName, 'proxy configuration filename'); + if (data_get($this->parameters, 'server_uuid')) { $this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first(); } @@ -65,8 +70,10 @@ public function addDynamicConfiguration() } $proxy_path = $this->server->proxyPath(); $file = "{$proxy_path}/dynamic/{$this->fileName}"; + $escapedFile = escapeshellarg($file); + if ($this->newFile) { - $exists = instant_remote_process(["test -f $file && echo 1 || echo 0"], $this->server); + $exists = instant_remote_process(["test -f {$escapedFile} && echo 1 || echo 0"], $this->server); if ($exists == 1) { $this->dispatch('error', 'File already exists'); @@ -80,7 +87,7 @@ public function addDynamicConfiguration() } $base64_value = base64_encode($this->value); instant_remote_process([ - "echo '{$base64_value}' | base64 -d | tee {$file} > /dev/null", + "echo '{$base64_value}' | base64 -d | tee {$escapedFile} > /dev/null", ], $this->server); if ($proxy_type === 'CADDY') { $this->server->reloadCaddy(); diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php index 284eea7dd..310edcfe4 100644 --- a/app/Livewire/Server/Security/TerminalAccess.php +++ b/app/Livewire/Server/Security/TerminalAccess.php @@ -2,11 +2,8 @@ namespace App\Livewire\Server\Security; -use App\Models\InstanceSettings; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Validate; use Livewire\Component; @@ -44,13 +41,9 @@ public function toggleTerminal($password) throw new \Exception('Only team administrators and owners can modify terminal access.'); } - // Verify password unless two-step confirmation is disabled - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + // Verify password + if (! verifyPasswordConfirmation($password, $this)) { + return; } // Toggle the terminal setting diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 4626a9135..7a4a1c480 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -5,9 +5,12 @@ use App\Actions\Server\StartSentinel; use App\Actions\Server\StopSentinel; use App\Events\ServerReachabilityChanged; +use App\Models\CloudProviderToken; use App\Models\Server; +use App\Services\HetznerService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Collection; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Component; @@ -73,6 +76,17 @@ class Show extends Component public bool $isValidating = false; + // Hetzner linking properties + public Collection $availableHetznerTokens; + + public ?int $selectedHetznerTokenId = null; + + public ?array $matchedHetznerServer = null; + + public ?string $hetznerSearchError = null; + + public bool $hetznerNoMatchFound = false; + public function getListeners() { $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; @@ -150,6 +164,9 @@ public function mount(string $server_uuid) $this->hetznerServerStatus = $this->server->hetzner_server_status; $this->isValidating = $this->server->is_validating ?? false; + // Load Hetzner tokens for linking + $this->loadHetznerTokens(); + } catch (\Throwable $e) { return handleError($e, $this); } @@ -465,6 +482,98 @@ public function submit() } } + public function loadHetznerTokens(): void + { + $this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + public function searchHetznerServer(): void + { + $this->hetznerSearchError = null; + $this->hetznerNoMatchFound = false; + $this->matchedHetznerServer = null; + + if (! $this->selectedHetznerTokenId) { + $this->hetznerSearchError = 'Please select a Hetzner token.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId); + if (! $token) { + $this->hetznerSearchError = 'Invalid token selected.'; + + return; + } + + $hetznerService = new HetznerService($token->token); + $matched = $hetznerService->findServerByIp($this->server->ip); + + if ($matched) { + $this->matchedHetznerServer = $matched; + } else { + $this->hetznerNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->hetznerSearchError = 'Failed to search Hetzner servers: '.$e->getMessage(); + } + } + + public function linkToHetzner() + { + if (! $this->matchedHetznerServer) { + $this->dispatch('error', 'No Hetzner server selected.'); + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId); + if (! $token) { + $this->dispatch('error', 'Invalid token selected.'); + + return; + } + + // Verify the server exists and is accessible with the token + $hetznerService = new HetznerService($token->token); + $serverData = $hetznerService->getServer($this->matchedHetznerServer['id']); + + if (empty($serverData)) { + $this->dispatch('error', 'Could not find Hetzner server with ID: '.$this->matchedHetznerServer['id']); + + return; + } + + // Update the server with Hetzner details + $this->server->update([ + 'cloud_provider_token_id' => $this->selectedHetznerTokenId, + 'hetzner_server_id' => $this->matchedHetznerServer['id'], + 'hetzner_server_status' => $serverData['status'] ?? null, + ]); + + $this->hetznerServerStatus = $serverData['status'] ?? null; + + // Clear the linking state + $this->matchedHetznerServer = null; + $this->selectedHetznerTokenId = null; + $this->hetznerNoMatchFound = false; + $this->hetznerSearchError = null; + + $this->dispatch('success', 'Server successfully linked to Hetzner Cloud!'); + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.server.show'); diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index c2dcd877b..1a5bd381b 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -206,6 +206,9 @@ public function validateDockerVersion() if (! $proxyShouldRun) { return; } + // Ensure networks exist BEFORE dispatching async proxy startup + // This prevents race condition where proxy tries to start before networks are created + instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false); StartProxy::dispatch($this->server); } else { $requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index be38ae1d8..b011d2dc1 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -5,8 +5,6 @@ use App\Models\InstanceSettings; use App\Models\Server; use App\Rules\ValidIpOrCidr; -use Auth; -use Hash; use Livewire\Attributes\Validate; use Livewire\Component; @@ -157,9 +155,7 @@ public function instantSave() public function toggleTwoStepConfirmation($password): bool { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - + if (! verifyPasswordConfirmation($password, $this)) { return false; } diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 96f13b173..7a96eabb2 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -44,6 +44,8 @@ class Index extends Component public bool $forceSaveDomains = false; + public $buildActivityId = null; + public function render() { return view('livewire.settings.index'); @@ -151,4 +153,37 @@ public function submit() return handleError($e, $this); } } + + public function buildHelperImage() + { + try { + if (! isDev()) { + $this->dispatch('error', 'Building helper image is only available in development mode.'); + + return; + } + + $version = $this->dev_helper_version ?: config('constants.coolify.helper_version'); + if (empty($version)) { + $this->dispatch('error', 'Please specify a version to build.'); + + return; + } + + $buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile ."; + + $activity = remote_process( + command: [$buildCommand], + server: $this->server, + type: 'build-helper-image' + ); + + $this->buildActivityId = $activity->id; + $this->dispatch('activityMonitor', $activity->id); + + $this->dispatch('success', "Building coolify-helper:{$version}..."); + } catch (\Exception $e) { + return handleError($e, $this); + } + } } diff --git a/app/Livewire/SharedVariables/Environment/Index.php b/app/Livewire/SharedVariables/Environment/Index.php index 3673a3882..6685c5c99 100644 --- a/app/Livewire/SharedVariables/Environment/Index.php +++ b/app/Livewire/SharedVariables/Environment/Index.php @@ -12,7 +12,7 @@ class Index extends Component public function mount() { - $this->projects = Project::ownedByCurrentTeam()->get(); + $this->projects = Project::ownedByCurrentTeamCached(); } public function render() diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index bee757a64..0bdc1503f 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -5,6 +5,7 @@ use App\Models\Application; use App\Models\Project; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Show extends Component @@ -19,7 +20,11 @@ class Show extends Component public array $parameters; - protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey', 'environmentVariableDeleted' => '$refresh']; + public string $view = 'normal'; + + public ?string $variables = null; + + protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs']; public function saveKey($data) { @@ -39,6 +44,7 @@ public function saveKey($data) 'team_id' => currentTeam()->id, ]); $this->environment->refresh(); + $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); } @@ -49,6 +55,120 @@ public function mount() $this->parameters = get_route_parameters(); $this->project = Project::ownedByCurrentTeam()->where('uuid', request()->route('project_uuid'))->firstOrFail(); $this->environment = $this->project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail(); + $this->getDevView(); + } + + public function switch() + { + $this->authorize('view', $this->environment); + $this->view = $this->view === 'normal' ? 'dev' : 'normal'; + $this->getDevView(); + } + + public function getDevView() + { + $this->variables = $this->formatEnvironmentVariables($this->environment->environment_variables->sortBy('key')); + } + + private function formatEnvironmentVariables($variables) + { + return $variables->map(function ($item) { + if ($item->is_shown_once) { + return "$item->key=(Locked Secret, delete and add again to change)"; + } + if ($item->is_multiline) { + return "$item->key=(Multiline environment variable, edit in normal view)"; + } + + return "$item->key=$item->value"; + })->join("\n"); + } + + public function submit() + { + try { + $this->authorize('update', $this->environment); + $this->handleBulkSubmit(); + $this->getDevView(); + } catch (\Throwable $e) { + return handleError($e, $this); + } finally { + $this->refreshEnvs(); + } + } + + private function handleBulkSubmit() + { + $variables = parseEnvFormatToArray($this->variables); + $changesMade = false; + + DB::transaction(function () use ($variables, &$changesMade) { + // Delete removed variables + $deletedCount = $this->deleteRemovedVariables($variables); + if ($deletedCount > 0) { + $changesMade = true; + } + + // Update or create variables + $updatedCount = $this->updateOrCreateVariables($variables); + if ($updatedCount > 0) { + $changesMade = true; + } + }); + + // Only dispatch success after transaction has committed + if ($changesMade) { + $this->dispatch('success', 'Environment variables updated.'); + } + } + + private function deleteRemovedVariables($variables) + { + $variablesToDelete = $this->environment->environment_variables()->whereNotIn('key', array_keys($variables))->get(); + + if ($variablesToDelete->isEmpty()) { + return 0; + } + + $this->environment->environment_variables()->whereNotIn('key', array_keys($variables))->delete(); + + return $variablesToDelete->count(); + } + + private function updateOrCreateVariables($variables) + { + $count = 0; + foreach ($variables as $key => $value) { + $found = $this->environment->environment_variables()->where('key', $key)->first(); + + if ($found) { + if (! $found->is_shown_once && ! $found->is_multiline) { + if ($found->value !== $value) { + $found->value = $value; + $found->save(); + $count++; + } + } + } else { + $this->environment->environment_variables()->create([ + 'key' => $key, + 'value' => $value, + 'is_multiline' => false, + 'is_literal' => false, + 'type' => 'environment', + 'team_id' => currentTeam()->id, + ]); + $count++; + } + } + + return $count; + } + + public function refreshEnvs() + { + $this->environment->refresh(); + $this->getDevView(); } public function render() diff --git a/app/Livewire/SharedVariables/Project/Index.php b/app/Livewire/SharedVariables/Project/Index.php index 570da74d3..58929bade 100644 --- a/app/Livewire/SharedVariables/Project/Index.php +++ b/app/Livewire/SharedVariables/Project/Index.php @@ -12,7 +12,7 @@ class Index extends Component public function mount() { - $this->projects = Project::ownedByCurrentTeam()->get(); + $this->projects = Project::ownedByCurrentTeamCached(); } public function render() diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index 712a9960b..b205ea1ec 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -4,6 +4,7 @@ use App\Models\Project; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Show extends Component @@ -12,7 +13,11 @@ class Show extends Component public Project $project; - protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh']; + public string $view = 'normal'; + + public ?string $variables = null; + + protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs']; public function saveKey($data) { @@ -32,6 +37,7 @@ public function saveKey($data) 'team_id' => currentTeam()->id, ]); $this->project->refresh(); + $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); } @@ -46,6 +52,114 @@ public function mount() return redirect()->route('dashboard'); } $this->project = $project; + $this->getDevView(); + } + + public function switch() + { + $this->authorize('view', $this->project); + $this->view = $this->view === 'normal' ? 'dev' : 'normal'; + $this->getDevView(); + } + + public function getDevView() + { + $this->variables = $this->formatEnvironmentVariables($this->project->environment_variables->sortBy('key')); + } + + private function formatEnvironmentVariables($variables) + { + return $variables->map(function ($item) { + if ($item->is_shown_once) { + return "$item->key=(Locked Secret, delete and add again to change)"; + } + if ($item->is_multiline) { + return "$item->key=(Multiline environment variable, edit in normal view)"; + } + + return "$item->key=$item->value"; + })->join("\n"); + } + + public function submit() + { + try { + $this->authorize('update', $this->project); + $this->handleBulkSubmit(); + $this->getDevView(); + } catch (\Throwable $e) { + return handleError($e, $this); + } finally { + $this->refreshEnvs(); + } + } + + private function handleBulkSubmit() + { + $variables = parseEnvFormatToArray($this->variables); + + $changesMade = DB::transaction(function () use ($variables) { + // Delete removed variables + $deletedCount = $this->deleteRemovedVariables($variables); + + // Update or create variables + $updatedCount = $this->updateOrCreateVariables($variables); + + return $deletedCount > 0 || $updatedCount > 0; + }); + + if ($changesMade) { + $this->dispatch('success', 'Environment variables updated.'); + } + } + + private function deleteRemovedVariables($variables) + { + $variablesToDelete = $this->project->environment_variables()->whereNotIn('key', array_keys($variables))->get(); + + if ($variablesToDelete->isEmpty()) { + return 0; + } + + $this->project->environment_variables()->whereNotIn('key', array_keys($variables))->delete(); + + return $variablesToDelete->count(); + } + + private function updateOrCreateVariables($variables) + { + $count = 0; + foreach ($variables as $key => $value) { + $found = $this->project->environment_variables()->where('key', $key)->first(); + + if ($found) { + if (! $found->is_shown_once && ! $found->is_multiline) { + if ($found->value !== $value) { + $found->value = $value; + $found->save(); + $count++; + } + } + } else { + $this->project->environment_variables()->create([ + 'key' => $key, + 'value' => $value, + 'is_multiline' => false, + 'is_literal' => false, + 'type' => 'project', + 'team_id' => currentTeam()->id, + ]); + $count++; + } + } + + return $count; + } + + public function refreshEnvs() + { + $this->project->refresh(); + $this->getDevView(); } public function render() diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index 82473528c..e420686f0 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -4,6 +4,7 @@ use App\Models\Team; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Index extends Component @@ -12,7 +13,11 @@ class Index extends Component public Team $team; - protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh']; + public string $view = 'normal'; + + public ?string $variables = null; + + protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs']; public function saveKey($data) { @@ -32,6 +37,7 @@ public function saveKey($data) 'team_id' => currentTeam()->id, ]); $this->team->refresh(); + $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); } @@ -40,6 +46,119 @@ public function saveKey($data) public function mount() { $this->team = currentTeam(); + $this->getDevView(); + } + + public function switch() + { + $this->authorize('view', $this->team); + $this->view = $this->view === 'normal' ? 'dev' : 'normal'; + $this->getDevView(); + } + + public function getDevView() + { + $this->variables = $this->formatEnvironmentVariables($this->team->environment_variables->sortBy('key')); + } + + private function formatEnvironmentVariables($variables) + { + return $variables->map(function ($item) { + if ($item->is_shown_once) { + return "$item->key=(Locked Secret, delete and add again to change)"; + } + if ($item->is_multiline) { + return "$item->key=(Multiline environment variable, edit in normal view)"; + } + + return "$item->key=$item->value"; + })->join("\n"); + } + + public function submit() + { + try { + $this->authorize('update', $this->team); + $this->handleBulkSubmit(); + $this->getDevView(); + } catch (\Throwable $e) { + return handleError($e, $this); + } finally { + $this->refreshEnvs(); + } + } + + private function handleBulkSubmit() + { + $variables = parseEnvFormatToArray($this->variables); + $changesMade = false; + + DB::transaction(function () use ($variables, &$changesMade) { + // Delete removed variables + $deletedCount = $this->deleteRemovedVariables($variables); + if ($deletedCount > 0) { + $changesMade = true; + } + + // Update or create variables + $updatedCount = $this->updateOrCreateVariables($variables); + if ($updatedCount > 0) { + $changesMade = true; + } + }); + + if ($changesMade) { + $this->dispatch('success', 'Environment variables updated.'); + } + } + + private function deleteRemovedVariables($variables) + { + $variablesToDelete = $this->team->environment_variables()->whereNotIn('key', array_keys($variables))->get(); + + if ($variablesToDelete->isEmpty()) { + return 0; + } + + $this->team->environment_variables()->whereNotIn('key', array_keys($variables))->delete(); + + return $variablesToDelete->count(); + } + + private function updateOrCreateVariables($variables) + { + $count = 0; + foreach ($variables as $key => $value) { + $found = $this->team->environment_variables()->where('key', $key)->first(); + + if ($found) { + if (! $found->is_shown_once && ! $found->is_multiline) { + if ($found->value !== $value) { + $found->value = $value; + $found->save(); + $count++; + } + } + } else { + $this->team->environment_variables()->create([ + 'key' => $key, + 'value' => $value, + 'is_multiline' => false, + 'is_literal' => false, + 'type' => 'team', + 'team_id' => currentTeam()->id, + ]); + $count++; + } + } + + return $count; + } + + public function refreshEnvs() + { + $this->team->refresh(); + $this->getDevView(); } public function render() diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 4bd0b798a..0a38e6088 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -196,7 +196,7 @@ public function mount() $github_app_uuid = request()->github_app_uuid; $this->github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail(); $this->github_app->makeVisible(['client_secret', 'webhook_secret']); - $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get(); + $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); $this->applications = $this->github_app->applications; $settings = instanceSettings(); diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index d97550693..d101d7b58 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -120,9 +120,16 @@ public function testConnection() $this->storage->testConnection(shouldSave: true); + // Update component property to reflect the new validation status + $this->isUsable = $this->storage->is_usable; + return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.'); } catch (\Throwable $e) { - $this->dispatch('error', 'Failed to create storage.', $e->getMessage()); + // Refresh model and sync to get the latest state + $this->storage->refresh(); + $this->isUsable = $this->storage->is_usable; + + $this->dispatch('error', 'Failed to test connection.', $e->getMessage()); } } diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index bdea9a3b0..fdf3d0d28 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -3,10 +3,13 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public $storage = null; public function mount() @@ -15,6 +18,7 @@ public function mount() if (! $this->storage) { abort(404); } + $this->authorize('view', $this->storage); } public function render() diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index 6d6915ae2..c8d44d42b 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -2,10 +2,7 @@ namespace App\Livewire\Team; -use App\Models\InstanceSettings; use App\Models\User; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Component; class AdminView extends Component @@ -58,12 +55,8 @@ public function delete($id, $password) return redirect()->route('dashboard'); } - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } + if (! verifyPasswordConfirmation($password, $this)) { + return; } if (! auth()->user()->isInstanceAdmin()) { diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php index e50085c64..36bee2a23 100644 --- a/app/Livewire/Upgrade.php +++ b/app/Livewire/Upgrade.php @@ -4,24 +4,31 @@ use App\Actions\Server\UpdateCoolify; use App\Models\InstanceSettings; +use App\Models\Server; use Livewire\Component; class Upgrade extends Component { - public bool $showProgress = false; - public bool $updateInProgress = false; public bool $isUpgradeAvailable = false; public string $latestVersion = ''; + public string $currentVersion = ''; + protected $listeners = ['updateAvailable' => 'checkUpdate']; + public function mount() + { + $this->currentVersion = config('constants.coolify.version'); + } + public function checkUpdate() { try { $this->latestVersion = get_latest_version_of_coolify(); + $this->currentVersion = config('constants.coolify.version'); $this->isUpgradeAvailable = data_get(InstanceSettings::get(), 'new_version_available', false); if (isDev()) { $this->isUpgradeAvailable = true; @@ -43,4 +50,71 @@ public function upgrade() return handleError($e, $this); } } + + public function getUpgradeStatus(): array + { + // Only root team members can view upgrade status + if (auth()->user()?->currentTeam()?->id !== 0) { + return ['status' => 'none']; + } + + $server = Server::find(0); + if (! $server) { + return ['status' => 'none']; + } + + $statusFile = '/data/coolify/source/.upgrade-status'; + + try { + $content = instant_remote_process( + ["cat {$statusFile} 2>/dev/null || echo ''"], + $server, + false + ); + $content = trim($content ?? ''); + } catch (\Throwable $e) { + return ['status' => 'none']; + } + + if (empty($content)) { + return ['status' => 'none']; + } + + $parts = explode('|', $content); + if (count($parts) < 3) { + return ['status' => 'none']; + } + + [$step, $message, $timestamp] = $parts; + + // Check if status is stale (older than 10 minutes) + try { + $statusTime = new \DateTime($timestamp); + $now = new \DateTime; + $diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60; + + if ($diffMinutes > 10) { + return ['status' => 'none']; + } + } catch (\Throwable $e) { + return ['status' => 'none']; + } + + if ($step === 'error') { + return [ + 'status' => 'error', + 'step' => 0, + 'message' => $message, + ]; + } + + $stepInt = (int) $step; + $status = $stepInt >= 6 ? 'complete' : 'in_progress'; + + return [ + 'status' => $status, + 'step' => $stepInt, + 'message' => $message, + ]; + } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 821c69bca..5006d0ff8 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -338,11 +338,25 @@ public static function ownedByCurrentTeamAPI(int $teamId) return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); } + /** + * Get query builder for applications owned by current team. + * If you need all applications without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return Application::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all applications owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return Application::ownedByCurrentTeam()->get(); + }); + } + public function getContainersToStop(Server $server, bool $previewDeployments = false): array { $containers = $previewDeployments @@ -1035,7 +1049,7 @@ public function isLogDrainEnabled() public function isConfigurationChanged(bool $save = false) { - $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets); + $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets.$this->settings->inject_build_args_to_dockerfile.$this->settings->include_source_commit_in_build); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } else { @@ -1500,10 +1514,10 @@ public function oldRawParser() instant_remote_process($commands, $this->destination->server, false); } - public function parse(int $pull_request_id = 0, ?int $preview_id = null) + public function parse(int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null) { if ((int) $this->compose_parsing_version >= 3) { - return applicationParser($this, $pull_request_id, $preview_id); + return applicationParser($this, $pull_request_id, $preview_id, $commit); } elseif ($this->docker_compose_raw) { return parseDockerComposeFile(resource: $this, isNew: false, pull_request_id: $pull_request_id, preview_id: $preview_id); } else { @@ -1511,9 +1525,11 @@ public function parse(int $pull_request_id = 0, ?int $preview_id = null) } } - public function loadComposeFile($isInit = false) + public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null) { - $initialDockerComposeLocation = $this->docker_compose_location; + // Use provided restore values or capture current values as fallback + $initialDockerComposeLocation = $restoreDockerComposeLocation ?? $this->docker_compose_location; + $initialBaseDirectory = $restoreBaseDirectory ?? $this->base_directory; if ($isInit && $this->docker_compose_raw) { return; } @@ -1580,6 +1596,7 @@ public function loadComposeFile($isInit = false) throw new \RuntimeException($e->getMessage()); } finally { $this->docker_compose_location = $initialDockerComposeLocation; + $this->base_directory = $initialBaseDirectory; $this->save(); $commands = collect([ "rm -rf /tmp/{$uuid}", diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 721b22216..04ce6274a 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -145,11 +145,13 @@ public function generate_preview_fqdn_compose() $template = $this->application->preview_url_template; $host = $url->getHost(); $schema = $url->getScheme(); + $portInt = $url->getPort(); + $port = $portInt !== null ? ':'.$portInt : ''; $random = new Cuid2; $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; + $preview_fqdn = "$schema://$preview_fqdn{$port}"; $preview_domains[] = $preview_fqdn; } diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 26cb937b3..f40977b3e 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -15,6 +15,8 @@ class ApplicationSetting extends Model 'is_container_label_escape_enabled' => 'boolean', 'is_container_label_readonly_enabled' => 'boolean', 'use_build_secrets' => 'boolean', + 'inject_build_args_to_dockerfile' => 'boolean', + 'include_source_commit_in_build' => 'boolean', 'is_auto_deploy_enabled' => 'boolean', 'is_force_https_enabled' => 'boolean', 'is_debug_enabled' => 'boolean', @@ -23,6 +25,7 @@ class ApplicationSetting extends Model 'is_git_submodules_enabled' => 'boolean', 'is_git_lfs_enabled' => 'boolean', 'is_git_shallow_clone_enabled' => 'boolean', + 'docker_images_to_keep' => 'integer', ]; protected $guarded = []; diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 607040269..700ab0992 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -2,9 +2,7 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; - -class CloudProviderToken extends Model +class CloudProviderToken extends BaseModel { protected $guarded = []; diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 80399a16b..895dc1c43 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -65,6 +65,8 @@ protected static function booted() 'value' => $environment_variable->value, 'is_multiline' => $environment_variable->is_multiline ?? false, 'is_literal' => $environment_variable->is_literal ?? false, + 'is_runtime' => $environment_variable->is_runtime ?? false, + 'is_buildtime' => $environment_variable->is_buildtime ?? false, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, 'is_preview' => true, @@ -190,11 +192,11 @@ private function get_real_environment_variables(?string $environment_variable = return $environment_variable; } foreach ($sharedEnvsFound as $sharedEnv) { - $type = str($sharedEnv)->match('/(.*?)\./'); + $type = str($sharedEnv)->trim()->match('/(.*?)\./'); if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) { continue; } - $variable = str($sharedEnv)->match('/\.(.*)/'); + $variable = str($sharedEnv)->trim()->match('/\.(.*)/'); if ($type->value() === 'environment') { $id = $resource->environment->id; } elseif ($type->value() === 'project') { @@ -231,7 +233,7 @@ private function set_environment_variables(?string $environment_variable = null) $environment_variable = trim($environment_variable); $type = str($environment_variable)->after('{{')->before('.')->value; if (str($environment_variable)->startsWith('{{'.$type) && str($environment_variable)->endsWith('}}')) { - return encrypt((string) str($environment_variable)->replace(' ', '')); + return encrypt($environment_variable); } return encrypt($environment_variable); diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 376ea9c5e..9d7095cb5 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -61,9 +61,14 @@ public function loadStorageOnServer() $path = $path->after('.'); $path = $workdir.$path; } - $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server); + + // Validate and escape path to prevent command injection + validateShellSafePath($path, 'storage path'); + $escapedPath = escapeshellarg($path); + + $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); if ($isFile === 'OK') { - $content = instant_remote_process(["cat $path"], $server, false); + $content = instant_remote_process(["cat {$escapedPath}"], $server, false); // Check if content contains binary data by looking for null bytes or non-printable characters if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) { $content = '[binary file]'; @@ -91,14 +96,19 @@ public function deleteStorageOnServer() $path = $path->after('.'); $path = $workdir.$path; } - $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server); - $isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server); + + // Validate and escape path to prevent command injection + validateShellSafePath($path, 'storage path'); + $escapedPath = escapeshellarg($path); + + $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); + $isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server); if ($path && $path != '/' && $path != '.' && $path != '..') { if ($isFile === 'OK') { - $commands->push("rm -rf $path > /dev/null 2>&1 || true"); + $commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true"); } elseif ($isDir === 'OK') { - $commands->push("rm -rf $path > /dev/null 2>&1 || true"); - $commands->push("rmdir $path > /dev/null 2>&1 || true"); + $commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true"); + $commands->push("rmdir {$escapedPath} > /dev/null 2>&1 || true"); } } if ($commands->count() > 0) { @@ -135,10 +145,15 @@ public function saveStorageOnServer() $path = $path->after('.'); $path = $workdir.$path; } - $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server); - $isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server); + + // Validate and escape path to prevent command injection + validateShellSafePath($path, 'storage path'); + $escapedPath = escapeshellarg($path); + + $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); + $isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server); if ($isFile === 'OK' && $this->is_directory) { - $content = instant_remote_process(["cat $path"], $server, false); + $content = instant_remote_process(["cat {$escapedPath}"], $server, false); $this->is_directory = false; $this->content = $content; $this->save(); @@ -151,8 +166,8 @@ public function saveStorageOnServer() throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file.

    Please delete the directory on the server or mark it as directory.'); } instant_remote_process([ - "rm -fr $path", - "touch $path", + "rm -fr {$escapedPath}", + "touch {$escapedPath}", ], $server, false); FileStorageChanged::dispatch(data_get($server, 'team_id')); } @@ -161,19 +176,19 @@ public function saveStorageOnServer() $chown = data_get($this, 'chown'); if ($content) { $content = base64_encode($content); - $commands->push("echo '$content' | base64 -d | tee $path > /dev/null"); + $commands->push("echo '$content' | base64 -d | tee {$escapedPath} > /dev/null"); } else { - $commands->push("touch $path"); + $commands->push("touch {$escapedPath}"); } - $commands->push("chmod +x $path"); + $commands->push("chmod +x {$escapedPath}"); if ($chown) { - $commands->push("chown $chown $path"); + $commands->push("chown $chown {$escapedPath}"); } if ($chmod) { - $commands->push("chmod $chmod $path"); + $commands->push("chmod $chmod {$escapedPath}"); } } elseif ($isDir === 'NOK' && $this->is_directory) { - $commands->push("mkdir -p $path > /dev/null 2>&1 || true"); + $commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true"); } return instant_remote_process($commands, $server); @@ -194,6 +209,23 @@ public function scopeWherePlainMountPath($query, $path) return $query->get()->where('plain_mount_path', $path); } + // Check if this volume belongs to a service resource + public function isServiceResource(): bool + { + return in_array($this->resource_type, [ + 'App\Models\ServiceApplication', + 'App\Models\ServiceDatabase', + ]); + } + + // Determine if this volume should be read-only in the UI + // File/directory mounts can be edited even for services + public function shouldBeReadOnlyInUI(): bool + { + // Check for explicit :ro flag in compose (existing logic) + return $this->isReadOnlyVolume(); + } + // Check if this volume is read-only by parsing the docker-compose content public function isReadOnlyVolume(): bool { @@ -224,22 +256,40 @@ public function isReadOnlyVolume(): bool $volumes = $compose['services'][$serviceName]['volumes']; // Check each volume to find a match + // Note: We match on mount_path (container path) only, since fs_path gets transformed + // from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing 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 + // Check if this volume matches our 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) { + // 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'; } } + } elseif (is_array($volume)) { + // Long-form syntax: { type: bind, source: ..., target: ..., read_only: true } + $containerPath = data_get($volume, 'target'); + $readOnly = data_get($volume, 'read_only', false); + + // 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 $readOnly === true; + } } } diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index e7862478b..7126253ea 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -10,6 +10,11 @@ class LocalPersistentVolume extends Model { protected $guarded = []; + public function resource() + { + return $this->morphTo('resource'); + } + public function application() { return $this->morphTo('resource'); @@ -50,6 +55,54 @@ protected function hostPath(): Attribute ); } + // Check if this volume belongs to a service resource + public function isServiceResource(): bool + { + return in_array($this->resource_type, [ + 'App\Models\ServiceApplication', + 'App\Models\ServiceDatabase', + ]); + } + + // Check if this volume belongs to a dockercompose application + public function isDockerComposeResource(): bool + { + if ($this->resource_type !== 'App\Models\Application') { + return false; + } + + // Only access relationship if already eager loaded to avoid N+1 + if (! $this->relationLoaded('resource')) { + return false; + } + + $application = $this->resource; + if (! $application) { + return false; + } + + return data_get($application, 'build_pack') === 'dockercompose'; + } + + // Determine if this volume should be read-only in the UI + // Service volumes and dockercompose application volumes are read-only + // (users should edit compose file directly) + public function shouldBeReadOnlyInUI(): bool + { + // All service volumes should be read-only in UI + if ($this->isServiceResource()) { + return true; + } + + // All dockercompose application volumes should be read-only in UI + if ($this->isDockerComposeResource()) { + return true; + } + + // Check for explicit :ro flag in compose (existing logic) + return $this->isReadOnlyVolume(); + } + // Check if this volume is read-only by parsing the docker-compose content public function isReadOnlyVolume(): bool { @@ -85,6 +138,7 @@ public function isReadOnlyVolume(): bool $volumes = $compose['services'][$serviceName]['volumes']; // Check each volume to find a match + // Note: We match on mount_path (container path) only, since host paths get transformed foreach ($volumes as $volume) { // Volume can be string like "host:container:ro" or "host:container" if (is_string($volume)) { @@ -104,6 +158,19 @@ public function isReadOnlyVolume(): bool return $options === 'ro'; } } + } elseif (is_array($volume)) { + // Long-form syntax: { type: bind/volume, source: ..., target: ..., read_only: true } + $containerPath = data_get($volume, 'target'); + $readOnly = data_get($volume, 'read_only', false); + + // 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 $readOnly === true; + } } } diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 46531ed34..bb76d5ed6 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -80,6 +80,10 @@ public function getPublicKey() return self::extractPublicKeyFromPrivate($this->private_key) ?? 'Error loading private key'; } + /** + * Get query builder for private keys owned by current team. + * If you need all private keys without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam(array $select = ['*']) { $teamId = currentTeam()->id; @@ -88,6 +92,16 @@ public static function ownedByCurrentTeam(array $select = ['*']) return self::whereTeamId($teamId)->select($selectArray->all()); } + /** + * Get all private keys owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return PrivateKey::ownedByCurrentTeam()->get(); + }); + } + public static function ownedAndOnlySShKeys(array $select = ['*']) { $teamId = currentTeam()->id; diff --git a/app/Models/Project.php b/app/Models/Project.php index a9bf76803..8b26672f0 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -30,11 +30,25 @@ class Project extends BaseModel protected $guarded = []; + /** + * Get query builder for projects owned by current team. + * If you need all projects without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return Project::whereTeamId(currentTeam()->id)->orderByRaw('LOWER(name)'); } + /** + * Get all projects owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return Project::ownedByCurrentTeam()->get(); + }); + } + protected static function booted() { static::created(function ($project) { diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index de27bbca6..3aae55966 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Storage; @@ -19,6 +20,28 @@ class S3Storage extends BaseModel 'secret' => 'encrypted', ]; + /** + * Boot the model and register event listeners. + */ + protected static function boot(): void + { + parent::boot(); + + // Trim whitespace from credentials before saving to prevent + // "Malformed Access Key Id" errors from accidental whitespace in pasted values. + // Note: We use the saving event instead of Attribute mutators because key/secret + // use Laravel's 'encrypted' cast. Attribute mutators fire before casts, which + // would cause issues with the encryption/decryption cycle. + static::saving(function (S3Storage $storage) { + if ($storage->key !== null) { + $storage->key = trim($storage->key); + } + if ($storage->secret !== null) { + $storage->secret = trim($storage->secret); + } + }); + } + public static function ownedByCurrentTeam(array $select = ['*']) { $selectArray = collect($select)->concat(['id']); @@ -41,6 +64,49 @@ public function awsUrl() return "{$this->endpoint}/{$this->bucket}"; } + protected function path(): Attribute + { + return Attribute::make( + set: function (?string $value) { + if ($value === null || $value === '') { + return null; + } + + return str($value)->trim()->start('/')->value(); + } + ); + } + + /** + * Trim whitespace from endpoint to prevent malformed URLs. + */ + protected function endpoint(): Attribute + { + return Attribute::make( + set: fn (?string $value) => $value ? trim($value) : null, + ); + } + + /** + * Trim whitespace from bucket name to prevent connection errors. + */ + protected function bucket(): Attribute + { + return Attribute::make( + set: fn (?string $value) => $value ? trim($value) : null, + ); + } + + /** + * Trim whitespace from region to prevent connection errors. + */ + protected function region(): Attribute + { + return Attribute::make( + set: fn (?string $value) => $value ? trim($value) : null, + ); + } + public function testConnection(bool $shouldSave = false) { try { diff --git a/app/Models/Server.php b/app/Models/Server.php index 8b153c8ac..82ee6721d 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -242,6 +242,10 @@ public static function isReachable() return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true); } + /** + * Get query builder for servers owned by current team. + * If you need all servers without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam(array $select = ['*']) { $teamId = currentTeam()->id; @@ -250,6 +254,16 @@ public static function ownedByCurrentTeam(array $select = ['*']) return Server::whereTeamId($teamId)->with('settings', 'swarmDockers', 'standaloneDockers')->select($selectArray->all())->orderBy('name'); } + /** + * Get all servers owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return Server::ownedByCurrentTeam()->get(); + }); + } + public static function isUsable() { return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_swarm_worker', false)->whereRelation('settings', 'is_build_server', false)->whereRelation('settings', 'force_disabled', false); diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 6da4dd4c6..0ad0fcf84 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -13,6 +13,7 @@ properties: [ 'id' => ['type' => 'integer'], 'concurrent_builds' => ['type' => 'integer'], + 'deployment_queue_limit' => ['type' => 'integer'], 'dynamic_timeout' => ['type' => 'integer'], 'force_disabled' => ['type' => 'boolean'], 'force_server_cleanup' => ['type' => 'boolean'], @@ -61,6 +62,7 @@ class ServerSetting extends Model 'is_reachable' => 'boolean', 'is_usable' => 'boolean', 'is_terminal_enabled' => 'boolean', + 'disable_application_image_retention' => 'boolean', ]; protected static function booted() diff --git a/app/Models/Service.php b/app/Models/Service.php index 2f8a64464..2daf9c39d 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -153,11 +153,25 @@ public function tags() return $this->morphToMany(Tag::class, 'taggable'); } + /** + * Get query builder for services owned by current team. + * If you need all services without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return Service::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all services owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return Service::ownedByCurrentTeam()->get(); + }); + } + public function deleteConfigurations() { $server = data_get($this, 'destination.server'); @@ -712,6 +726,84 @@ public function extraFields() $fields->put('MinIO', $data->toArray()); break; + case $image->contains('garage'): + $data = collect([]); + $s3_api_url = $this->environment_variables()->where('key', 'GARAGE_S3_API_URL')->first(); + $web_url = $this->environment_variables()->where('key', 'GARAGE_WEB_URL')->first(); + $admin_url = $this->environment_variables()->where('key', 'GARAGE_ADMIN_URL')->first(); + $admin_token = $this->environment_variables()->where('key', 'GARAGE_ADMIN_TOKEN')->first(); + if (is_null($admin_token)) { + $admin_token = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GARAGE')->first(); + } + $rpc_secret = $this->environment_variables()->where('key', 'GARAGE_RPC_SECRET')->first(); + if (is_null($rpc_secret)) { + $rpc_secret = $this->environment_variables()->where('key', 'SERVICE_HEX_32_RPCSECRET')->first(); + } + $metrics_token = $this->environment_variables()->where('key', 'GARAGE_METRICS_TOKEN')->first(); + if (is_null($metrics_token)) { + $metrics_token = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GARAGEMETRICS')->first(); + } + + if ($s3_api_url) { + $data = $data->merge([ + 'S3 API URL' => [ + 'key' => data_get($s3_api_url, 'key'), + 'value' => data_get($s3_api_url, 'value'), + 'rules' => 'required|url', + ], + ]); + } + if ($web_url) { + $data = $data->merge([ + 'Web URL' => [ + 'key' => data_get($web_url, 'key'), + 'value' => data_get($web_url, 'value'), + 'rules' => 'required|url', + ], + ]); + } + if ($admin_url) { + $data = $data->merge([ + 'Admin URL' => [ + 'key' => data_get($admin_url, 'key'), + 'value' => data_get($admin_url, 'value'), + 'rules' => 'required|url', + ], + ]); + } + if ($admin_token) { + $data = $data->merge([ + 'Admin Token' => [ + 'key' => data_get($admin_token, 'key'), + 'value' => data_get($admin_token, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + if ($rpc_secret) { + $data = $data->merge([ + 'RPC Secret' => [ + 'key' => data_get($rpc_secret, 'key'), + 'value' => data_get($rpc_secret, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + if ($metrics_token) { + $data = $data->merge([ + 'Metrics Token' => [ + 'key' => data_get($metrics_token, 'key'), + 'value' => data_get($metrics_token, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + + $fields->put('Garage', $data->toArray()); + break; case $image->contains('weblate'): $data = collect([]); $admin_email = $this->environment_variables()->where('key', 'WEBLATE_ADMIN_EMAIL')->first(); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index aef74b402..7b8b46812 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -37,11 +37,25 @@ public static function ownedByCurrentTeamAPI(int $teamId) return ServiceApplication::whereRelation('service.environment.project.team', 'id', $teamId)->orderBy('name'); } + /** + * Get query builder for service applications owned by current team. + * If you need all service applications without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return ServiceApplication::whereRelation('service.environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all service applications owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return ServiceApplication::ownedByCurrentTeam()->get(); + }); + } + public function isRunning() { return str($this->status)->contains('running'); diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 3a249059c..f6a39cfe4 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -30,11 +30,25 @@ public static function ownedByCurrentTeamAPI(int $teamId) return ServiceDatabase::whereRelation('service.environment.project.team', 'id', $teamId)->orderBy('name'); } + /** + * Get query builder for service databases owned by current team. + * If you need all service databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return ServiceDatabase::whereRelation('service.environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all service databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return ServiceDatabase::ownedByCurrentTeam()->get(); + }); + } + public function restart() { $container_id = $this->name.'-'.$this->service->uuid; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 6ac685618..f598ef2ea 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -44,11 +44,25 @@ protected static function booted() }); } + /** + * Get query builder for ClickHouse databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all ClickHouse databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneClickhouse::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 2d004246c..47170056f 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -44,11 +44,25 @@ protected static function booted() }); } + /** + * Get query builder for Dragonfly databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all Dragonfly databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneDragonfly::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 131e5bb3f..266110d0a 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -44,11 +44,25 @@ protected static function booted() }); } + /** + * Get query builder for KeyDB databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all KeyDB databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneKeydb::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 675c7987f..aa7f2d31a 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -45,11 +45,25 @@ protected static function booted() }); } + /** + * Get query builder for MariaDB databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all MariaDB databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneMariadb::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 7b70988f6..9046ab013 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -47,11 +47,25 @@ protected static function booted() }); } + /** + * Get query builder for MongoDB databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all MongoDB databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneMongodb::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 6f79241af..719387b36 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -45,11 +45,25 @@ protected static function booted() }); } + /** + * Get query builder for MySQL databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all MySQL databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneMysql::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 2dc5616a2..03080fd3d 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -45,11 +45,25 @@ protected static function booted() }); } + /** + * Get query builder for PostgreSQL databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all PostgreSQL databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandalonePostgresql::ownedByCurrentTeam()->get(); + }); + } + public function workdir() { return database_configuration_dir()."/{$this->uuid}"; diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index c0223304a..6aca8af9a 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -46,11 +46,25 @@ protected static function booted() }); } + /** + * Get query builder for Redis databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all Redis databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneRedis::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/User.php b/app/Models/User.php index f04b6fa77..b790efcf1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -443,4 +443,13 @@ public function hasEmailChangeRequest(): bool && $this->email_change_code_expires_at && Carbon::now()->lessThan($this->email_change_code_expires_at); } + + /** + * Check if the user has a password set. + * OAuth users are created without passwords. + */ + public function hasPassword(): bool + { + return ! empty($this->password); + } } diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php index 79bd0ae62..731006181 100644 --- a/app/Models/WebhookNotificationSettings.php +++ b/app/Models/WebhookNotificationSettings.php @@ -24,7 +24,8 @@ class WebhookNotificationSettings extends Model 'backup_failure_webhook_notifications', 'scheduled_task_success_webhook_notifications', 'scheduled_task_failure_webhook_notifications', - 'docker_cleanup_webhook_notifications', + 'docker_cleanup_success_webhook_notifications', + 'docker_cleanup_failure_webhook_notifications', 'server_disk_usage_webhook_notifications', 'server_reachable_webhook_notifications', 'server_unreachable_webhook_notifications', @@ -45,7 +46,8 @@ protected function casts(): array 'backup_failure_webhook_notifications' => 'boolean', 'scheduled_task_success_webhook_notifications' => 'boolean', 'scheduled_task_failure_webhook_notifications' => 'boolean', - 'docker_cleanup_webhook_notifications' => 'boolean', + 'docker_cleanup_success_webhook_notifications' => 'boolean', + 'docker_cleanup_failure_webhook_notifications' => 'boolean', 'server_disk_usage_webhook_notifications' => 'boolean', 'server_reachable_webhook_notifications' => 'boolean', 'server_unreachable_webhook_notifications' => 'boolean', diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index 234bc37ad..abd115550 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -43,21 +43,26 @@ public function send(SendsEmail $notifiable, Notification $notification): void throw new Exception('No email recipients found'); } - foreach ($recipients as $recipient) { - // Check if the recipient is part of the team - if (! $members->contains('email', $recipient)) { - $emailSettings = $notifiable->emailNotificationSettings; - data_set($emailSettings, 'smtp_password', '********'); - data_set($emailSettings, 'resend_api_key', '********'); - send_internal_notification(sprintf( - "Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s", - $recipient, - $team, - get_class($notification), - get_class($notifiable), - json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) - )); - throw new Exception('Recipient is not part of the team'); + // Skip team membership validation for test notifications + $isTestNotification = data_get($notification, 'isTestNotification', false); + + if (! $isTestNotification) { + foreach ($recipients as $recipient) { + // Check if the recipient is part of the team + if (! $members->contains('email', $recipient)) { + $emailSettings = $notifiable->emailNotificationSettings; + data_set($emailSettings, 'smtp_password', '********'); + data_set($emailSettings, 'resend_api_key', '********'); + send_internal_notification(sprintf( + "Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s", + $recipient, + $team, + get_class($notification), + get_class($notifiable), + json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + )); + throw new Exception('Recipient is not part of the team'); + } } } diff --git a/app/Notifications/Server/TraefikVersionOutdated.php b/app/Notifications/Server/TraefikVersionOutdated.php index 09ef4257d..c94cc1732 100644 --- a/app/Notifications/Server/TraefikVersionOutdated.php +++ b/app/Notifications/Server/TraefikVersionOutdated.php @@ -43,9 +43,19 @@ public function toMail($notifiable = null): MailMessage $mail = new MailMessage; $count = $this->servers->count(); + // Transform servers to include URLs + $serversWithUrls = $this->servers->map(function ($server) { + return [ + 'name' => $server->name, + 'uuid' => $server->uuid, + 'url' => base_url().'/server/'.$server->uuid.'/proxy', + 'outdatedInfo' => $server->outdatedInfo ?? [], + ]; + }); + $mail->subject("Coolify: Traefik proxy outdated on {$count} server(s)"); $mail->view('emails.traefik-version-outdated', [ - 'servers' => $this->servers, + 'servers' => $serversWithUrls, 'count' => $count, ]); diff --git a/app/Notifications/Test.php b/app/Notifications/Test.php index 60bc8a0ee..bbed22777 100644 --- a/app/Notifications/Test.php +++ b/app/Notifications/Test.php @@ -23,6 +23,8 @@ class Test extends Notification implements ShouldQueue public $tries = 5; + public bool $isTestNotification = true; + public function __construct(public ?string $emails = null, public ?string $channel = null, public ?bool $ping = false) { $this->onQueue('high'); diff --git a/app/Notifications/TransactionalEmails/Test.php b/app/Notifications/TransactionalEmails/Test.php index 3add70db2..2f7d70bbf 100644 --- a/app/Notifications/TransactionalEmails/Test.php +++ b/app/Notifications/TransactionalEmails/Test.php @@ -8,6 +8,8 @@ class Test extends CustomEmailNotification { + public bool $isTestNotification = true; + public function __construct(public string $emails, public bool $isTransactionalEmail = true) { $this->onQueue('high'); diff --git a/app/Policies/InstanceSettingsPolicy.php b/app/Policies/InstanceSettingsPolicy.php new file mode 100644 index 000000000..a04f07a28 --- /dev/null +++ b/app/Policies/InstanceSettingsPolicy.php @@ -0,0 +1,25 @@ + \App\Policies\ApiTokenPolicy::class, + // Instance settings policy + \App\Models\InstanceSettings::class => \App\Policies\InstanceSettingsPolicy::class, + // Team policy \App\Models\Team::class => \App\Policies\TeamPolicy::class, diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 2d9910add..9163d595c 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,10 +2,6 @@ namespace App\Providers; -use App\Listeners\MaintenanceModeDisabledNotification; -use App\Listeners\MaintenanceModeEnabledNotification; -use Illuminate\Foundation\Events\MaintenanceModeDisabled; -use Illuminate\Foundation\Events\MaintenanceModeEnabled; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use SocialiteProviders\Authentik\AuthentikExtendSocialite; use SocialiteProviders\Azure\AzureExtendSocialite; @@ -19,12 +15,6 @@ class EventServiceProvider extends ServiceProvider { protected $listen = [ - MaintenanceModeEnabled::class => [ - MaintenanceModeEnabledNotification::class, - ], - MaintenanceModeDisabled::class => [ - MaintenanceModeDisabledNotification::class, - ], SocialiteWasCalled::class => [ AzureExtendSocialite::class.'@handle', AuthentikExtendSocialite::class.'@handle', diff --git a/app/Rules/ValidProxyConfigFilename.php b/app/Rules/ValidProxyConfigFilename.php new file mode 100644 index 000000000..871cc6eeb --- /dev/null +++ b/app/Rules/ValidProxyConfigFilename.php @@ -0,0 +1,73 @@ + 255) { + $fail('The :attribute must not exceed 255 characters.'); + + return; + } + + // Check for path separators (prevent path traversal) + if (str_contains($filename, '/') || str_contains($filename, '\\')) { + $fail('The :attribute cannot contain path separators.'); + + return; + } + + // Check for hidden files (starting with dot) + if (str_starts_with($filename, '.')) { + $fail('The :attribute cannot start with a dot (hidden files not allowed).'); + + return; + } + + // Check for valid characters only: alphanumeric, dashes, underscores, dots + if (! preg_match('/^[a-zA-Z0-9._-]+$/', $filename)) { + $fail('The :attribute may only contain letters, numbers, dashes, underscores, and dots.'); + + return; + } + + // Check for reserved filenames (case-sensitive for coolify.yaml/yml, case-insensitive check not needed as Caddyfile is exact) + if (in_array($filename, self::RESERVED_FILENAMES, true)) { + $fail('The :attribute uses a reserved filename.'); + + return; + } + } +} diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php index 4a17ecdd6..2be36d905 100644 --- a/app/Services/ContainerStatusAggregator.php +++ b/app/Services/ContainerStatusAggregator.php @@ -16,14 +16,23 @@ * UI components transform this to human-readable format (e.g., "Running (Healthy)"). * * State Priority (highest to lowest): - * 1. Restarting → degraded:unhealthy - * 2. Crash Loop (exited with restarts) → degraded:unhealthy - * 3. Mixed (running + exited) → degraded:unhealthy - * 4. Running → running:healthy/unhealthy/unknown - * 5. Dead/Removing → degraded:unhealthy - * 6. Paused → paused:unknown - * 7. Starting/Created → starting:unknown - * 8. Exited → exited + * 1. Degraded (from sub-resources) → degraded:unhealthy + * 2. Restarting → degraded:unhealthy (or restarting:unknown if preserveRestarting=true) + * 3. Crash Loop (exited with restarts) → degraded:unhealthy + * 4. Mixed (running + exited) → degraded:unhealthy + * 5. Mixed (running + starting) → starting:unknown + * 6. Running → running:healthy/unhealthy/unknown + * 7. Dead/Removing → degraded:unhealthy + * 8. Paused → paused:unknown + * 9. Starting/Created → starting:unknown + * 10. Exited → exited + * + * The $preserveRestarting parameter controls whether "restarting" containers should be + * reported as "restarting:unknown" (true) or "degraded:unhealthy" (false, default). + * - Use preserveRestarting=true for individual sub-resources (ServiceApplication/ServiceDatabase) + * so they show "Restarting" in the UI. + * - Use preserveRestarting=false for overall Service status aggregation where any restarting + * container should mark the entire service as "Degraded". */ class ContainerStatusAggregator { @@ -32,9 +41,10 @@ class ContainerStatusAggregator * * @param Collection $containerStatuses Collection of status strings (e.g., "running (healthy)", "running:healthy") * @param int $maxRestartCount Maximum restart count across containers (for crash loop detection) + * @param bool $preserveRestarting If true, "restarting" containers return "restarting:unknown" instead of "degraded:unhealthy" * @return string Aggregated status in colon format (e.g., "running:healthy") */ - public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0): string + public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0, bool $preserveRestarting = false): string { // Validate maxRestartCount parameter if ($maxRestartCount < 0) { @@ -64,10 +74,16 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest $hasStarting = false; $hasPaused = false; $hasDead = false; + $hasDegraded = false; // Parse each status string and set flags foreach ($containerStatuses as $status) { - if (str($status)->contains('restarting')) { + if (str($status)->contains('degraded')) { + $hasDegraded = true; + if (str($status)->contains('unhealthy')) { + $hasUnhealthy = true; + } + } elseif (str($status)->contains('restarting')) { $hasRestarting = true; } elseif (str($status)->contains('running')) { $hasRunning = true; @@ -98,7 +114,9 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest $hasStarting, $hasPaused, $hasDead, - $maxRestartCount + $hasDegraded, + $maxRestartCount, + $preserveRestarting ); } @@ -107,9 +125,10 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest * * @param Collection $containers Collection of Docker container objects with State property * @param int $maxRestartCount Maximum restart count across containers (for crash loop detection) + * @param bool $preserveRestarting If true, "restarting" containers return "restarting:unknown" instead of "degraded:unhealthy" * @return string Aggregated status in colon format (e.g., "running:healthy") */ - public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0): string + public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0, bool $preserveRestarting = false): string { // Validate maxRestartCount parameter if ($maxRestartCount < 0) { @@ -175,7 +194,9 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC $hasStarting, $hasPaused, $hasDead, - $maxRestartCount + false, // $hasDegraded - not applicable for container objects, only for status strings + $maxRestartCount, + $preserveRestarting ); } @@ -190,7 +211,9 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC * @param bool $hasStarting Has at least one starting/created container * @param bool $hasPaused Has at least one paused container * @param bool $hasDead Has at least one dead/removing container + * @param bool $hasDegraded Has at least one degraded container * @param int $maxRestartCount Maximum restart count (for crash loop detection) + * @param bool $preserveRestarting If true, return "restarting:unknown" instead of "degraded:unhealthy" for restarting containers * @return string Status in colon format (e.g., "running:healthy") */ private function resolveStatus( @@ -202,24 +225,40 @@ private function resolveStatus( bool $hasStarting, bool $hasPaused, bool $hasDead, - int $maxRestartCount + bool $hasDegraded, + int $maxRestartCount, + bool $preserveRestarting = false ): string { - // Priority 1: Restarting containers (degraded state) - if ($hasRestarting) { + // Priority 1: Degraded containers from sub-resources (highest priority) + // If any service/application within a service stack is degraded, the entire stack is degraded + if ($hasDegraded) { return 'degraded:unhealthy'; } - // Priority 2: Crash loop detection (exited with restart count > 0) + // Priority 2: Restarting containers + // When preserveRestarting is true (for individual sub-resources), keep as "restarting" + // When false (for overall service status), mark as "degraded" + if ($hasRestarting) { + return $preserveRestarting ? 'restarting:unknown' : 'degraded:unhealthy'; + } + + // Priority 3: Crash loop detection (exited with restart count > 0) if ($hasExited && $maxRestartCount > 0) { return 'degraded:unhealthy'; } - // Priority 3: Mixed state (some running, some exited = degraded) + // Priority 4: Mixed state (some running, some exited = degraded) if ($hasRunning && $hasExited) { return 'degraded:unhealthy'; } - // Priority 4: Running containers (check health status) + // Priority 5: Mixed state (some running, some starting = still starting) + // If any component is still starting, the entire service stack is not fully ready + if ($hasRunning && $hasStarting) { + return 'starting:unknown'; + } + + // Priority 6: Running containers (check health status) if ($hasRunning) { if ($hasUnhealthy) { return 'running:unhealthy'; @@ -230,22 +269,22 @@ private function resolveStatus( } } - // Priority 5: Dead or removing containers + // Priority 7: Dead or removing containers if ($hasDead) { return 'degraded:unhealthy'; } - // Priority 6: Paused containers + // Priority 8: Paused containers if ($hasPaused) { return 'paused:unknown'; } - // Priority 7: Starting/created containers + // Priority 9: Starting/created containers if ($hasStarting) { return 'starting:unknown'; } - // Priority 8: All containers exited (no restart count = truly stopped) + // Priority 10: All containers exited (no restart count = truly stopped) return 'exited'; } } diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index dd4d6e631..1de7eb2b1 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Exceptions\RateLimitException; use Illuminate\Support\Facades\Http; class HetznerService @@ -46,6 +47,19 @@ private function request(string $method, string $endpoint, array $data = []) ->{$method}($this->baseUrl.$endpoint, $data); if (! $response->successful()) { + if ($response->status() === 429) { + $retryAfter = $response->header('Retry-After'); + if ($retryAfter === null) { + $resetTime = $response->header('RateLimit-Reset'); + $retryAfter = $resetTime ? max(0, (int) $resetTime - time()) : null; + } + + throw new RateLimitException( + 'Rate limit exceeded. Please try again later.', + $retryAfter !== null ? (int) $retryAfter : null + ); + } + throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error')); } @@ -147,4 +161,30 @@ public function deleteServer(int $serverId): void { $this->request('delete', "/servers/{$serverId}"); } + + public function getServers(): array + { + return $this->requestPaginated('get', '/servers', 'servers'); + } + + public function findServerByIp(string $ip): ?array + { + $servers = $this->getServers(); + + foreach ($servers as $server) { + // Check IPv4 + $ipv4 = data_get($server, 'public_net.ipv4.ip'); + if ($ipv4 === $ip) { + return $server; + } + + // Check IPv6 (Hetzner returns the full /64 block) + $ipv6 = data_get($server, 'public_net.ipv6.ip'); + if ($ipv6 && str_starts_with($ip, rtrim($ipv6, '/'))) { + return $server; + } + } + + return null; + } } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 58ae5f249..a60a47b93 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -140,9 +140,15 @@ public function execute_remote_command(...$commands) // If we exhausted all retries and still failed if (! $commandExecuted && $lastError) { // Now we can set the status to FAILED since all retries have been exhausted + // But only if the deployment hasn't already been marked as FINISHED if (isset($this->application_deployment_queue)) { - $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value; - $this->application_deployment_queue->save(); + // Avoid clobbering a deployment that may have just been marked FINISHED + $this->application_deployment_queue->newQuery() + ->where('id', $this->application_deployment_queue->id) + ->where('status', '!=', ApplicationDeploymentStatus::FINISHED->value) + ->update([ + 'status' => ApplicationDeploymentStatus::FAILED->value, + ]); } throw $lastError; } diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php new file mode 100644 index 000000000..4a98e4a51 --- /dev/null +++ b/app/View/Components/Forms/EnvVarInput.php @@ -0,0 +1,94 @@ +canGate && $this->canResource && $this->autoDisable) { + $hasPermission = Gate::allows($this->canGate, $this->canResource); + + if (! $hasPermission) { + $this->disabled = true; + } + } + } + + public function render(): View|Closure|string + { + // Store original ID for wire:model binding (property name) + $this->modelBinding = $this->id; + + if (is_null($this->id)) { + $this->id = new Cuid2; + // Don't create wire:model binding for auto-generated IDs + $this->modelBinding = 'null'; + } + // Generate unique HTML ID by adding random suffix + // This prevents duplicate IDs when multiple forms are on the same page + if ($this->modelBinding && $this->modelBinding !== 'null') { + // Use original ID with random suffix for uniqueness + $uniqueSuffix = new Cuid2; + $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; + } else { + $this->htmlId = (string) $this->id; + } + + if (is_null($this->name)) { + $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id; + } + + if ($this->type === 'password') { + $this->defaultClass = $this->defaultClass.' pr-[2.8rem]'; + } + + $this->scopeUrls = [ + 'team' => route('shared-variables.team.index'), + 'project' => route('shared-variables.project.index'), + 'environment' => $this->projectUuid && $this->environmentUuid + ? route('shared-variables.environment.show', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + ]) + : route('shared-variables.environment.index'), + 'default' => route('shared-variables.index'), + ]; + + return view('components.forms.env-var-input'); + } +} diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 488653fb1..84bde5393 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -178,4 +178,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('use_build_server'); $request->offsetUnset('is_static'); $request->offsetUnset('force_domain_override'); + $request->offsetUnset('autogenerate_domain'); } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index db7767c1e..03c53989c 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -28,6 +28,20 @@ function queue_application_deployment(Application $application, string $deployme $destination_id = $destination->id; } + // Check if the deployment queue is full for this server + $serverForQueueCheck = $server ?? Server::find($server_id); + $queue_limit = $serverForQueueCheck->settings->deployment_queue_limit ?? 25; + $queued_count = ApplicationDeploymentQueue::where('server_id', $server_id) + ->where('status', ApplicationDeploymentStatus::QUEUED->value) + ->count(); + + if ($queued_count >= $queue_limit) { + return [ + 'status' => 'queue_full', + 'message' => 'Deployment queue is full. Please wait for existing deployments to complete.', + ]; + } + // Check if there's already a deployment in progress or queued for this application and commit $existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id) ->where('commit', $commit) @@ -68,10 +82,16 @@ function queue_application_deployment(Application $application, string $deployme ]); if ($no_questions_asked) { + $deployment->update([ + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); ApplicationDeploymentJob::dispatch( application_deployment_queue_id: $deployment->id, ); } elseif (next_queuable($server_id, $application_id, $commit, $pull_request_id)) { + $deployment->update([ + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); ApplicationDeploymentJob::dispatch( application_deployment_queue_id: $deployment->id, ); diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index f588b6c00..114c4bb98 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -48,6 +48,8 @@ 'influxdb', 'clickhouse/clickhouse-server', 'timescaledb/timescaledb', + 'timescaledb', // Matches timescale/timescaledb + 'timescaledb-ha', // Matches timescale/timescaledb-ha 'pgvector/pgvector', ]; const SPECIFIC_SERVICES = [ @@ -56,6 +58,7 @@ 'ghcr.io/coollabsio/minio', 'coollabsio/minio', 'svhd/logto', + 'dxflrs/garage', ]; // Based on /etc/os-release @@ -67,4 +70,14 @@ 'alpine', ]; +const NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK = [ + 'pgadmin', + 'postgresus', +]; +const NEEDS_TO_DISABLE_GZIP = [ + 'beszel' => ['beszel'], +]; +const NEEDS_TO_DISABLE_STRIPPREFIX = [ + 'appwrite' => ['appwrite', 'appwrite-console', 'appwrite-realtime'], +]; const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment']; diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index c4d77979f..a0f810480 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -312,6 +312,36 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource) $LOGTO_ADMIN_ENDPOINT->value.':3002', ]); break; + case $type?->contains('garage'): + $GARAGE_S3_API_URL = $variables->where('key', 'GARAGE_S3_API_URL')->first(); + $GARAGE_WEB_URL = $variables->where('key', 'GARAGE_WEB_URL')->first(); + $GARAGE_ADMIN_URL = $variables->where('key', 'GARAGE_ADMIN_URL')->first(); + + if (is_null($GARAGE_S3_API_URL) || is_null($GARAGE_WEB_URL) || is_null($GARAGE_ADMIN_URL)) { + return collect([]); + } + + if (str($GARAGE_S3_API_URL->value ?? '')->isEmpty()) { + $GARAGE_S3_API_URL->update([ + 'value' => generateUrl(server: $server, random: 's3-'.$uuid, forceHttps: true), + ]); + } + if (str($GARAGE_WEB_URL->value ?? '')->isEmpty()) { + $GARAGE_WEB_URL->update([ + 'value' => generateUrl(server: $server, random: 'web-'.$uuid, forceHttps: true), + ]); + } + if (str($GARAGE_ADMIN_URL->value ?? '')->isEmpty()) { + $GARAGE_ADMIN_URL->update([ + 'value' => generateUrl(server: $server, random: 'admin-'.$uuid, forceHttps: true), + ]); + } + $payload = collect([ + $GARAGE_S3_API_URL->value.':3900', + $GARAGE_WEB_URL->value.':3902', + $GARAGE_ADMIN_URL->value.':3903', + ]); + break; } return $payload; @@ -770,10 +800,26 @@ function isDatabaseImage(?string $image = null, ?array $serviceConfig = null) } $imageName = $image->before(':'); - // First check if it's a known database image + // Extract base image name (ignore registry prefix) + // Examples: + // docker.io/library/postgres -> postgres + // ghcr.io/postgrest/postgrest -> postgrest + // postgres -> postgres + // postgrest/postgrest -> postgrest + $baseImageName = $imageName; + if (str($imageName)->contains('/')) { + $baseImageName = str($imageName)->afterLast('/'); + } + + // Check if base image name exactly matches a known database image $isKnownDatabase = false; foreach (DATABASE_DOCKER_IMAGES as $database_docker_image) { - if (str($imageName)->contains($database_docker_image)) { + // Extract base name from database pattern for comparison + $databaseBaseName = str($database_docker_image)->contains('/') + ? str($database_docker_image)->afterLast('/') + : $database_docker_image; + + if ($baseImageName == $databaseBaseName) { $isKnownDatabase = true; break; } @@ -962,6 +1008,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) '--shm-size' => 'shm_size', '--gpus' => 'gpus', '--hostname' => 'hostname', + '--entrypoint' => 'entrypoint', ]); foreach ($matches as $match) { $option = $match[1]; @@ -982,6 +1029,38 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) $options[$option] = array_unique($options[$option]); } } + if ($option === '--entrypoint') { + $value = null; + // Match --entrypoint=value or --entrypoint value + // Handle quoted strings with escaped quotes: --entrypoint "python -c \"print('hi')\"" + // Pattern matches: double-quoted (with escapes), single-quoted (with escapes), or unquoted values + if (preg_match( + '/--entrypoint(?:=|\s+)(?"(?:\\\\.|[^"])*"|\'(?:\\\\.|[^\'])*\'|[^\s]+)/', + $custom_docker_run_options, + $entrypoint_matches + )) { + $rawValue = $entrypoint_matches['raw']; + // Handle double-quoted strings: strip quotes and unescape special characters + if (str_starts_with($rawValue, '"') && str_ends_with($rawValue, '"')) { + $inner = substr($rawValue, 1, -1); + // Unescape backslash sequences: \" \$ \` \\ + $value = preg_replace('/\\\\(["$`\\\\])/', '$1', $inner); + } elseif (str_starts_with($rawValue, "'") && str_ends_with($rawValue, "'")) { + // Handle single-quoted strings: just strip quotes (no unescaping per shell rules) + $value = substr($rawValue, 1, -1); + } else { + // Handle unquoted values + $value = $rawValue; + } + } + + if ($value && trim($value) !== '') { + $options[$option][] = $value; + $options[$option] = array_values(array_unique($options[$option])); + } + + continue; + } if (isset($match[2]) && $match[2] !== '') { $value = $match[2]; $options[$option][] = $value; @@ -1022,6 +1101,12 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) { $compose_options->put($mapping[$option], $value[0]); } + } elseif ($option === '--entrypoint') { + if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) { + // Docker compose accepts entrypoint as either a string or an array + // Keep it as a string for simplicity - docker compose will handle it + $compose_options->put($mapping[$option], $value[0]); + } } elseif ($option === '--gpus') { $payload = [ 'driver' => 'nvidia', @@ -1337,3 +1422,62 @@ function injectDockerComposeFlags(string $command, string $composeFilePath, stri // Replace only first occurrence to avoid modifying comments/strings/chained commands return preg_replace('/docker\s+compose/', $dockerComposeReplacement, $command, 1); } + +/** + * Inject build arguments right after build-related subcommands in docker/docker compose commands. + * This ensures build args are only applied to build operations, not to push, pull, up, etc. + * + * Supports: + * - docker compose build + * - docker buildx build + * - docker builder build + * - docker build (legacy) + * + * Examples: + * - Input: "docker compose -f file.yml build" + * Output: "docker compose -f file.yml build --build-arg X --build-arg Y" + * + * - Input: "docker buildx build --platform linux/amd64" + * Output: "docker buildx build --build-arg X --build-arg Y --platform linux/amd64" + * + * - Input: "docker builder build --tag myimage:latest" + * Output: "docker builder build --build-arg X --build-arg Y --tag myimage:latest" + * + * - Input: "docker compose build && docker compose push" + * Output: "docker compose build --build-arg X --build-arg Y && docker compose push" + * + * - Input: "docker compose push" + * Output: "docker compose push" (unchanged - no build command found) + * + * @param string $command The docker command + * @param string $buildArgsString The build arguments to inject (e.g., "--build-arg X --build-arg Y") + * @return string The modified command with build args injected after build subcommand + */ +function injectDockerComposeBuildArgs(string $command, string $buildArgsString): string +{ + // Early return if no build args to inject + if (empty(trim($buildArgsString))) { + return $command; + } + + // Match build-related commands: + // - ' builder build' (docker builder build) + // - ' buildx build' (docker buildx build) + // - ' build' (docker compose build, docker build) + // Followed by either: + // - whitespace (allowing service names, flags, or any valid arguments) + // - end of string ($) + // This regex ensures we match build subcommands, not "build" in other contexts + // IMPORTANT: Order matters - check longer patterns first (builder build, buildx build) before ' build' + $pattern = '/( builder build| buildx build| build)(?=\s|$)/'; + + // Replace the first occurrence of build command with build command + build-args + $modifiedCommand = preg_replace( + $pattern, + '$1 '.$buildArgsString, + $command, + 1 // Only replace first occurrence + ); + + return $modifiedCommand ?? $command; +} diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index dfcc3e190..43ba58e59 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -358,7 +358,7 @@ function parseDockerVolumeString(string $volumeString): array ]; } -function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection +function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null): Collection { $uuid = data_get($resource, 'uuid'); $compose = data_get($resource, 'docker_compose_raw'); @@ -1145,11 +1145,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $template = $resource->preview_url_template; $host = $url->getHost(); $schema = $url->getScheme(); + $portInt = $url->getPort(); + $port = $portInt !== null ? ':'.$portInt : ''; $random = new Cuid2; $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; + $preview_fqdn = "$schema://$preview_fqdn{$port}"; $preview->fqdn = $preview_fqdn; $preview->save(); @@ -1324,6 +1326,20 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ->values(); $payload['env_file'] = $envFiles; + + // Inject commit-based image tag for services with build directive (for rollback support) + // Only inject if service has build but no explicit image defined + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + if ($isPullRequest) { + $imageTag = "pr-{$pullRequestId}"; + } + $imageRepo = "{$uuid}_{$serviceName}"; + $payload['image'] = "{$imageRepo}:{$imageTag}"; + } + if ($isPullRequest) { $serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId); } @@ -1644,9 +1660,16 @@ function serviceParser(Service $resource): Collection if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) { $path = $value->value(); if ($path !== '/') { - $fqdn = "$fqdn$path"; - $url = "$url$path"; - $fqdnValueForEnv = "$fqdnValueForEnv$path"; + // Only add path if it's not already present (prevents duplication on subsequent parse() calls) + if (! str($fqdn)->endsWith($path)) { + $fqdn = "$fqdn$path"; + } + if (! str($url)->endsWith($path)) { + $url = "$url$path"; + } + if (! str($fqdnValueForEnv)->endsWith($path)) { + $fqdnValueForEnv = "$fqdnValueForEnv$path"; + } } } diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 08fad4958..ac52c0af8 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -6,6 +6,20 @@ use App\Models\Server; use Symfony\Component\Yaml\Yaml; +/** + * Check if a network name is a Docker predefined system network. + * These networks cannot be created, modified, or managed by docker network commands. + * + * @param string $network Network name to check + * @return bool True if it's a predefined network that should be skipped + */ +function isDockerPredefinedNetwork(string $network): bool +{ + // Only filter 'default' and 'host' to match existing codebase patterns + // See: bootstrap/helpers/parsers.php:891, bootstrap/helpers/shared.php:689,748 + return in_array($network, ['default', 'host'], true); +} + function collectProxyDockerNetworksByServer(Server $server) { if (! $server->isFunctional()) { @@ -66,8 +80,12 @@ function collectDockerNetworksByServer(Server $server) $networks->push($network); $allNetworks->push($network); } - $networks = collect($networks)->flatten()->unique(); - $allNetworks = $allNetworks->flatten()->unique(); + $networks = collect($networks)->flatten()->unique()->filter(function ($network) { + return ! isDockerPredefinedNetwork($network); + }); + $allNetworks = $allNetworks->flatten()->unique()->filter(function ($network) { + return ! isDockerPredefinedNetwork($network); + }); if ($server->isSwarm()) { if ($networks->count() === 0) { $networks = collect(['coolify-overlay']); @@ -108,6 +126,37 @@ function connectProxyToNetworks(Server $server) return $commands->flatten(); } + +/** + * Ensures all required networks exist before docker compose up. + * This must be called BEFORE docker compose up since the compose file declares networks as external. + * + * @param Server $server The server to ensure networks on + * @return \Illuminate\Support\Collection Commands to create networks if they don't exist + */ +function ensureProxyNetworksExist(Server $server) +{ + ['allNetworks' => $networks] = collectDockerNetworksByServer($server); + + if ($server->isSwarm()) { + $commands = $networks->map(function ($network) { + return [ + "echo 'Ensuring network $network exists...'", + "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network", + ]; + }); + } else { + $commands = $networks->map(function ($network) { + return [ + "echo 'Ensuring network $network exists...'", + "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network", + ]; + }); + } + + return $commands->flatten(); +} + function extractCustomProxyCommands(Server $server, string $existing_config): array { $custom_commands = []; @@ -188,8 +237,8 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command $array_of_networks = collect([]); $filtered_networks = collect([]); $networks->map(function ($network) use ($array_of_networks, $filtered_networks) { - if ($network === 'host') { - return; // network-scoped alias is supported only for containers in user defined networks + if (isDockerPredefinedNetwork($network)) { + return; // Predefined networks cannot be used in network configuration } $array_of_networks[$network] = [ diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 3218bf878..bdfbaba48 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -118,7 +118,7 @@ function () use ($server, $command_string) { ); } -function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string +function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false, ?int $timeout = null, bool $disableMultiplexing = false): ?string { $command = $command instanceof Collection ? $command->toArray() : $command; @@ -126,11 +126,12 @@ function instant_remote_process(Collection|array $command, Server $server, bool $command = parseCommandsByLineForSudo(collect($command), $server); } $command_string = implode("\n", $command); + $effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout'); return \App\Helpers\SshRetryHandler::retry( - function () use ($server, $command_string) { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); - $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand); + function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) { + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing); + $process = Process::timeout($effectiveTimeout)->run($sshCommand); $output = trim($process->output()); $exitCode = $process->exitCode(); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 3fff2c090..3d2b61b86 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -4,6 +4,7 @@ use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Stringable; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; @@ -339,3 +340,54 @@ function parseServiceEnvironmentVariable(string $key): array 'has_port' => $hasPort, ]; } + +/** + * Apply service-specific application prerequisites after service parse. + * + * This function configures application-level settings that are required for + * specific one-click services to work correctly (e.g., disabling gzip for Beszel, + * disabling strip prefix for Appwrite services). + * + * Must be called AFTER $service->parse() since it requires applications to exist. + * + * @param Service $service The service to apply prerequisites to + */ +function applyServiceApplicationPrerequisites(Service $service): void +{ + try { + // Extract service name from service name (format: "servicename-uuid") + $serviceName = str($service->name)->beforeLast('-')->value(); + + // Apply gzip disabling if needed + if (array_key_exists($serviceName, NEEDS_TO_DISABLE_GZIP)) { + $applicationNames = NEEDS_TO_DISABLE_GZIP[$serviceName]; + foreach ($applicationNames as $applicationName) { + $application = $service->applications()->whereName($applicationName)->first(); + if ($application) { + $application->is_gzip_enabled = false; + $application->save(); + } + } + } + + // Apply stripprefix disabling if needed + if (array_key_exists($serviceName, NEEDS_TO_DISABLE_STRIPPREFIX)) { + $applicationNames = NEEDS_TO_DISABLE_STRIPPREFIX[$serviceName]; + foreach ($applicationNames as $applicationName) { + $application = $service->applications()->whereName($applicationName)->first(); + if ($application) { + $application->is_stripprefix_enabled = false; + $application->save(); + } + } + } + } catch (\Throwable $e) { + // Log error but don't throw - prerequisites are nice-to-have, not critical + Log::error('Failed to apply service application prerequisites', [ + 'service_id' => $service->id, + 'service_name' => $service->name, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } +} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 8a278476e..670716164 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -33,6 +33,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\RateLimiter; @@ -230,7 +231,7 @@ function get_route_parameters(): array function get_latest_sentinel_version(): string { try { - $response = Http::get('https://cdn.coollabs.io/coolify/versions.json'); + $response = Http::get(config('constants.coolify.versions_url')); $versions = $response->json(); return data_get($versions, 'coolify.sentinel.version'); @@ -300,6 +301,24 @@ function generate_application_name(string $git_repository, string $git_branch, ? return Str::kebab("$git_repository:$git_branch-$cuid"); } +/** + * Sort branches by priority: main first, master second, then alphabetically. + * + * @param Collection $branches Collection of branch objects with 'name' key + */ +function sortBranchesByPriority(Collection $branches): Collection +{ + return $branches->sortBy(function ($branch) { + $name = data_get($branch, 'name'); + + return match ($name) { + 'main' => '0_main', + 'master' => '1_master', + default => '2_'.$name, + }; + })->values(); +} + function base_ip(): string { if (isDev()) { @@ -3154,6 +3173,118 @@ function generateDockerComposeServiceName(mixed $services, int $pullRequestId = return $collection; } +function formatBytes(?int $bytes, int $precision = 2): string +{ + if ($bytes === null || $bytes === 0) { + return '0 B'; + } + + // Handle negative numbers + if ($bytes < 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + $base = 1024; + $exponent = floor(log($bytes) / log($base)); + $exponent = min($exponent, count($units) - 1); + + $value = $bytes / pow($base, $exponent); + + return round($value, $precision).' '.$units[$exponent]; +} + +/** + * Validates that a file path is safely within the /tmp/ directory. + * Protects against path traversal attacks by resolving the real path + * and verifying it stays within /tmp/. + * + * Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled. + */ +function isSafeTmpPath(?string $path): bool +{ + if (blank($path)) { + return false; + } + + // URL decode to catch encoded traversal attempts + $decodedPath = urldecode($path); + + // Minimum length check - /tmp/x is 6 chars + if (strlen($decodedPath) < 6) { + return false; + } + + // Must start with /tmp/ + if (! str($decodedPath)->startsWith('/tmp/')) { + return false; + } + + // Quick check for obvious traversal attempts + if (str($decodedPath)->contains('..')) { + return false; + } + + // Check for null bytes (directory traversal technique) + if (str($decodedPath)->contains("\0")) { + return false; + } + + // Remove any trailing slashes for consistent validation + $normalizedPath = rtrim($decodedPath, '/'); + + // Normalize the path by removing redundant separators and resolving . and .. + // We'll do this manually since realpath() requires the path to exist + $parts = explode('/', $normalizedPath); + $resolvedParts = []; + + foreach ($parts as $part) { + if ($part === '' || $part === '.') { + // Skip empty parts (from //) and current directory references + continue; + } elseif ($part === '..') { + // Parent directory - this should have been caught earlier but double-check + return false; + } else { + $resolvedParts[] = $part; + } + } + + $resolvedPath = '/'.implode('/', $resolvedParts); + + // Final check: resolved path must start with /tmp/ + // And must have at least one component after /tmp/ + if (! str($resolvedPath)->startsWith('/tmp/') || $resolvedPath === '/tmp') { + return false; + } + + // Resolve the canonical /tmp path (handles symlinks like /tmp -> /private/tmp on macOS) + $canonicalTmpPath = realpath('/tmp'); + if ($canonicalTmpPath === false) { + // If /tmp doesn't exist, something is very wrong, but allow non-existing paths + $canonicalTmpPath = '/tmp'; + } + + // Calculate dirname once to avoid redundant calls + $dirPath = dirname($resolvedPath); + + // If the directory exists, resolve it via realpath to catch symlink attacks + if (is_dir($dirPath)) { + // For existing paths, resolve to absolute path to catch symlinks + $realDir = realpath($dirPath); + if ($realDir === false) { + return false; + } + + // Check if the real directory is within /tmp (or its canonical path) + if (! str($realDir)->startsWith('/tmp') && ! str($realDir)->startsWith($canonicalTmpPath)) { + return false; + } + } + + return true; +} + /** * Transform colon-delimited status format to human-readable parentheses format. * @@ -3196,3 +3327,57 @@ function formatContainerStatus(string $status): string return str($status)->headline()->value(); } } + +/** + * Check if password confirmation should be skipped. + * Returns true if: + * - Two-step confirmation is globally disabled + * - User has no password (OAuth users) + * + * Used by modal-confirmation.blade.php to determine if password step should be shown. + * + * @return bool True if password confirmation should be skipped + */ +function shouldSkipPasswordConfirmation(): bool +{ + // Skip if two-step confirmation is globally disabled + if (data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { + return true; + } + + // Skip if user has no password (OAuth users) + if (! Auth::user()?->hasPassword()) { + return true; + } + + return false; +} + +/** + * Verify password for two-step confirmation. + * Skips verification if: + * - Two-step confirmation is globally disabled + * - User has no password (OAuth users) + * + * @param mixed $password The password to verify (may be array if skipped by frontend) + * @param \Livewire\Component|null $component Optional Livewire component to add errors to + * @return bool True if verification passed (or skipped), false if password is incorrect + */ +function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $component = null): bool +{ + // Skip if password confirmation should be skipped + if (shouldSkipPasswordConfirmation()) { + return true; + } + + // Verify the password + if (! Hash::check($password, Auth::user()->password)) { + if ($component) { + $component->addError('password', 'The provided password is incorrect.'); + } + + return false; + } + + return true; +} diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 48c3a62c3..1a0ae0fbd 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -5,39 +5,44 @@ function isSubscriptionActive() { - if (! isCloud()) { - return false; - } - $team = currentTeam(); - if (! $team) { - return false; - } - $subscription = $team?->subscription; + return once(function () { + if (! isCloud()) { + return false; + } + $team = currentTeam(); + if (! $team) { + return false; + } + $subscription = $team?->subscription; - if (is_null($subscription)) { - return false; - } - if (isStripe()) { - return $subscription->stripe_invoice_paid === true; - } + if (is_null($subscription)) { + return false; + } + if (isStripe()) { + return $subscription->stripe_invoice_paid === true; + } - return false; + return false; + }); } + function isSubscriptionOnGracePeriod() { - $team = currentTeam(); - if (! $team) { - return false; - } - $subscription = $team?->subscription; - if (! $subscription) { - return false; - } - if (isStripe()) { - return $subscription->stripe_cancel_at_period_end; - } + return once(function () { + $team = currentTeam(); + if (! $team) { + return false; + } + $subscription = $team?->subscription; + if (! $subscription) { + return false; + } + if (isStripe()) { + return $subscription->stripe_cancel_at_period_end; + } - return false; + return false; + }); } function subscriptionProvider() { diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php index f7336beeb..b8ef84687 100644 --- a/bootstrap/helpers/sudo.php +++ b/bootstrap/helpers/sudo.php @@ -23,24 +23,56 @@ function shouldChangeOwnership(string $path): bool function parseCommandsByLineForSudo(Collection $commands, Server $server): array { $commands = $commands->map(function ($line) { - if ( - ! str(trim($line))->startsWith([ - 'cd', - 'command', - 'echo', - 'true', - 'if', - 'fi', - ]) - ) { - return "sudo $line"; + $trimmedLine = trim($line); + + // All bash keywords that should not receive sudo prefix + // Using word boundary matching to avoid prefix collisions (e.g., 'do' vs 'docker', 'if' vs 'ifconfig', 'fi' vs 'find') + $bashKeywords = [ + 'cd', + 'command', + 'declare', + 'echo', + 'export', + 'local', + 'readonly', + 'return', + 'true', + 'if', + 'fi', + 'for', + 'done', + 'while', + 'until', + 'case', + 'esac', + 'select', + 'then', + 'else', + 'elif', + 'break', + 'continue', + 'do', + ]; + + // Special case: comments (no collision risk with '#') + if (str_starts_with($trimmedLine, '#')) { + return $line; } - if (str(trim($line))->startsWith('if')) { - return str_replace('if', 'if sudo', $line); + // Check all keywords with word boundary matching + // Match keyword followed by space, semicolon, or end of line + foreach ($bashKeywords as $keyword) { + if (preg_match('/^'.preg_quote($keyword, '/').'(\s|;|$)/', $trimmedLine)) { + // Special handling for 'if' - insert sudo after 'if ' + if ($keyword === 'if') { + return preg_replace('/^(\s*)if\s+/', '$1if sudo ', $line); + } + + return $line; + } } - return $line; + return "sudo $line"; }); $commands = $commands->map(function ($line) use ($server) { diff --git a/config/constants.php b/config/constants.php index 161d1e2fd..d9734c48e 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.446', + 'version' => '4.0.0-beta.455', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), @@ -12,6 +12,9 @@ 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'), 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), + 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), + 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), + 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), 'releases_url' => 'https://cdn.coolify.io/releases.json', ], diff --git a/config/filesystems.php b/config/filesystems.php index c2df26c84..ba0921a79 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -35,13 +35,6 @@ 'throw' => false, ], - 'webhooks-during-maintenance' => [ - 'driver' => 'local', - 'root' => storage_path('app/webhooks-during-maintenance'), - 'visibility' => 'private', - 'throw' => false, - ], - 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/config/session.php b/config/session.php index 44ca7ded9..c7b176a5a 100644 --- a/config/session.php +++ b/config/session.php @@ -18,7 +18,7 @@ | */ - 'driver' => env('SESSION_DRIVER', 'database'), + 'driver' => env('SESSION_DRIVER', 'redis'), /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php index 2c92b0e19..a9c59cbc3 100644 --- a/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php +++ b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php @@ -11,16 +11,18 @@ */ public function up(): void { - Schema::create('cloud_provider_tokens', function (Blueprint $table) { - $table->id(); - $table->foreignId('team_id')->constrained()->onDelete('cascade'); - $table->string('provider'); - $table->text('token'); - $table->string('name')->nullable(); - $table->timestamps(); + if (! Schema::hasTable('cloud_provider_tokens')) { + Schema::create('cloud_provider_tokens', function (Blueprint $table) { + $table->id(); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->string('provider'); + $table->text('token'); + $table->string('name')->nullable(); + $table->timestamps(); - $table->index(['team_id', 'provider']); - }); + $table->index(['team_id', 'provider']); + }); + } } /** diff --git a/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php index b1c9ec48b..b5cae7d32 100644 --- a/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php +++ b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('servers', function (Blueprint $table) { - $table->bigInteger('hetzner_server_id')->nullable()->after('id'); - }); + if (! Schema::hasColumn('servers', 'hetzner_server_id')) { + Schema::table('servers', function (Blueprint $table) { + $table->bigInteger('hetzner_server_id')->nullable()->after('id'); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('servers', function (Blueprint $table) { - $table->dropColumn('hetzner_server_id'); - }); + if (Schema::hasColumn('servers', 'hetzner_server_id')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('hetzner_server_id'); + }); + } } }; diff --git a/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php index a25a4ce83..9f23a7ee9 100644 --- a/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php +++ b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('servers', function (Blueprint $table) { - $table->foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null'); - }); + if (! Schema::hasColumn('servers', 'cloud_provider_token_id')) { + Schema::table('servers', function (Blueprint $table) { + $table->foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null'); + }); + } } /** @@ -21,9 +23,11 @@ public function up(): void */ public function down(): void { - Schema::table('servers', function (Blueprint $table) { - $table->dropForeign(['cloud_provider_token_id']); - $table->dropColumn('cloud_provider_token_id'); - }); + if (Schema::hasColumn('servers', 'cloud_provider_token_id')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropForeign(['cloud_provider_token_id']); + $table->dropColumn('cloud_provider_token_id'); + }); + } } }; diff --git a/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php b/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php index d94c9c76f..54a0a37ba 100644 --- a/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php +++ b/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('servers', function (Blueprint $table) { - $table->string('hetzner_server_status')->nullable()->after('hetzner_server_id'); - }); + if (! Schema::hasColumn('servers', 'hetzner_server_status')) { + Schema::table('servers', function (Blueprint $table) { + $table->string('hetzner_server_status')->nullable()->after('hetzner_server_id'); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('servers', function (Blueprint $table) { - $table->dropColumn('hetzner_server_status'); - }); + if (Schema::hasColumn('servers', 'hetzner_server_status')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('hetzner_server_status'); + }); + } } }; diff --git a/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php b/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php index ddb655d2c..b1309713d 100644 --- a/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php +++ b/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('servers', function (Blueprint $table) { - $table->boolean('is_validating')->default(false)->after('hetzner_server_status'); - }); + if (! Schema::hasColumn('servers', 'is_validating')) { + Schema::table('servers', function (Blueprint $table) { + $table->boolean('is_validating')->default(false)->after('hetzner_server_status'); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('servers', function (Blueprint $table) { - $table->dropColumn('is_validating'); - }); + if (Schema::hasColumn('servers', 'is_validating')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('is_validating'); + }); + } } }; diff --git a/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php b/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php deleted file mode 100644 index de2707557..000000000 --- a/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php +++ /dev/null @@ -1,47 +0,0 @@ -get(); - - foreach ($teams as $team) { - DB::table('webhook_notification_settings')->updateOrInsert( - ['team_id' => $team->id], - [ - 'webhook_enabled' => false, - 'webhook_url' => null, - 'deployment_success_webhook_notifications' => false, - 'deployment_failure_webhook_notifications' => true, - 'status_change_webhook_notifications' => false, - 'backup_success_webhook_notifications' => false, - 'backup_failure_webhook_notifications' => true, - 'scheduled_task_success_webhook_notifications' => false, - 'scheduled_task_failure_webhook_notifications' => true, - 'docker_cleanup_success_webhook_notifications' => false, - 'docker_cleanup_failure_webhook_notifications' => true, - 'server_disk_usage_webhook_notifications' => true, - 'server_reachable_webhook_notifications' => false, - 'server_unreachable_webhook_notifications' => true, - 'server_patch_webhook_notifications' => false, - ] - ); - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - // We don't need to do anything in down() since the webhook_notification_settings - // table will be dropped by the create migration's down() method - } -}; diff --git a/database/migrations/2025_10_10_120002_create_cloud_init_scripts_table.php b/database/migrations/2025_10_10_120002_create_cloud_init_scripts_table.php deleted file mode 100644 index ae63dc53a..000000000 --- a/database/migrations/2025_10_10_120002_create_cloud_init_scripts_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->foreignId('team_id')->constrained()->onDelete('cascade'); - $table->string('name'); - $table->text('script'); // Encrypted in the model - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('cloud_init_scripts'); - } -}; diff --git a/database/migrations/2025_10_10_120002_create_webhook_notification_settings_table.php b/database/migrations/2025_10_10_120002_create_webhook_notification_settings_table.php deleted file mode 100644 index c0689f81e..000000000 --- a/database/migrations/2025_10_10_120002_create_webhook_notification_settings_table.php +++ /dev/null @@ -1,52 +0,0 @@ -id(); - $table->foreignId('team_id')->constrained()->cascadeOnDelete(); - - $table->boolean('webhook_enabled')->default(false); - $table->text('webhook_url')->nullable(); - - $table->boolean('deployment_success_webhook_notifications')->default(false); - $table->boolean('deployment_failure_webhook_notifications')->default(true); - $table->boolean('status_change_webhook_notifications')->default(false); - $table->boolean('backup_success_webhook_notifications')->default(false); - $table->boolean('backup_failure_webhook_notifications')->default(true); - $table->boolean('scheduled_task_success_webhook_notifications')->default(false); - $table->boolean('scheduled_task_failure_webhook_notifications')->default(true); - $table->boolean('docker_cleanup_success_webhook_notifications')->default(false); - $table->boolean('docker_cleanup_failure_webhook_notifications')->default(true); - $table->boolean('server_disk_usage_webhook_notifications')->default(true); - $table->boolean('server_reachable_webhook_notifications')->default(false); - $table->boolean('server_unreachable_webhook_notifications')->default(true); - $table->boolean('server_patch_webhook_notifications')->default(false); - - $table->unique(['team_id']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('webhook_notification_settings'); - } -}; diff --git a/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php b/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php index 56ed2239a..f968d2926 100644 --- a/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php +++ b/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('instance_settings', function (Blueprint $table) { - $table->string('dev_helper_version')->nullable(); - }); + if (! Schema::hasColumn('instance_settings', 'dev_helper_version')) { + Schema::table('instance_settings', function (Blueprint $table) { + $table->string('dev_helper_version')->nullable(); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('instance_settings', function (Blueprint $table) { - $table->dropColumn('dev_helper_version'); - }); + if (Schema::hasColumn('instance_settings', 'dev_helper_version')) { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('dev_helper_version'); + }); + } } }; diff --git a/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php b/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php index 067861e16..59223a506 100644 --- a/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php +++ b/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('scheduled_tasks', function (Blueprint $table) { - $table->integer('timeout')->default(300)->after('frequency'); - }); + if (! Schema::hasColumn('scheduled_tasks', 'timeout')) { + Schema::table('scheduled_tasks', function (Blueprint $table) { + $table->integer('timeout')->default(300)->after('frequency'); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('scheduled_tasks', function (Blueprint $table) { - $table->dropColumn('timeout'); - }); + if (Schema::hasColumn('scheduled_tasks', 'timeout')) { + Schema::table('scheduled_tasks', function (Blueprint $table) { + $table->dropColumn('timeout'); + }); + } } }; diff --git a/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php b/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php index 14fdd5998..ff45b1fcf 100644 --- a/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php +++ b/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php @@ -11,12 +11,29 @@ */ public function up(): void { - Schema::table('scheduled_task_executions', function (Blueprint $table) { - $table->timestamp('started_at')->nullable()->after('scheduled_task_id'); - $table->integer('retry_count')->default(0)->after('status'); - $table->decimal('duration', 10, 2)->nullable()->after('retry_count')->comment('Duration in seconds'); - $table->text('error_details')->nullable()->after('message'); - }); + if (! Schema::hasColumn('scheduled_task_executions', 'started_at')) { + Schema::table('scheduled_task_executions', function (Blueprint $table) { + $table->timestamp('started_at')->nullable()->after('scheduled_task_id'); + }); + } + + if (! Schema::hasColumn('scheduled_task_executions', 'retry_count')) { + Schema::table('scheduled_task_executions', function (Blueprint $table) { + $table->integer('retry_count')->default(0)->after('status'); + }); + } + + if (! Schema::hasColumn('scheduled_task_executions', 'duration')) { + Schema::table('scheduled_task_executions', function (Blueprint $table) { + $table->decimal('duration', 10, 2)->nullable()->after('retry_count')->comment('Duration in seconds'); + }); + } + + if (! Schema::hasColumn('scheduled_task_executions', 'error_details')) { + Schema::table('scheduled_task_executions', function (Blueprint $table) { + $table->text('error_details')->nullable()->after('message'); + }); + } } /** @@ -24,8 +41,13 @@ public function up(): void */ public function down(): void { - Schema::table('scheduled_task_executions', function (Blueprint $table) { - $table->dropColumn(['started_at', 'retry_count', 'duration', 'error_details']); - }); + $columns = ['started_at', 'retry_count', 'duration', 'error_details']; + foreach ($columns as $column) { + if (Schema::hasColumn('scheduled_task_executions', $column)) { + Schema::table('scheduled_task_executions', function (Blueprint $table) use ($column) { + $table->dropColumn($column); + }); + } + } } }; diff --git a/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php b/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php index 329ac7af9..b9dfd4d9d 100644 --- a/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php +++ b/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php @@ -11,11 +11,23 @@ */ public function up(): void { - Schema::table('applications', function (Blueprint $table) { - $table->integer('restart_count')->default(0)->after('status'); - $table->timestamp('last_restart_at')->nullable()->after('restart_count'); - $table->string('last_restart_type', 10)->nullable()->after('last_restart_at'); - }); + if (! Schema::hasColumn('applications', 'restart_count')) { + Schema::table('applications', function (Blueprint $table) { + $table->integer('restart_count')->default(0)->after('status'); + }); + } + + if (! Schema::hasColumn('applications', 'last_restart_at')) { + Schema::table('applications', function (Blueprint $table) { + $table->timestamp('last_restart_at')->nullable()->after('restart_count'); + }); + } + + if (! Schema::hasColumn('applications', 'last_restart_type')) { + Schema::table('applications', function (Blueprint $table) { + $table->string('last_restart_type', 10)->nullable()->after('last_restart_at'); + }); + } } /** @@ -23,8 +35,13 @@ public function up(): void */ public function down(): void { - Schema::table('applications', function (Blueprint $table) { - $table->dropColumn(['restart_count', 'last_restart_at', 'last_restart_type']); - }); + $columns = ['restart_count', 'last_restart_at', 'last_restart_type']; + foreach ($columns as $column) { + if (Schema::hasColumn('applications', $column)) { + Schema::table('applications', function (Blueprint $table) use ($column) { + $table->dropColumn($column); + }); + } + } } }; diff --git a/database/migrations/2025_11_12_130931_add_traefik_version_tracking_to_servers_table.php b/database/migrations/2025_11_12_130931_add_traefik_version_tracking_to_servers_table.php index 3bab33368..290423526 100644 --- a/database/migrations/2025_11_12_130931_add_traefik_version_tracking_to_servers_table.php +++ b/database/migrations/2025_11_12_130931_add_traefik_version_tracking_to_servers_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('servers', function (Blueprint $table) { - $table->string('detected_traefik_version')->nullable(); - }); + if (! Schema::hasColumn('servers', 'detected_traefik_version')) { + Schema::table('servers', function (Blueprint $table) { + $table->string('detected_traefik_version')->nullable(); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('servers', function (Blueprint $table) { - $table->dropColumn('detected_traefik_version'); - }); + if (Schema::hasColumn('servers', 'detected_traefik_version')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('detected_traefik_version'); + }); + } } }; diff --git a/database/migrations/2025_11_12_131252_add_traefik_outdated_to_email_notification_settings.php b/database/migrations/2025_11_12_131252_add_traefik_outdated_to_email_notification_settings.php index ac509dc71..61a9c80b1 100644 --- a/database/migrations/2025_11_12_131252_add_traefik_outdated_to_email_notification_settings.php +++ b/database/migrations/2025_11_12_131252_add_traefik_outdated_to_email_notification_settings.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('email_notification_settings', function (Blueprint $table) { - $table->boolean('traefik_outdated_email_notifications')->default(true); - }); + if (! Schema::hasColumn('email_notification_settings', 'traefik_outdated_email_notifications')) { + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->boolean('traefik_outdated_email_notifications')->default(true); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('email_notification_settings', function (Blueprint $table) { - $table->dropColumn('traefik_outdated_email_notifications'); - }); + if (Schema::hasColumn('email_notification_settings', 'traefik_outdated_email_notifications')) { + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->dropColumn('traefik_outdated_email_notifications'); + }); + } } }; diff --git a/database/migrations/2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings.php b/database/migrations/2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings.php index b7d69e634..3ceb07da8 100644 --- a/database/migrations/2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings.php +++ b/database/migrations/2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('telegram_notification_settings', function (Blueprint $table) { - $table->text('telegram_notifications_traefik_outdated_thread_id')->nullable(); - }); + if (! Schema::hasColumn('telegram_notification_settings', 'telegram_notifications_traefik_outdated_thread_id')) { + Schema::table('telegram_notification_settings', function (Blueprint $table) { + $table->text('telegram_notifications_traefik_outdated_thread_id')->nullable(); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('telegram_notification_settings', function (Blueprint $table) { - $table->dropColumn('telegram_notifications_traefik_outdated_thread_id'); - }); + if (Schema::hasColumn('telegram_notification_settings', 'telegram_notifications_traefik_outdated_thread_id')) { + Schema::table('telegram_notification_settings', function (Blueprint $table) { + $table->dropColumn('telegram_notifications_traefik_outdated_thread_id'); + }); + } } }; diff --git a/database/migrations/2025_11_14_114632_add_traefik_outdated_info_to_servers_table.php b/database/migrations/2025_11_14_114632_add_traefik_outdated_info_to_servers_table.php index 99e10707d..12fca4190 100644 --- a/database/migrations/2025_11_14_114632_add_traefik_outdated_info_to_servers_table.php +++ b/database/migrations/2025_11_14_114632_add_traefik_outdated_info_to_servers_table.php @@ -11,9 +11,11 @@ */ public function up(): void { - Schema::table('servers', function (Blueprint $table) { - $table->json('traefik_outdated_info')->nullable(); - }); + if (! Schema::hasColumn('servers', 'traefik_outdated_info')) { + Schema::table('servers', function (Blueprint $table) { + $table->json('traefik_outdated_info')->nullable(); + }); + } } /** @@ -21,8 +23,10 @@ public function up(): void */ public function down(): void { - Schema::table('servers', function (Blueprint $table) { - $table->dropColumn('traefik_outdated_info'); - }); + if (Schema::hasColumn('servers', 'traefik_outdated_info')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('traefik_outdated_info'); + }); + } } }; diff --git a/database/migrations/2025_11_16_000001_create_webhook_notification_settings_table.php b/database/migrations/2025_11_16_000001_create_webhook_notification_settings_table.php new file mode 100644 index 000000000..df620bd6e --- /dev/null +++ b/database/migrations/2025_11_16_000001_create_webhook_notification_settings_table.php @@ -0,0 +1,89 @@ +id(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + + $table->boolean('webhook_enabled')->default(false); + $table->text('webhook_url')->nullable(); + + $table->boolean('deployment_success_webhook_notifications')->default(false); + $table->boolean('deployment_failure_webhook_notifications')->default(true); + $table->boolean('status_change_webhook_notifications')->default(false); + $table->boolean('backup_success_webhook_notifications')->default(false); + $table->boolean('backup_failure_webhook_notifications')->default(true); + $table->boolean('scheduled_task_success_webhook_notifications')->default(false); + $table->boolean('scheduled_task_failure_webhook_notifications')->default(true); + $table->boolean('docker_cleanup_success_webhook_notifications')->default(false); + $table->boolean('docker_cleanup_failure_webhook_notifications')->default(true); + $table->boolean('server_disk_usage_webhook_notifications')->default(true); + $table->boolean('server_reachable_webhook_notifications')->default(false); + $table->boolean('server_unreachable_webhook_notifications')->default(true); + $table->boolean('server_patch_webhook_notifications')->default(false); + $table->boolean('traefik_outdated_webhook_notifications')->default(true); + + $table->unique(['team_id']); + }); + } + + // Populate webhook notification settings for existing teams (only if they don't already have settings) + DB::table('teams')->chunkById(100, function ($teams) { + foreach ($teams as $team) { + try { + // Check if settings already exist for this team + $exists = DB::table('webhook_notification_settings') + ->where('team_id', $team->id) + ->exists(); + + if (! $exists) { + // Only insert if no settings exist - don't overwrite existing preferences + DB::table('webhook_notification_settings')->insert([ + 'team_id' => $team->id, + 'webhook_enabled' => false, + 'webhook_url' => null, + 'deployment_success_webhook_notifications' => false, + 'deployment_failure_webhook_notifications' => true, + 'status_change_webhook_notifications' => false, + 'backup_success_webhook_notifications' => false, + 'backup_failure_webhook_notifications' => true, + 'scheduled_task_success_webhook_notifications' => false, + 'scheduled_task_failure_webhook_notifications' => true, + 'docker_cleanup_success_webhook_notifications' => false, + 'docker_cleanup_failure_webhook_notifications' => true, + 'server_disk_usage_webhook_notifications' => true, + 'server_reachable_webhook_notifications' => false, + 'server_unreachable_webhook_notifications' => true, + 'server_patch_webhook_notifications' => false, + 'traefik_outdated_webhook_notifications' => true, + ]); + } + } catch (\Throwable $e) { + Log::error('Error creating webhook notification settings for team '.$team->id.': '.$e->getMessage()); + } + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_notification_settings'); + } +}; diff --git a/database/migrations/2025_11_16_000002_create_cloud_init_scripts_table.php b/database/migrations/2025_11_16_000002_create_cloud_init_scripts_table.php new file mode 100644 index 000000000..11c5b99a3 --- /dev/null +++ b/database/migrations/2025_11_16_000002_create_cloud_init_scripts_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('script'); // Encrypted in the model + $table->timestamps(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cloud_init_scripts'); + } +}; diff --git a/database/migrations/2025_11_17_092707_add_traefik_outdated_to_notification_settings.php b/database/migrations/2025_11_17_092707_add_traefik_outdated_to_notification_settings.php index b5cad28b0..a0806ae9f 100644 --- a/database/migrations/2025_11_17_092707_add_traefik_outdated_to_notification_settings.php +++ b/database/migrations/2025_11_17_092707_add_traefik_outdated_to_notification_settings.php @@ -19,9 +19,13 @@ public function up(): void $table->boolean('traefik_outdated_slack_notifications')->default(true); }); - Schema::table('webhook_notification_settings', function (Blueprint $table) { - $table->boolean('traefik_outdated_webhook_notifications')->default(true); - }); + // Only add if table exists and column doesn't exist + if (Schema::hasTable('webhook_notification_settings') && + ! Schema::hasColumn('webhook_notification_settings', 'traefik_outdated_webhook_notifications')) { + Schema::table('webhook_notification_settings', function (Blueprint $table) { + $table->boolean('traefik_outdated_webhook_notifications')->default(true); + }); + } Schema::table('telegram_notification_settings', function (Blueprint $table) { $table->boolean('traefik_outdated_telegram_notifications')->default(true); @@ -45,9 +49,13 @@ public function down(): void $table->dropColumn('traefik_outdated_slack_notifications'); }); - Schema::table('webhook_notification_settings', function (Blueprint $table) { - $table->dropColumn('traefik_outdated_webhook_notifications'); - }); + // Only drop if table and column exist + if (Schema::hasTable('webhook_notification_settings') && + Schema::hasColumn('webhook_notification_settings', 'traefik_outdated_webhook_notifications')) { + Schema::table('webhook_notification_settings', function (Blueprint $table) { + $table->dropColumn('traefik_outdated_webhook_notifications'); + }); + } Schema::table('telegram_notification_settings', function (Blueprint $table) { $table->dropColumn('traefik_outdated_telegram_notifications'); diff --git a/database/migrations/2025_11_26_124200_add_build_cache_settings_to_application_settings.php b/database/migrations/2025_11_26_124200_add_build_cache_settings_to_application_settings.php new file mode 100644 index 000000000..f38c9c2a8 --- /dev/null +++ b/database/migrations/2025_11_26_124200_add_build_cache_settings_to_application_settings.php @@ -0,0 +1,44 @@ +boolean('inject_build_args_to_dockerfile')->default(true)->after('use_build_secrets'); + }); + } + + if (! Schema::hasColumn('application_settings', 'include_source_commit_in_build')) { + Schema::table('application_settings', function (Blueprint $table) { + $table->boolean('include_source_commit_in_build')->default(false)->after('inject_build_args_to_dockerfile'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('application_settings', 'inject_build_args_to_dockerfile')) { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('inject_build_args_to_dockerfile'); + }); + } + + if (Schema::hasColumn('application_settings', 'include_source_commit_in_build')) { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('include_source_commit_in_build'); + }); + } + } +}; diff --git a/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php b/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php new file mode 100644 index 000000000..88c236239 --- /dev/null +++ b/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php @@ -0,0 +1,32 @@ +integer('deployment_queue_limit')->default(25)->after('concurrent_builds'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('server_settings', 'deployment_queue_limit')) { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('deployment_queue_limit'); + }); + } + } +}; diff --git a/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php b/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php new file mode 100644 index 000000000..3cc027466 --- /dev/null +++ b/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php @@ -0,0 +1,26 @@ +integer('docker_images_to_keep')->default(2); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('application_settings', 'docker_images_to_keep')) { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('docker_images_to_keep'); + }); + } + } +}; diff --git a/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php b/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php new file mode 100644 index 000000000..dc70cc9f0 --- /dev/null +++ b/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php @@ -0,0 +1,26 @@ +boolean('disable_application_image_retention')->default(false); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('server_settings', 'disable_application_image_retention')) { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('disable_application_image_retention'); + }); + } + } +}; diff --git a/database/migrations/2025_12_08_135600_add_performance_indexes.php b/database/migrations/2025_12_08_135600_add_performance_indexes.php new file mode 100644 index 000000000..680c4b4f7 --- /dev/null +++ b/database/migrations/2025_12_08_135600_add_performance_indexes.php @@ -0,0 +1,49 @@ +indexes as [$table, $columns, $indexName]) { + if (! $this->indexExists($indexName)) { + $columnList = implode(', ', array_map(fn ($col) => "\"$col\"", $columns)); + DB::statement("CREATE INDEX \"{$indexName}\" ON \"{$table}\" ({$columnList})"); + } + } + } + + public function down(): void + { + foreach ($this->indexes as [, , $indexName]) { + DB::statement("DROP INDEX IF EXISTS \"{$indexName}\""); + } + } + + private function indexExists(string $indexName): bool + { + $result = DB::selectOne( + 'SELECT 1 FROM pg_indexes WHERE indexname = ?', + [$indexName] + ); + + return $result !== null; + } +}; diff --git a/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php b/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php new file mode 100644 index 000000000..56f44794d --- /dev/null +++ b/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php @@ -0,0 +1,50 @@ +string('uuid')->nullable()->unique()->after('id'); + }); + + // Generate UUIDs for existing records using chunked processing + DB::table('cloud_provider_tokens') + ->whereNull('uuid') + ->chunkById(500, function ($tokens) { + foreach ($tokens as $token) { + DB::table('cloud_provider_tokens') + ->where('id', $token->id) + ->update(['uuid' => (string) new Cuid2]); + } + }); + + // Make uuid non-nullable after filling in values + Schema::table('cloud_provider_tokens', function (Blueprint $table) { + $table->string('uuid')->nullable(false)->change(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('cloud_provider_tokens', 'uuid')) { + Schema::table('cloud_provider_tokens', function (Blueprint $table) { + $table->dropColumn('uuid'); + }); + } + } +}; diff --git a/database/migrations/2025_12_15_143052_trim_s3_storage_credentials.php b/database/migrations/2025_12_15_143052_trim_s3_storage_credentials.php new file mode 100644 index 000000000..bb59d7358 --- /dev/null +++ b/database/migrations/2025_12_15_143052_trim_s3_storage_credentials.php @@ -0,0 +1,112 @@ +select(['id', 'key', 'secret', 'endpoint', 'bucket', 'region']) + ->orderBy('id') + ->chunk(100, function ($storages) { + foreach ($storages as $storage) { + try { + DB::transaction(function () use ($storage) { + $updates = []; + + // Trim endpoint (not encrypted) + if ($storage->endpoint !== null) { + $trimmedEndpoint = trim($storage->endpoint); + if ($trimmedEndpoint !== $storage->endpoint) { + $updates['endpoint'] = $trimmedEndpoint; + } + } + + // Trim bucket (not encrypted) + if ($storage->bucket !== null) { + $trimmedBucket = trim($storage->bucket); + if ($trimmedBucket !== $storage->bucket) { + $updates['bucket'] = $trimmedBucket; + } + } + + // Trim region (not encrypted) + if ($storage->region !== null) { + $trimmedRegion = trim($storage->region); + if ($trimmedRegion !== $storage->region) { + $updates['region'] = $trimmedRegion; + } + } + + // Trim key (encrypted) - verify re-encryption works before saving + if ($storage->key !== null) { + try { + $decryptedKey = Crypt::decryptString($storage->key); + $trimmedKey = trim($decryptedKey); + if ($trimmedKey !== $decryptedKey) { + $encryptedKey = Crypt::encryptString($trimmedKey); + // Verify the new encryption is valid + if (Crypt::decryptString($encryptedKey) === $trimmedKey) { + $updates['key'] = $encryptedKey; + } else { + Log::warning("S3 storage ID {$storage->id}: Re-encryption verification failed for key, skipping"); + } + } + } catch (\Throwable $e) { + Log::warning("Could not decrypt S3 storage key for ID {$storage->id}: ".$e->getMessage()); + } + } + + // Trim secret (encrypted) - verify re-encryption works before saving + if ($storage->secret !== null) { + try { + $decryptedSecret = Crypt::decryptString($storage->secret); + $trimmedSecret = trim($decryptedSecret); + if ($trimmedSecret !== $decryptedSecret) { + $encryptedSecret = Crypt::encryptString($trimmedSecret); + // Verify the new encryption is valid + if (Crypt::decryptString($encryptedSecret) === $trimmedSecret) { + $updates['secret'] = $encryptedSecret; + } else { + Log::warning("S3 storage ID {$storage->id}: Re-encryption verification failed for secret, skipping"); + } + } + } catch (\Throwable $e) { + Log::warning("Could not decrypt S3 storage secret for ID {$storage->id}: ".$e->getMessage()); + } + } + + if (! empty($updates)) { + DB::table('s3_storages')->where('id', $storage->id)->update($updates); + Log::info("Trimmed whitespace from S3 storage credentials for ID {$storage->id}", [ + 'fields_updated' => array_keys($updates), + ]); + } + }); + } catch (\Throwable $e) { + Log::error("Failed to process S3 storage ID {$storage->id}: ".$e->getMessage()); + // Continue with next record instead of failing entire migration + } + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Cannot reverse trimming operation + } +}; diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index f012c1534..f5a00fe15 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -15,6 +15,7 @@ class ApplicationSeeder extends Seeder public function run(): void { Application::create([ + 'uuid' => 'docker-compose', 'name' => 'Docker Compose Example', 'repository_project_id' => 603035348, 'git_repository' => 'coollabsio/coolify-examples', @@ -30,6 +31,7 @@ public function run(): void 'source_type' => GithubApp::class, ]); Application::create([ + 'uuid' => 'nodejs', 'name' => 'NodeJS Fastify Example', 'fqdn' => 'http://nodejs.127.0.0.1.sslip.io', 'repository_project_id' => 603035348, @@ -45,6 +47,7 @@ public function run(): void 'source_type' => GithubApp::class, ]); Application::create([ + 'uuid' => 'dockerfile', 'name' => 'Dockerfile Example', 'fqdn' => 'http://dockerfile.127.0.0.1.sslip.io', 'repository_project_id' => 603035348, @@ -60,6 +63,7 @@ public function run(): void 'source_type' => GithubApp::class, ]); Application::create([ + 'uuid' => 'dockerfile-pure', 'name' => 'Pure Dockerfile Example', 'fqdn' => 'http://pure-dockerfile.127.0.0.1.sslip.io', 'git_repository' => 'coollabsio/coolify', @@ -75,6 +79,23 @@ public function run(): void 'dockerfile' => 'FROM nginx EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] +', + ]); + Application::create([ + 'uuid' => 'crashloop', + 'name' => 'Crash Loop Example', + 'git_repository' => 'coollabsio/coolify', + 'git_branch' => 'v4.x', + 'git_commit_sha' => 'HEAD', + 'build_pack' => 'dockerfile', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 0, + 'source_type' => GithubApp::class, + 'dockerfile' => 'FROM alpine +CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"] ', ]); } diff --git a/database/seeders/GithubAppSeeder.php b/database/seeders/GithubAppSeeder.php index b34c00473..10e23c36a 100644 --- a/database/seeders/GithubAppSeeder.php +++ b/database/seeders/GithubAppSeeder.php @@ -14,6 +14,7 @@ public function run(): void { GithubApp::create([ 'id' => 0, + 'uuid' => 'github-public', 'name' => 'Public GitHub', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', @@ -22,7 +23,7 @@ public function run(): void ]); GithubApp::create([ 'name' => 'coolify-laravel-dev-public', - 'uuid' => '69420', + 'uuid' => 'github-app', 'organization' => 'coollabsio', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', diff --git a/database/seeders/GitlabAppSeeder.php b/database/seeders/GitlabAppSeeder.php index ec2b7ec5e..5dfb59902 100644 --- a/database/seeders/GitlabAppSeeder.php +++ b/database/seeders/GitlabAppSeeder.php @@ -14,6 +14,7 @@ public function run(): void { GitlabApp::create([ 'id' => 1, + 'uuid' => 'gitlab-public', 'name' => 'Public GitLab', 'api_url' => 'https://gitlab.com/api/v4', 'html_url' => 'https://gitlab.com', diff --git a/database/seeders/PrivateKeySeeder.php b/database/seeders/PrivateKeySeeder.php index 6b44d0867..0aa4153b3 100644 --- a/database/seeders/PrivateKeySeeder.php +++ b/database/seeders/PrivateKeySeeder.php @@ -13,6 +13,7 @@ class PrivateKeySeeder extends Seeder public function run(): void { PrivateKey::create([ + 'uuid' => 'ssh', 'team_id' => 0, 'name' => 'Testing Host Key', 'description' => 'This is a test docker container', @@ -27,6 +28,7 @@ public function run(): void ]); PrivateKey::create([ + 'uuid' => 'github-key', 'team_id' => 0, 'name' => 'development-github-app', 'description' => 'This is the key for using the development GitHub app', diff --git a/database/seeders/ProjectSeeder.php b/database/seeders/ProjectSeeder.php index 33cd8cd06..ab8e54051 100644 --- a/database/seeders/ProjectSeeder.php +++ b/database/seeders/ProjectSeeder.php @@ -9,10 +9,14 @@ class ProjectSeeder extends Seeder { public function run(): void { - Project::create([ + $project = Project::create([ + 'uuid' => 'project', 'name' => 'My first project', 'description' => 'This is a test project in development', 'team_id' => 0, ]); + + // Update the auto-created environment with a deterministic UUID + $project->environments()->first()->update(['uuid' => 'production']); } } diff --git a/database/seeders/S3StorageSeeder.php b/database/seeders/S3StorageSeeder.php index 9fa531447..b38df6ad5 100644 --- a/database/seeders/S3StorageSeeder.php +++ b/database/seeders/S3StorageSeeder.php @@ -13,6 +13,7 @@ class S3StorageSeeder extends Seeder public function run(): void { S3Storage::create([ + 'uuid' => 'minio', 'name' => 'Local MinIO', 'description' => 'Local MinIO S3 Storage', 'key' => 'minioadmin', diff --git a/database/seeders/ServerSeeder.php b/database/seeders/ServerSeeder.php index d32843107..2d8746691 100644 --- a/database/seeders/ServerSeeder.php +++ b/database/seeders/ServerSeeder.php @@ -13,6 +13,7 @@ public function run(): void { Server::create([ 'id' => 0, + 'uuid' => 'localhost', 'name' => 'localhost', 'description' => 'This is a test docker container in development mode', 'ip' => 'coolify-testing-host', diff --git a/database/seeders/StandaloneDockerSeeder.php b/database/seeders/StandaloneDockerSeeder.php index a466de56b..e31c62d9f 100644 --- a/database/seeders/StandaloneDockerSeeder.php +++ b/database/seeders/StandaloneDockerSeeder.php @@ -15,6 +15,7 @@ public function run(): void if (StandaloneDocker::find(0) == null) { StandaloneDocker::create([ 'id' => 0, + 'uuid' => 'docker', 'name' => 'Standalone Docker 1', 'network' => 'coolify', 'server_id' => 0, diff --git a/database/seeders/StandalonePostgresqlSeeder.php b/database/seeders/StandalonePostgresqlSeeder.php index 1fc96a610..59ee6fd42 100644 --- a/database/seeders/StandalonePostgresqlSeeder.php +++ b/database/seeders/StandalonePostgresqlSeeder.php @@ -11,6 +11,7 @@ class StandalonePostgresqlSeeder extends Seeder public function run(): void { StandalonePostgresql::create([ + 'uuid' => 'postgresql', 'name' => 'Local PostgreSQL', 'description' => 'Local PostgreSQL for testing', 'postgres_password' => 'postgres', diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b90f126a2..46e0e88e5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -11,7 +11,6 @@ services: - /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/backups:/var/www/html/storage/app/backups - - /data/coolify/webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance environment: - APP_ENV=${APP_ENV:-production} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index cd4a307aa..3116a4185 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -25,7 +25,6 @@ services: - ./databases:/var/www/html/storage/app/databases - ./services:/var/www/html/storage/app/services - ./backups:/var/www/html/storage/app/backups - - ./webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance env_file: - .env environment: @@ -75,13 +74,7 @@ services: POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_DB: "${DB_DATABASE:-coolify}" healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${DB_USERNAME}", - "-d", - "${DB_DATABASE:-coolify}" - ] + test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ] interval: 5s retries: 10 timeout: 2s @@ -121,7 +114,7 @@ services: SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s retries: 10 timeout: 2s diff --git a/openapi.json b/openapi.json index dd3c6783a..fe8ca863e 100644 --- a/openapi.json +++ b/openapi.json @@ -361,6 +361,11 @@ "force_domain_override": { "type": "boolean", "description": "Force domain usage even if conflicts are detected. Default is false." + }, + "autogenerate_domain": { + "type": "boolean", + "default": true, + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." } }, "type": "object" @@ -771,6 +776,11 @@ "force_domain_override": { "type": "boolean", "description": "Force domain usage even if conflicts are detected. Default is false." + }, + "autogenerate_domain": { + "type": "boolean", + "default": true, + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." } }, "type": "object" @@ -1181,6 +1191,11 @@ "force_domain_override": { "type": "boolean", "description": "Force domain usage even if conflicts are detected. Default is false." + }, + "autogenerate_domain": { + "type": "boolean", + "default": true, + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." } }, "type": "object" @@ -1520,6 +1535,11 @@ "force_domain_override": { "type": "boolean", "description": "Force domain usage even if conflicts are detected. Default is false." + }, + "autogenerate_domain": { + "type": "boolean", + "default": true, + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." } }, "type": "object" @@ -1842,6 +1862,11 @@ "force_domain_override": { "type": "boolean", "description": "Force domain usage even if conflicts are detected. Default is false." + }, + "autogenerate_domain": { + "type": "boolean", + "default": true, + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." } }, "type": "object" @@ -3275,6 +3300,387 @@ ] } }, + "\/cloud-tokens": { + "get": { + "tags": [ + "Cloud Tokens" + ], + "summary": "List Cloud Provider Tokens", + "description": "List all cloud provider tokens for the authenticated team.", + "operationId": "list-cloud-tokens", + "responses": { + "200": { + "description": "Get all cloud provider tokens.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string", + "enum": [ + "hetzner", + "digitalocean" + ] + }, + "team_id": { + "type": "integer" + }, + "servers_count": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Create Cloud Provider Token", + "description": "Create a new cloud provider token. The token will be validated before being stored.", + "operationId": "create-cloud-token", + "requestBody": { + "description": "Cloud provider token details", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "provider", + "token", + "name" + ], + "properties": { + "provider": { + "type": "string", + "enum": [ + "hetzner", + "digitalocean" + ], + "example": "hetzner", + "description": "The cloud provider." + }, + "token": { + "type": "string", + "example": "your-api-token-here", + "description": "The API token for the cloud provider." + }, + "name": { + "type": "string", + "example": "My Hetzner Token", + "description": "A friendly name for the token." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Cloud provider token created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "og888os", + "description": "The UUID of the token." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/cloud-tokens\/{uuid}": { + "get": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Get Cloud Provider Token", + "description": "Get cloud provider token by UUID.", + "operationId": "get-cloud-token-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Token UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get cloud provider token by UUID", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "team_id": { + "type": "integer" + }, + "servers_count": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Delete Cloud Provider Token", + "description": "Delete cloud provider token by UUID. Cannot delete if token is used by any servers.", + "operationId": "delete-cloud-token-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the cloud provider token.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Cloud provider token deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Cloud provider token deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Update Cloud Provider Token", + "description": "Update cloud provider token name.", + "operationId": "update-cloud-token-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Token UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Cloud provider token updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The friendly name for the token." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Cloud provider token updated.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/cloud-tokens\/{uuid}\/validate": { + "post": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Validate Cloud Provider Token", + "description": "Validate a cloud provider token against the provider API.", + "operationId": "validate-cloud-token-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Token UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Token validation result.", + "content": { + "application\/json": { + "schema": { + "properties": { + "valid": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Token is valid." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases": { "get": { "tags": [ @@ -6314,6 +6720,486 @@ ] } }, + "\/hetzner\/locations": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Locations", + "description": "Get all available Hetzner datacenter locations.", + "operationId": "get-hetzner-locations", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner locations.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "country": { + "type": "string" + }, + "city": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/server-types": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Server Types", + "description": "Get all available Hetzner server types (instance sizes).", + "operationId": "get-hetzner-server-types", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner server types.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "cores": { + "type": "integer" + }, + "memory": { + "type": "number" + }, + "disk": { + "type": "integer" + }, + "prices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "Datacenter location name" + }, + "price_hourly": { + "type": "object", + "properties": { + "net": { + "type": "string" + }, + "gross": { + "type": "string" + } + } + }, + "price_monthly": { + "type": "object", + "properties": { + "net": { + "type": "string" + }, + "gross": { + "type": "string" + } + } + } + } + } + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/images": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Images", + "description": "Get all available Hetzner system images (operating systems).", + "operationId": "get-hetzner-images", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner images.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string" + }, + "os_flavor": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "architecture": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/ssh-keys": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner SSH Keys", + "description": "Get all SSH keys stored in the Hetzner account.", + "operationId": "get-hetzner-ssh-keys", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner SSH keys.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "public_key": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/hetzner": { + "post": { + "tags": [ + "Hetzner" + ], + "summary": "Create Hetzner Server", + "description": "Create a new server on Hetzner and register it in Coolify.", + "operationId": "create-hetzner-server", + "requestBody": { + "description": "Hetzner server creation parameters", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "location", + "server_type", + "image", + "private_key_uuid" + ], + "properties": { + "cloud_provider_token_uuid": { + "type": "string", + "example": "abc123", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided." + }, + "cloud_provider_token_id": { + "type": "string", + "example": "abc123", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "deprecated": true + }, + "location": { + "type": "string", + "example": "nbg1", + "description": "Hetzner location name" + }, + "server_type": { + "type": "string", + "example": "cx11", + "description": "Hetzner server type name" + }, + "image": { + "type": "integer", + "example": 15512617, + "description": "Hetzner image ID" + }, + "name": { + "type": "string", + "example": "my-server", + "description": "Server name (auto-generated if not provided)" + }, + "private_key_uuid": { + "type": "string", + "example": "xyz789", + "description": "Private key UUID" + }, + "enable_ipv4": { + "type": "boolean", + "example": true, + "description": "Enable IPv4 (default: true)" + }, + "enable_ipv6": { + "type": "boolean", + "example": true, + "description": "Enable IPv6 (default: true)" + }, + "hetzner_ssh_key_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Additional Hetzner SSH key IDs" + }, + "cloud_init_script": { + "type": "string", + "description": "Cloud-init YAML script (optional)" + }, + "instant_validate": { + "type": "boolean", + "example": false, + "description": "Validate server immediately after creation" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Hetzner server created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "og888os", + "description": "The UUID of the server." + }, + "hetzner_server_id": { + "type": "integer", + "description": "The Hetzner server ID." + }, + "ip": { + "type": "string", + "description": "The server IP address." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + }, + "429": { + "$ref": "#\/components\/responses\/429" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/version": { "get": { "summary": "Version", @@ -9816,6 +10702,9 @@ "concurrent_builds": { "type": "integer" }, + "deployment_queue_limit": { + "type": "integer" + }, "dynamic_timeout": { "type": "integer" }, @@ -10174,6 +11063,31 @@ } } } + }, + "429": { + "description": "Rate limit exceeded.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying.", + "schema": { + "type": "integer", + "example": 60 + } + } + }, + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Rate limit exceeded. Please try again later." + } + }, + "type": "object" + } + } + } } }, "securitySchemes": { @@ -10189,6 +11103,10 @@ "name": "Applications", "description": "Applications" }, + { + "name": "Cloud Tokens", + "description": "Cloud Tokens" + }, { "name": "Databases", "description": "Databases" @@ -10201,6 +11119,10 @@ "name": "GitHub Apps", "description": "GitHub Apps" }, + { + "name": "Hetzner", + "description": "Hetzner" + }, { "name": "Projects", "description": "Projects" diff --git a/openapi.yaml b/openapi.yaml index 754b7ec6f..a7faa8c72 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -265,6 +265,10 @@ paths: force_domain_override: type: boolean description: 'Force domain usage even if conflicts are detected. Default is false.' + autogenerate_domain: + type: boolean + default: true + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." type: object responses: '201': @@ -531,6 +535,10 @@ paths: force_domain_override: type: boolean description: 'Force domain usage even if conflicts are detected. Default is false.' + autogenerate_domain: + type: boolean + default: true + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." type: object responses: '201': @@ -797,6 +805,10 @@ paths: force_domain_override: type: boolean description: 'Force domain usage even if conflicts are detected. Default is false.' + autogenerate_domain: + type: boolean + default: true + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." type: object responses: '201': @@ -1010,6 +1022,10 @@ paths: force_domain_override: type: boolean description: 'Force domain usage even if conflicts are detected. Default is false.' + autogenerate_domain: + type: boolean + default: true + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." type: object responses: '201': @@ -1214,6 +1230,10 @@ paths: force_domain_override: type: boolean description: 'Force domain usage even if conflicts are detected. Default is false.' + autogenerate_domain: + type: boolean + default: true + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." type: object responses: '201': @@ -2075,6 +2095,224 @@ paths: security: - bearerAuth: [] + /cloud-tokens: + get: + tags: + - 'Cloud Tokens' + summary: 'List Cloud Provider Tokens' + description: 'List all cloud provider tokens for the authenticated team.' + operationId: list-cloud-tokens + responses: + '200': + description: 'Get all cloud provider tokens.' + content: + application/json: + schema: + type: array + items: + properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + post: + tags: + - 'Cloud Tokens' + summary: 'Create Cloud Provider Token' + description: 'Create a new cloud provider token. The token will be validated before being stored.' + operationId: create-cloud-token + requestBody: + description: 'Cloud provider token details' + required: true + content: + application/json: + schema: + required: + - provider + - token + - name + properties: + provider: + type: string + enum: [hetzner, digitalocean] + example: hetzner + description: 'The cloud provider.' + token: + type: string + example: your-api-token-here + description: 'The API token for the cloud provider.' + name: + type: string + example: 'My Hetzner Token' + description: 'A friendly name for the token.' + type: object + responses: + '201': + description: 'Cloud provider token created.' + content: + application/json: + schema: + properties: + uuid: { type: string, example: og888os, description: 'The UUID of the token.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/cloud-tokens/{uuid}': + get: + tags: + - 'Cloud Tokens' + summary: 'Get Cloud Provider Token' + description: 'Get cloud provider token by UUID.' + operationId: get-cloud-token-by-uuid + parameters: + - + name: uuid + in: path + description: 'Token UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Get cloud provider token by UUID' + content: + application/json: + schema: + properties: + uuid: { type: string } + name: { type: string } + provider: { type: string } + team_id: { type: integer } + servers_count: { type: integer } + created_at: { type: string } + updated_at: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + delete: + tags: + - 'Cloud Tokens' + summary: 'Delete Cloud Provider Token' + description: 'Delete cloud provider token by UUID. Cannot delete if token is used by any servers.' + operationId: delete-cloud-token-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the cloud provider token.' + required: true + schema: + type: string + format: uuid + responses: + '200': + description: 'Cloud provider token deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Cloud provider token deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Cloud Tokens' + summary: 'Update Cloud Provider Token' + description: 'Update cloud provider token name.' + operationId: update-cloud-token-by-uuid + parameters: + - + name: uuid + in: path + description: 'Token UUID' + required: true + schema: + type: string + requestBody: + description: 'Cloud provider token updated.' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The friendly name for the token.' + type: object + responses: + '200': + description: 'Cloud provider token updated.' + content: + application/json: + schema: + properties: + uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/cloud-tokens/{uuid}/validate': + post: + tags: + - 'Cloud Tokens' + summary: 'Validate Cloud Provider Token' + description: 'Validate a cloud provider token against the provider API.' + operationId: validate-cloud-token-by-uuid + parameters: + - + name: uuid + in: path + description: 'Token UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Token validation result.' + content: + application/json: + schema: + properties: + valid: { type: boolean, example: true } + message: { type: string, example: 'Token is valid.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /databases: get: tags: @@ -4084,6 +4322,258 @@ paths: security: - bearerAuth: [] + /hetzner/locations: + get: + tags: + - Hetzner + summary: 'Get Hetzner Locations' + description: 'Get all available Hetzner datacenter locations.' + operationId: get-hetzner-locations + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner locations.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string }, description: { type: string }, country: { type: string }, city: { type: string }, latitude: { type: number }, longitude: { type: number } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /hetzner/server-types: + get: + tags: + - Hetzner + summary: 'Get Hetzner Server Types' + description: 'Get all available Hetzner server types (instance sizes).' + operationId: get-hetzner-server-types + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner server types.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string }, description: { type: string }, cores: { type: integer }, memory: { type: number }, disk: { type: integer }, prices: { type: array, items: { type: object, properties: { location: { type: string, description: 'Datacenter location name' }, price_hourly: { type: object, properties: { net: { type: string }, gross: { type: string } } }, price_monthly: { type: object, properties: { net: { type: string }, gross: { type: string } } } } } } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /hetzner/images: + get: + tags: + - Hetzner + summary: 'Get Hetzner Images' + description: 'Get all available Hetzner system images (operating systems).' + operationId: get-hetzner-images + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner images.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string }, description: { type: string }, type: { type: string }, os_flavor: { type: string }, os_version: { type: string }, architecture: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /hetzner/ssh-keys: + get: + tags: + - Hetzner + summary: 'Get Hetzner SSH Keys' + description: 'Get all SSH keys stored in the Hetzner account.' + operationId: get-hetzner-ssh-keys + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner SSH keys.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string }, fingerprint: { type: string }, public_key: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /servers/hetzner: + post: + tags: + - Hetzner + summary: 'Create Hetzner Server' + description: 'Create a new server on Hetzner and register it in Coolify.' + operationId: create-hetzner-server + requestBody: + description: 'Hetzner server creation parameters' + required: true + content: + application/json: + schema: + required: + - location + - server_type + - image + - private_key_uuid + properties: + cloud_provider_token_uuid: + type: string + example: abc123 + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + cloud_provider_token_id: + type: string + example: abc123 + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + deprecated: true + location: + type: string + example: nbg1 + description: 'Hetzner location name' + server_type: + type: string + example: cx11 + description: 'Hetzner server type name' + image: + type: integer + example: 15512617 + description: 'Hetzner image ID' + name: + type: string + example: my-server + description: 'Server name (auto-generated if not provided)' + private_key_uuid: + type: string + example: xyz789 + description: 'Private key UUID' + enable_ipv4: + type: boolean + example: true + description: 'Enable IPv4 (default: true)' + enable_ipv6: + type: boolean + example: true + description: 'Enable IPv6 (default: true)' + hetzner_ssh_key_ids: + type: array + items: { type: integer } + description: 'Additional Hetzner SSH key IDs' + cloud_init_script: + type: string + description: 'Cloud-init YAML script (optional)' + instant_validate: + type: boolean + example: false + description: 'Validate server immediately after creation' + type: object + responses: + '201': + description: 'Hetzner server created.' + content: + application/json: + schema: + properties: + uuid: { type: string, example: og888os, description: 'The UUID of the server.' } + hetzner_server_id: { type: integer, description: 'The Hetzner server ID.' } + ip: { type: string, description: 'The server IP address.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + '429': + $ref: '#/components/responses/429' + security: + - + bearerAuth: [] /version: get: summary: Version @@ -6312,6 +6802,8 @@ components: type: integer concurrent_builds: type: integer + deployment_queue_limit: + type: integer dynamic_timeout: type: integer force_disabled: @@ -6556,6 +7048,22 @@ components: type: array items: { type: string } type: object + '429': + description: 'Rate limit exceeded.' + headers: + Retry-After: + description: 'Number of seconds to wait before retrying.' + schema: + type: integer + example: 60 + content: + application/json: + schema: + properties: + message: + type: string + example: 'Rate limit exceeded. Please try again later.' + type: object securitySchemes: bearerAuth: type: http @@ -6565,6 +7073,9 @@ tags: - name: Applications description: Applications + - + name: 'Cloud Tokens' + description: 'Cloud Tokens' - name: Databases description: Databases @@ -6574,6 +7085,9 @@ tags: - name: 'GitHub Apps' description: 'GitHub Apps' + - + name: Hetzner + description: Hetzner - name: Projects description: Projects diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index b90f126a2..46e0e88e5 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -11,7 +11,6 @@ services: - /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/backups:/var/www/html/storage/app/backups - - /data/coolify/webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance environment: - APP_ENV=${APP_ENV:-production} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 09ce3ead3..6306ab381 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -25,7 +25,6 @@ services: - ./databases:/var/www/html/storage/app/databases - ./services:/var/www/html/storage/app/services - ./backups:/var/www/html/storage/app/backups - - ./webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance env_file: - .env environment: @@ -75,13 +74,7 @@ services: POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_DB: "${DB_DATABASE:-coolify}" healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${DB_USERNAME}", - "-d", - "${DB_DATABASE:-coolify}" - ] + test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ] interval: 5s retries: 10 timeout: 2s @@ -121,7 +114,7 @@ services: SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s retries: 10 timeout: 2s diff --git a/other/nightly/install.sh b/other/nightly/install.sh index bcd37e71f..b037fe382 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -223,7 +223,7 @@ if [ "$WARNING_SPACE" = true ]; then sleep 5 fi -mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,sentinel} +mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} mkdir -p /data/coolify/ssh/{keys,mux} mkdir -p /data/coolify/proxy/dynamic @@ -288,9 +288,9 @@ if [ "$OS_TYPE" = 'amzn' ]; then dnf install -y findutils >/dev/null fi -LATEST_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $2}' | tr -d ',') -LATEST_HELPER_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $6}' | tr -d ',') -LATEST_REALTIME_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $8}' | tr -d ',') +LATEST_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $2}' | tr -d ',') +LATEST_HELPER_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $6}' | tr -d ',') +LATEST_REALTIME_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $8}' | tr -d ',') if [ -z "$LATEST_HELPER_VERSION" ]; then LATEST_HELPER_VERSION=latest @@ -705,10 +705,10 @@ else fi echo -e "5. Download required files from CDN. " -curl -fsSL $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml -curl -fsSL $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml -curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production -curl -fsSL $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh +curl -fsSL -L $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml +curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml +curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production +curl -fsSL -L $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh echo -e "6. Setting up environment variable file" diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 14eede4ee..0d3896647 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -64,9 +64,45 @@ if [ -f /root/.docker/config.json ]; then DOCKER_CONFIG_MOUNT="-v /root/.docker/config.json:/root/.docker/config.json" fi -if [ -f /data/coolify/source/docker-compose.custom.yml ]; then - echo "docker-compose.custom.yml detected." >>"$LOGFILE" - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 -else - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 -fi +# Pull all required images before stopping containers +# This ensures we don't take down the system if image pull fails (rate limits, network issues, etc.) +echo "Pulling required Docker images..." >>"$LOGFILE" +docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE}" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify image. Aborting upgrade." >>"$LOGFILE"; exit 1; } +docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify helper image. Aborting upgrade." >>"$LOGFILE"; exit 1; } +docker pull postgres:15-alpine >>"$LOGFILE" 2>&1 || { echo "Failed to pull PostgreSQL image. Aborting upgrade." >>"$LOGFILE"; exit 1; } +docker pull redis:7-alpine >>"$LOGFILE" 2>&1 || { echo "Failed to pull Redis image. Aborting upgrade." >>"$LOGFILE"; exit 1; } +# Pull realtime image - version is hardcoded in docker-compose.prod.yml, extract it or use a known version +docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify realtime image. Aborting upgrade." >>"$LOGFILE"; exit 1; } +echo "All images pulled successfully." >>"$LOGFILE" + +# Stop and remove existing Coolify containers to prevent conflicts +# This handles both old installations (project "source") and new ones (project "coolify") +# Use nohup to ensure the script continues even if SSH connection is lost +echo "Starting container restart sequence (detached)..." >>"$LOGFILE" + +nohup bash -c " + LOGFILE='$LOGFILE' + DOCKER_CONFIG_MOUNT='$DOCKER_CONFIG_MOUNT' + REGISTRY_URL='$REGISTRY_URL' + LATEST_HELPER_VERSION='$LATEST_HELPER_VERSION' + LATEST_IMAGE='$LATEST_IMAGE' + + # Stop and remove containers + echo 'Stopping existing Coolify containers...' >>\"\$LOGFILE\" + for container in coolify coolify-db coolify-redis coolify-realtime; do + if docker ps -a --format '{{.Names}}' | grep -q \"^\${container}\$\"; then + docker stop \"\$container\" >>\"\$LOGFILE\" 2>&1 || true + docker rm \"\$container\" >>\"\$LOGFILE\" 2>&1 || true + echo \" - Removed container: \$container\" >>\"\$LOGFILE\" + fi + done + + # Start new containers + if [ -f /data/coolify/source/docker-compose.custom.yml ]; then + echo 'docker-compose.custom.yml detected.' >>\"\$LOGFILE\" + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 + else + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 + fi + echo 'Upgrade completed.' >>\"\$LOGFILE\" +" >>"$LOGFILE" 2>&1 & diff --git a/other/nightly/versions.json b/other/nightly/versions.json index bb9b51ab1..94c23ede4 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.445" + "version": "4.0.0-beta.455" }, "nightly": { - "version": "4.0.0-beta.446" + "version": "4.0.0-beta.456" }, "helper": { "version": "1.0.12" @@ -13,7 +13,17 @@ "version": "1.0.10" }, "sentinel": { - "version": "0.0.16" + "version": "0.0.18" } + }, + "traefik": { + "v3.6": "3.6.1", + "v3.5": "3.5.6", + "v3.4": "3.4.5", + "v3.3": "3.3.7", + "v3.2": "3.2.5", + "v3.1": "3.1.7", + "v3.0": "3.0.4", + "v2.11": "2.11.31" } } \ No newline at end of file diff --git a/public/svgs/fizzy.png b/public/svgs/fizzy.png new file mode 100644 index 000000000..44efbd781 Binary files /dev/null and b/public/svgs/fizzy.png differ diff --git a/public/svgs/garage.svg b/public/svgs/garage.svg new file mode 100644 index 000000000..18aedeaaf --- /dev/null +++ b/public/svgs/garage.svg @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/rustfs.png b/public/svgs/rustfs.png new file mode 100644 index 000000000..927b8c5c4 Binary files /dev/null and b/public/svgs/rustfs.png differ diff --git a/public/svgs/rustfs.svg b/public/svgs/rustfs.svg new file mode 100644 index 000000000..18e9b8418 --- /dev/null +++ b/public/svgs/rustfs.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/svgs/unsend.svg b/public/svgs/unsend.svg deleted file mode 100644 index f5ff6fabc..000000000 --- a/public/svgs/unsend.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/svgs/usesend.svg b/public/svgs/usesend.svg new file mode 100644 index 000000000..067a3f569 --- /dev/null +++ b/public/svgs/usesend.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/css/app.css b/resources/css/app.css index 70759e542..30371d307 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -18,6 +18,16 @@ @theme { --color-base: #101010; --color-warning: #fcd452; + --color-warning-50: #fefce8; + --color-warning-100: #fef9c3; + --color-warning-200: #fef08a; + --color-warning-300: #fde047; + --color-warning-400: #fcd452; + --color-warning-500: #facc15; + --color-warning-600: #ca8a04; + --color-warning-700: #a16207; + --color-warning-800: #854d0e; + --color-warning-900: #713f12; --color-success: #22C55E; --color-error: #dc2626; --color-coollabs-50: #f5f0ff; @@ -175,4 +185,15 @@ .input[type="password"] { .lds-heart { animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1); +} + +.log-highlight { + background-color: rgba(234, 179, 8, 0.4); + border-radius: 2px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.dark .log-highlight { + background-color: rgba(234, 179, 8, 0.3); } \ No newline at end of file diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 2899ea1e5..abb177835 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -49,7 +49,7 @@ @utility input-sticky { } @utility input-sticky-active { - @apply text-black border-2 border-coollabs dark:text-white focus:bg-neutral-200 dark:focus:bg-coolgray-400 focus:border-coollabs; + @apply text-black border-2 border-coollabs dark:border-warning dark:text-white focus:bg-neutral-200 dark:focus:bg-coolgray-400 focus:border-coollabs dark:focus:border-warning; } /* Focus */ @@ -154,7 +154,7 @@ @utility badge { } @utility badge-dashboard { - @apply absolute top-0 right-0 w-2.5 h-2.5 rounded-bl-full text-xs font-bold leading-none border border-neutral-200 dark:border-black; + @apply absolute top-1 right-1 w-2.5 h-2.5 rounded-full text-xs font-bold leading-none border border-neutral-200 dark:border-black; } @utility badge-success { @@ -229,6 +229,10 @@ @utility box-without-bg-without-border { @apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem]; } +@utility coolbox { + @apply relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem]; +} + @utility on-box { @apply rounded-sm hover:bg-neutral-300 dark:hover:bg-coolgray-500/20; } diff --git a/resources/js/terminal.js b/resources/js/terminal.js index b49aad9cf..6707bec98 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -33,6 +33,9 @@ export function initializeTerminalComponent() { // Resize handling resizeObserver: null, resizeTimeout: null, + // Visibility handling - prevent disconnects when tab loses focus + isDocumentVisible: true, + wasConnectedBeforeHidden: false, init() { this.setupTerminal(); @@ -92,6 +95,11 @@ export function initializeTerminalComponent() { }, { once: true }); }); + // Handle visibility changes to prevent disconnects when tab loses focus + document.addEventListener('visibilitychange', () => { + this.handleVisibilityChange(); + }); + window.onresize = () => { this.resizeTerminal() }; @@ -451,6 +459,11 @@ export function initializeTerminalComponent() { }, keepAlive() { + // Skip keepalive when document is hidden to prevent unnecessary disconnects + if (!this.isDocumentVisible) { + return; + } + if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.sendMessage({ ping: true }); } else if (this.connectionState === 'disconnected') { @@ -459,6 +472,35 @@ export function initializeTerminalComponent() { } }, + handleVisibilityChange() { + const wasVisible = this.isDocumentVisible; + this.isDocumentVisible = !document.hidden; + + if (!this.isDocumentVisible) { + // Tab is now hidden - pause heartbeat monitoring to prevent false disconnects + this.wasConnectedBeforeHidden = this.connectionState === 'connected'; + if (this.pingTimeoutId) { + clearTimeout(this.pingTimeoutId); + this.pingTimeoutId = null; + } + console.log('[Terminal] Tab hidden, pausing heartbeat monitoring'); + } else if (wasVisible === false) { + // Tab is now visible again + console.log('[Terminal] Tab visible, resuming connection management'); + + if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) { + // Send immediate ping to verify connection is still alive + this.heartbeatMissed = 0; + this.sendMessage({ ping: true }); + this.resetPingTimeout(); + } else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') { + // Was connected before but now disconnected - attempt reconnection + this.reconnectAttempts = 0; + this.initializeWebSocket(); + } + } + }, + resetPingTimeout() { if (this.pingTimeoutId) { clearTimeout(this.pingTimeoutId); diff --git a/resources/views/components/callout.blade.php b/resources/views/components/callout.blade.php index 67da3ba7f..ec99729ef 100644 --- a/resources/views/components/callout.blade.php +++ b/resources/views/components/callout.blade.php @@ -2,7 +2,7 @@ @php $icons = [ - 'warning' => '', + 'warning' => '', 'danger' => '', @@ -13,10 +13,10 @@ $colors = [ 'warning' => [ - 'bg' => 'bg-yellow-50 dark:bg-yellow-900/30', - 'border' => 'border-yellow-300 dark:border-yellow-800', - 'title' => 'text-yellow-800 dark:text-yellow-300', - 'text' => 'text-yellow-700 dark:text-yellow-200' + 'bg' => 'bg-warning-50 dark:bg-warning-900/30', + 'border' => 'border-warning-300 dark:border-warning-800', + 'title' => 'text-warning-800 dark:text-warning-300', + 'text' => 'text-warning-700 dark:text-warning-200' ], 'danger' => [ 'bg' => 'bg-red-50 dark:bg-red-900/30', diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php new file mode 100644 index 000000000..2466a57f9 --- /dev/null +++ b/resources/views/components/forms/env-var-input.blade.php @@ -0,0 +1,274 @@ +
    + @if ($label) + + @endif + +
    + + @if ($type === 'password' && $allowToPeak) +
    + + + + + +
    + @endif + + merge(['class' => $defaultClass]) }} + @required($required) + @readonly($readonly) + @if ($modelBinding !== 'null') + wire:model="{{ $modelBinding }}" + wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" + @endif + wire:loading.attr="disabled" + @if ($type === 'password') + :type="type" + @else + type="{{ $type }}" + @endif + @disabled($disabled) + @if ($htmlId !== 'null') id="{{ $htmlId }}" @endif + name="{{ $name }}" + placeholder="{{ $attributes->get('placeholder') }}" + @if ($autofocus) autofocus @endif> + + {{-- Dropdown for suggestions --}} +
    + + + +
    + +
    +
    +
    + + @if (!$label && $helper) + + @endif + @error($modelBinding) + + @enderror +
    diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index edff3b6bf..73939092e 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -29,17 +29,23 @@ @php use App\Models\InstanceSettings; + // Global setting to disable ALL two-step confirmation (text + password) $disableTwoStepConfirmation = data_get(InstanceSettings::get(), 'disable_two_step_confirmation'); + // Skip ONLY password confirmation for OAuth users (they have no password) + $skipPasswordConfirmation = shouldSkipPasswordConfirmation(); if ($temporaryDisableTwoStepConfirmation) { $disableTwoStepConfirmation = false; + $skipPasswordConfirmation = false; } + // When password step is skipped, Step 2 becomes final - change button text from "Continue" to "Confirm" + $effectiveStep2ButtonText = ($skipPasswordConfirmation && $step2ButtonText === 'Continue') ? 'Confirm' : $step2ButtonText; @endphp
    - @if (!$disableTwoStepConfirmation) + @if (!$skipPasswordConfirmation)
    Please enter your password to confirm this destructive action. diff --git a/resources/views/components/resource-view.blade.php b/resources/views/components/resource-view.blade.php index ff8e99074..3d216d8da 100644 --- a/resources/views/components/resource-view.blade.php +++ b/resources/views/components/resource-view.blade.php @@ -1,27 +1,22 @@ -
    !$upgrade, - 'hover:border-l-red-500 cursor-not-allowed' => $upgrade, -])> +
    $upgrade, + ])>
    -
    +
    {{ $logo }}
    -
    -
    +
    +
    {{ $title }}
    @if ($upgrade)
    {{ $upgrade }}
    @else -
    +
    {{ $description }}
    @endif
    - @isset($documentation) -
    - {{ $documentation }} - @endisset
    diff --git a/resources/views/components/slide-over.blade.php b/resources/views/components/slide-over.blade.php index 3cb8ec3ab..13769c7b6 100644 --- a/resources/views/components/slide-over.blade.php +++ b/resources/views/components/slide-over.blade.php @@ -1,7 +1,13 @@ @props(['closeWithX' => false, 'fullScreen' => false])
    merge(['class' => 'relative w-auto h-auto']) }}> +}" +x-init="$watch('slideOverOpen', value => { + if (!value) { + $dispatch('slideOverClosed') + } +})" +{{ $attributes->merge(['class' => 'relative w-auto h-auto']) }}> {{ $slot }}