diff --git a/.AI_INSTRUCTIONS_SYNC.md b/.AI_INSTRUCTIONS_SYNC.md new file mode 100644 index 000000000..bbe0a90e1 --- /dev/null +++ b/.AI_INSTRUCTIONS_SYNC.md @@ -0,0 +1,156 @@ +# AI Instructions Synchronization Guide + +This document explains how AI instructions are organized and synchronized across different AI tools used with Coolify. + +## Overview + +Coolify maintains AI instructions in two parallel systems: + +1. **CLAUDE.md** - For Claude Code (claude.ai/code) +2. **.cursor/rules/** - For Cursor IDE and other AI assistants + +Both systems share core principles but are optimized for their respective workflows. + +## Structure + +### CLAUDE.md +- **Purpose**: Condensed, workflow-focused guide for Claude Code +- **Format**: Single markdown file +- **Includes**: + - Quick-reference development commands + - High-level architecture overview + - Core patterns and guidelines + - Embedded Laravel Boost guidelines + - References to detailed .cursor/rules/ documentation + +### .cursor/rules/ +- **Purpose**: Detailed, topic-specific documentation +- **Format**: Multiple .mdc files organized by topic +- **Structure**: + - `README.mdc` - Main index and overview + - `cursor_rules.mdc` - Maintenance guidelines + - Topic-specific files (testing-patterns.mdc, security-patterns.mdc, etc.) +- **Used by**: Cursor IDE, Claude Code (for detailed reference), other AI assistants + +## Cross-References + +Both systems reference each other: + +- **CLAUDE.md** → references `.cursor/rules/` for detailed documentation +- **.cursor/rules/README.mdc** → references `CLAUDE.md` for Claude Code workflow +- **.cursor/rules/cursor_rules.mdc** → notes that changes should sync with CLAUDE.md + +## Maintaining Consistency + +When updating AI instructions, follow these guidelines: + +### 1. Core Principles (MUST be consistent) +- Laravel version (currently Laravel 12) +- PHP version (8.4) +- Testing execution rules (Docker for Feature tests, mocking for Unit tests) +- Security patterns and authorization requirements +- Code style requirements (Pint, PSR-12) + +### 2. Where to Make Changes + +**For workflow changes** (how to run commands, development setup): +- Primary: `CLAUDE.md` +- Secondary: `.cursor/rules/development-workflow.mdc` + +**For architectural patterns** (how code should be structured): +- Primary: `.cursor/rules/` topic files +- Secondary: Reference in `CLAUDE.md` "Additional Documentation" section + +**For testing patterns**: +- Both: Must be synchronized +- `CLAUDE.md` - Contains condensed testing execution rules +- `.cursor/rules/testing-patterns.mdc` - Contains detailed examples and patterns + +### 3. Update Checklist + +When making significant changes: + +- [ ] Identify if change affects core principles (version numbers, critical patterns) +- [ ] Update primary location (CLAUDE.md or .cursor/rules/) +- [ ] Check if update affects cross-referenced content +- [ ] Update secondary location if needed +- [ ] Verify cross-references are still accurate +- [ ] Run: `./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc` (if applicable) + +### 4. Common Inconsistencies to Watch + +- **Version numbers**: Laravel, PHP, package versions +- **Testing instructions**: Docker execution requirements +- **File paths**: Ensure relative paths work from root +- **Command syntax**: Docker commands, artisan commands +- **Architecture decisions**: Laravel 10 structure vs Laravel 12+ structure + +## File Organization + +``` +/ +├── CLAUDE.md # Claude Code instructions (condensed) +├── .AI_INSTRUCTIONS_SYNC.md # This file +└── .cursor/ + └── rules/ + ├── README.mdc # Index and overview + ├── cursor_rules.mdc # Maintenance guide + ├── testing-patterns.mdc # Testing details + ├── development-workflow.mdc # Dev setup details + ├── security-patterns.mdc # Security details + ├── application-architecture.mdc + ├── deployment-architecture.mdc + ├── database-patterns.mdc + ├── frontend-patterns.mdc + ├── api-and-routing.mdc + ├── form-components.mdc + ├── technology-stack.mdc + ├── project-overview.mdc + └── laravel-boost.mdc # Laravel-specific patterns +``` + +## Recent Updates + +### 2025-10-07 +- ✅ Added cross-references between CLAUDE.md and .cursor/rules/ +- ✅ Synchronized Laravel version (12) across all files +- ✅ Added comprehensive testing execution rules (Docker for Feature tests) +- ✅ Added test design philosophy (prefer mocking over database) +- ✅ Fixed inconsistencies in testing documentation +- ✅ Created this synchronization guide + +## Maintenance Commands + +```bash +# Check for version inconsistencies +grep -r "Laravel [0-9]" CLAUDE.md .cursor/rules/*.mdc + +# Check for PHP version consistency +grep -r "PHP [0-9]" CLAUDE.md .cursor/rules/*.mdc + +# Format all documentation +./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc + +# Search for specific patterns across all docs +grep -r "pattern_to_check" CLAUDE.md .cursor/rules/ +``` + +## Contributing + +When contributing documentation: + +1. Check both CLAUDE.md and .cursor/rules/ for existing documentation +2. Add to appropriate location(s) based on guidelines above +3. Add cross-references if creating new patterns +4. Update this file if changing organizational structure +5. Verify consistency before submitting PR + +## Questions? + +If unsure about where to document something: + +- **Quick reference / workflow** → CLAUDE.md +- **Detailed patterns / examples** → .cursor/rules/[topic].mdc +- **Both?** → Start with .cursor/rules/, then reference in CLAUDE.md + +When in doubt, prefer detailed documentation in .cursor/rules/ and concise references in CLAUDE.md. diff --git a/.cursor/rules/README.mdc b/.cursor/rules/README.mdc index 07f19a816..d0597bb72 100644 --- a/.cursor/rules/README.mdc +++ b/.cursor/rules/README.mdc @@ -9,6 +9,10 @@ alwaysApply: false This comprehensive set of Cursor Rules provides deep insights into **Coolify**, an open-source self-hostable alternative to Heroku/Netlify/Vercel. These rules will help you understand, navigate, and contribute to this complex Laravel-based deployment platform. +> **Cross-Reference**: This directory is for **detailed, topic-specific rules** used by Cursor IDE and other AI assistants. For Claude Code specifically, also see **[CLAUDE.md](mdc:CLAUDE.md)** which provides a condensed, workflow-focused guide. Both systems share core principles but are optimized for their respective tools. +> +> **Maintaining Rules**: When updating these rules, see **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for synchronization guidelines to keep CLAUDE.md and .cursor/rules/ consistent. + ## Rule Categories ### 🏗️ Architecture & Foundation @@ -71,7 +75,7 @@ Coolify uses a **team-based multi-tenancy** model where: - **Multi-server** support with SSH connections ### 3. Technology Stack -- **Backend**: Laravel 11 + PHP 8.4 +- **Backend**: Laravel 12 + PHP 8.4 - **Frontend**: Livewire 3.5 + Alpine.js + Tailwind CSS 4.1 - **Database**: PostgreSQL 15 + Redis 7 - **Containerization**: Docker + Docker Compose diff --git a/.cursor/rules/cursor_rules.mdc b/.cursor/rules/cursor_rules.mdc index 7dfae3de0..9edccd496 100644 --- a/.cursor/rules/cursor_rules.mdc +++ b/.cursor/rules/cursor_rules.mdc @@ -4,6 +4,12 @@ globs: .cursor/rules/*.mdc alwaysApply: true --- +# Cursor Rules Maintenance Guide + +> **Important**: These rules in `.cursor/rules/` are shared between Cursor IDE and other AI assistants. Changes here should be reflected in **[CLAUDE.md](mdc:CLAUDE.md)** when they affect core workflows or patterns. +> +> **Synchronization Guide**: See **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for detailed guidelines on maintaining consistency between CLAUDE.md and .cursor/rules/. + - **Required Rule Structure:** ```markdown --- diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc index 005ede849..c409a4647 100644 --- a/.cursor/rules/laravel-boost.mdc +++ b/.cursor/rules/laravel-boost.mdc @@ -185,7 +185,7 @@ protected function isAccessible(User $user, ?string $path = null): bool ### Database - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. -- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. ### Models - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. diff --git a/.cursor/rules/testing-patterns.mdc b/.cursor/rules/testing-patterns.mdc index a0e64dbae..8d250b56a 100644 --- a/.cursor/rules/testing-patterns.mdc +++ b/.cursor/rules/testing-patterns.mdc @@ -5,11 +5,56 @@ alwaysApply: false --- # Coolify Testing Architecture & Patterns +> **Cross-Reference**: These detailed testing patterns align with the testing guidelines in **[CLAUDE.md](mdc:CLAUDE.md)**. Both documents share the same core principles about Docker execution and mocking preferences. + ## Testing Philosophy Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions. -!Important: Always run tests inside `coolify` container. +### Test Execution Rules + +**CRITICAL**: Tests are categorized by database dependency: + +#### Unit Tests (`tests/Unit/`) +- **MUST NOT** use database connections +- **MUST** use mocking for models and external dependencies +- **CAN** run outside Docker: `./vendor/bin/pest tests/Unit` +- Purpose: Test isolated logic, helper functions, and business rules + +#### Feature Tests (`tests/Feature/`) +- **MAY** use database connections (factories, migrations, models) +- **MUST** run inside Docker container: `docker exec coolify php artisan test` +- **MUST** use `RefreshDatabase` trait if touching database +- Purpose: Test API endpoints, workflows, and integration scenarios + +**Rule of thumb**: If your test needs `Server::factory()->create()` or any database operation, it's a Feature test and MUST run in Docker. + +### Prefer Mocking Over Database + +When writing tests, always prefer mocking over real database operations: + +```php +// ❌ BAD: Unit test using database +it('extracts custom commands', function () { + $server = Server::factory()->create(['ip' => '1.2.3.4']); + $commands = extract_custom_proxy_commands($server, $yaml); + expect($commands)->toBeArray(); +}); + +// ✅ GOOD: Unit test using mocking +it('extracts custom commands', function () { + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn('traefik'); + $commands = extract_custom_proxy_commands($server, $yaml); + expect($commands)->toBeArray(); +}); +``` + +**Design principles for testable code:** +- Use dependency injection instead of global state +- Create interfaces for external dependencies (SSH, Docker, etc.) +- Separate business logic from data persistence +- Make functions accept interfaces instead of concrete models when possible ## Testing Framework Stack diff --git a/.github/workflows/chore-pr-comments.yml b/.github/workflows/chore-pr-comments.yml new file mode 100644 index 000000000..8836c6632 --- /dev/null +++ b/.github/workflows/chore-pr-comments.yml @@ -0,0 +1,56 @@ +name: Add comment based on label +on: + pull_request_target: + types: + - labeled +jobs: + add-comment: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + actions: none + checks: none + deployments: none + issues: none + packages: none + repository-projects: none + security-events: none + statuses: none + strategy: + matrix: + include: + - label: "⚙️ Service" + body: | + Hi @${{ github.event.pull_request.user.login }}! 👋 + + It appears to us that you are either adding a new service or making changes to an existing one. + We kindly ask you to also review and update the **Coolify Documentation** to include this new service or it's new configuration needs. + This will help ensure that our documentation remains accurate and up-to-date for all users. + + Coolify Docs Repository: https://github.com/coollabsio/coolify-docs + How to Contribute a new Service to the Docs: https://coolify.io/docs/get-started/contribute/service#adding-a-new-service-template-to-the-coolify-documentation + - label: "🛠️ Feature" + body: | + Hi @${{ github.event.pull_request.user.login }}! 👋 + + It appears to us that you are adding a new feature to Coolify. + We kindly ask you to also update the **Coolify Documentation** to include information about this new feature. + This will help ensure that our documentation remains accurate and up-to-date for all users. + + Coolify Docs Repository: https://github.com/coollabsio/coolify-docs + How to Contribute to the Docs: https://coolify.io/docs/get-started/contribute/documentation + # - label: "✨ Enhancement" + # body: | + # It appears to us that you are making an enhancement to Coolify. + # We kindly ask you to also review and update the Coolify Documentation to include information about this enhancement if applicable. + # This will help ensure that our documentation remains accurate and up-to-date for all users. + steps: + - name: Add comment + if: github.event.label.name == matrix.label + run: gh pr comment "$NUMBER" --body "$BODY" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.pull_request.number }} + BODY: ${{ matrix.body }} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index bc773072b..9daf0e90e 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -16,6 +16,8 @@ jobs: (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'Claude') || + (github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'Claude') || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: @@ -32,9 +34,9 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@beta + uses: anthropics/claude-code-action@v1 with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + anthropic_api_key: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | @@ -61,4 +63,3 @@ jobs: # Optional: Custom environment variables for Claude # claude_env: | # NODE_ENV: test - diff --git a/CHANGELOG.md b/CHANGELOG.md index 4360a7c49..aefabfd29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,32 +2,113 @@ # Changelog All notable changes to this project will be documented in this file. -## [unreleased] - -### 📚 Documentation - -- Update changelog - -### ⚙️ Miscellaneous Tasks - -- *(docker)* Add a blank line for improved readability in Dockerfile - -## [4.0.0-beta.428] - 2025-09-15 +## [4.0.0-beta.434] - 2025-10-03 ### 🚀 Features -- *(deployment)* Enhance deployment status reporting with detailed information on active deployments and team members +- *(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 -- *(application)* Improve watch paths handling by trimming and filtering empty paths to prevent unnecessary triggers +- *(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 -- *(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 +- *(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 @@ -35,13 +116,150 @@ ### 📚 Documentation ### ⚙️ Miscellaneous Tasks -- *(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 +- *(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 @@ -67,6 +285,9 @@ ### 🚀 Features - *(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 @@ -93,6 +314,13 @@ ### 🐛 Bug Fixes - *(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 @@ -132,6 +360,16 @@ ### 🚜 Refactor - *(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 @@ -145,6 +383,10 @@ ### ⚙️ Miscellaneous Tasks - 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 diff --git a/CLAUDE.md b/CLAUDE.md index 83b51d4a8..6c594955c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,10 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to **Claude Code** (claude.ai/code) when working with code in this repository. + +> **Note for AI Assistants**: This file is specifically for Claude Code. If you're using Cursor IDE, refer to the `.cursor/rules/` directory for detailed rule files. Both systems share core principles but are optimized for their respective workflows. +> +> **Maintaining Instructions**: When updating AI instructions, see [.AI_INSTRUCTIONS_SYNC.md](.AI_INSTRUCTIONS_SYNC.md) for synchronization guidelines between CLAUDE.md and .cursor/rules/. ## Project Overview @@ -23,7 +27,14 @@ ### Backend Development ### Code Quality - `./vendor/bin/pint` - Run Laravel Pint for code formatting - `./vendor/bin/phpstan` - Run PHPStan for static analysis -- `./vendor/bin/pest` - Run Pest tests +- `./vendor/bin/pest` - Run Pest tests (unit tests only, without database) + +### Running Tests +**IMPORTANT**: Tests that require database connections MUST be run inside the Docker container: +- **Inside Docker**: `docker exec coolify php artisan test` (for feature tests requiring database) +- **Outside Docker**: `./vendor/bin/pest tests/Unit` (for pure unit tests without database dependencies) +- Unit tests should use mocking and avoid database connections +- Feature tests that require database must be run in the `coolify` container ## Architecture Overview @@ -173,6 +184,21 @@ ### Testing Strategy - **Mocking**: Use Laravel's built-in mocking for external services - **Database**: Use RefreshDatabase trait for test isolation +#### Test Execution Environment +**CRITICAL**: Database-dependent tests MUST run inside Docker container: +- **Unit Tests** (`tests/Unit/`): Should NOT use database. Use mocking. Run with `./vendor/bin/pest tests/Unit` +- **Feature Tests** (`tests/Feature/`): May use database. MUST run inside Docker with `docker exec coolify php artisan test` +- If a test needs database (factories, migrations, etc.), it belongs in `tests/Feature/` +- Always mock external services and SSH connections in tests + +#### Test Design Philosophy +**PREFER MOCKING**: When designing features and writing tests: +- **Design for testability**: Structure code so it can be tested without database (use dependency injection, interfaces) +- **Mock by default**: Unit tests should mock models and external dependencies using Mockery +- **Avoid database when possible**: If you can test the logic without database, write it as a Unit test +- **Only use database when necessary**: Feature tests should test integration points, not isolated logic +- **Example**: Instead of `Server::factory()->create()`, use `Mockery::mock('App\Models\Server')` in unit tests + ### Routing Conventions - Group routes by middleware and prefix - Use route model binding for cleaner controllers @@ -228,7 +254,9 @@ ## Important Reminders ## Additional Documentation -For more detailed guidelines and patterns, refer to the `.cursor/rules/` directory: +This file contains high-level guidelines for Claude Code. For **more detailed, topic-specific documentation**, refer to the `.cursor/rules/` directory (also accessible by Cursor IDE and other AI assistants): + +> **Cross-Reference**: The `.cursor/rules/` directory contains comprehensive, detailed documentation organized by topic. Start with [.cursor/rules/README.mdc](.cursor/rules/README.mdc) for an overview, then explore specific topics below. ### Architecture & Patterns - [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure @@ -434,7 +462,7 @@ ### Laravel 10 Structure ### Database - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. -- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. ### Models - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. @@ -543,6 +571,10 @@ ### Pest Tests - You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. - Tests should test all of the happy paths, failure paths, and weird paths. - Tests live in the `tests/Feature` and `tests/Unit` directories. +- **Unit tests** MUST use mocking and avoid database. They can run outside Docker. +- **Feature tests** can use database but MUST run inside Docker container. +- **Design for testability**: Structure code to be testable without database when possible. Use dependency injection and interfaces. +- **Mock by default**: Prefer `Mockery::mock()` over `Model::factory()->create()` in unit tests. - Pest tests look and behave like this: it('is true', function () { @@ -551,11 +583,23 @@ ### Pest Tests ### Running Tests -- Run the minimal number of tests using an appropriate filter before finalizing code edits. -- To run all tests: `php artisan test`. -- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. -- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). -- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. +**IMPORTANT**: Always run tests in the correct environment based on database dependencies: + +**Unit Tests (no database):** +- Run outside Docker: `./vendor/bin/pest tests/Unit` +- Run specific file: `./vendor/bin/pest tests/Unit/ProxyCustomCommandsTest.php` +- These tests use mocking and don't require PostgreSQL + +**Feature Tests (with database):** +- Run inside Docker: `docker exec coolify php artisan test` +- Run specific file: `docker exec coolify php artisan test tests/Feature/ExampleTest.php` +- Filter by name: `docker exec coolify php artisan test --filter=testName` +- These tests require PostgreSQL and use factories/migrations + +**General Guidelines:** +- Run the minimal number of tests using an appropriate filter before finalizing code edits +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite +- If you get database connection errors, you're running a Feature test outside Docker - move it inside ### Pest Assertions - When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: @@ -650,5 +694,14 @@ ### Replaced Utilities ## Test Enforcement - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. - \ No newline at end of file +- Run the minimum number of tests needed to ensure code quality and speed. +- **For Unit tests**: Use `./vendor/bin/pest tests/Unit/YourTest.php` (runs outside Docker) +- **For Feature tests**: Use `docker exec coolify php artisan test --filter=YourTest` (runs inside Docker) +- Choose the correct test type based on database dependency: + - No database needed? → Unit test with mocking + - Database needed? → Feature test in Docker + + + +Random other things you should remember: +- App\Models\Application::team must return a relationship instance., always use team() \ No newline at end of file diff --git a/README.md b/README.md index f291a33e8..1c88f4c54 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ ## Big Sponsors * [QuantCDN](https://www.quantcdn.io?ref=coolify.io) - Enterprise-grade content delivery network * [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang * [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers -* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital transformation and web solutions +* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency * [Cloudify.ro](https://cloudify.ro?ref=coolify.io) - Cloud hosting solutions * [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half * [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 3bf91c281..3aa1d8d34 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -33,7 +33,13 @@ public function handle(Server $server, bool $forceRegenerate = false): string // 1. Force regenerate is requested // 2. Configuration file doesn't exist or is empty if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) { - $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value(); + // Extract custom commands from existing config before regenerating + $custom_commands = []; + if (! empty(trim($proxy_configuration ?? ''))) { + $custom_commands = extractCustomProxyCommands($server, $proxy_configuration); + } + + $proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value(); } if (empty($proxy_configuration)) { diff --git a/app/Console/Commands/CloudDeleteUser.php b/app/Console/Commands/Cloud/CloudDeleteUser.php similarity index 83% rename from app/Console/Commands/CloudDeleteUser.php rename to app/Console/Commands/Cloud/CloudDeleteUser.php index 6928eb97b..a2ea9b3e5 100644 --- a/app/Console/Commands/CloudDeleteUser.php +++ b/app/Console/Commands/Cloud/CloudDeleteUser.php @@ -1,6 +1,6 @@ logAction("Starting user deletion process for: {$email}"); + // Implement file lock to prevent concurrent deletions of the same user + $lockKey = "user_deletion_{$this->user->id}"; + $lock = Cache::lock($lockKey, 600); // 10 minute lock - // Phase 1: Show User Overview (outside transaction) - if (! $this->showUserOverview()) { - $this->info('User deletion cancelled.'); + if (! $lock->get()) { + $this->error('Another deletion process is already running for this user. Please try again later.'); + $this->logAction("Deletion blocked for user {$email}: Another process is already running"); - return 0; + return 1; } - // If not dry run, wrap everything in a transaction - if (! $this->isDryRun) { - try { - DB::beginTransaction(); + try { + $this->logAction("Starting user deletion process for: {$email}"); + // Phase 1: Show User Overview (outside transaction) + if (! $this->showUserOverview()) { + $this->info('User deletion cancelled.'); + $lock->release(); + + return 0; + } + + // If not dry run, wrap everything in a transaction + if (! $this->isDryRun) { + try { + DB::beginTransaction(); + + // Phase 2: Delete Resources + if (! $this->skipResources) { + if (! $this->deleteResources()) { + DB::rollBack(); + $this->error('User deletion failed at resource deletion phase. All changes rolled back.'); + + return 1; + } + } + + // Phase 3: Delete Servers + if (! $this->deleteServers()) { + DB::rollBack(); + $this->error('User deletion failed at server deletion phase. All changes rolled back.'); + + return 1; + } + + // Phase 4: Handle Teams + if (! $this->handleTeams()) { + DB::rollBack(); + $this->error('User deletion failed at team handling phase. All changes rolled back.'); + + return 1; + } + + // Phase 5: Cancel Stripe Subscriptions + if (! $this->skipStripe && isCloud()) { + if (! $this->cancelStripeSubscriptions()) { + DB::rollBack(); + $this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.'); + + return 1; + } + } + + // Phase 6: Delete User Profile + if (! $this->deleteUserProfile()) { + DB::rollBack(); + $this->error('User deletion failed at final phase. All changes rolled back.'); + + return 1; + } + + // Commit the transaction + DB::commit(); + + $this->newLine(); + $this->info('✅ User deletion completed successfully!'); + $this->logAction("User deletion completed for: {$email}"); + + } catch (\Exception $e) { + DB::rollBack(); + $this->error('An error occurred during user deletion: '.$e->getMessage()); + $this->logAction("User deletion failed for {$email}: ".$e->getMessage()); + + return 1; + } + } else { + // Dry run mode - just run through the phases without transaction // Phase 2: Delete Resources if (! $this->skipResources) { if (! $this->deleteResources()) { - DB::rollBack(); - $this->error('User deletion failed at resource deletion phase. All changes rolled back.'); + $this->info('User deletion would be cancelled at resource deletion phase.'); - return 1; + return 0; } } // Phase 3: Delete Servers if (! $this->deleteServers()) { - DB::rollBack(); - $this->error('User deletion failed at server deletion phase. All changes rolled back.'); + $this->info('User deletion would be cancelled at server deletion phase.'); - return 1; + return 0; } // Phase 4: Handle Teams if (! $this->handleTeams()) { - DB::rollBack(); - $this->error('User deletion failed at team handling phase. All changes rolled back.'); + $this->info('User deletion would be cancelled at team handling phase.'); - return 1; + return 0; } // Phase 5: Cancel Stripe Subscriptions if (! $this->skipStripe && isCloud()) { if (! $this->cancelStripeSubscriptions()) { - DB::rollBack(); - $this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.'); + $this->info('User deletion would be cancelled at Stripe cancellation phase.'); - return 1; + return 0; } } // Phase 6: Delete User Profile if (! $this->deleteUserProfile()) { - DB::rollBack(); - $this->error('User deletion failed at final phase. All changes rolled back.'); + $this->info('User deletion would be cancelled at final phase.'); - return 1; + return 0; } - // Commit the transaction - DB::commit(); - $this->newLine(); - $this->info('✅ User deletion completed successfully!'); - $this->logAction("User deletion completed for: {$email}"); - - } catch (\Exception $e) { - DB::rollBack(); - $this->error('An error occurred during user deletion: '.$e->getMessage()); - $this->logAction("User deletion failed for {$email}: ".$e->getMessage()); - - return 1; - } - } else { - // Dry run mode - just run through the phases without transaction - // Phase 2: Delete Resources - if (! $this->skipResources) { - if (! $this->deleteResources()) { - $this->info('User deletion would be cancelled at resource deletion phase.'); - - return 0; - } + $this->info('✅ DRY RUN completed successfully! No data was deleted.'); } - // Phase 3: Delete Servers - if (! $this->deleteServers()) { - $this->info('User deletion would be cancelled at server deletion phase.'); - - return 0; - } - - // Phase 4: Handle Teams - if (! $this->handleTeams()) { - $this->info('User deletion would be cancelled at team handling phase.'); - - return 0; - } - - // Phase 5: Cancel Stripe Subscriptions - if (! $this->skipStripe && isCloud()) { - if (! $this->cancelStripeSubscriptions()) { - $this->info('User deletion would be cancelled at Stripe cancellation phase.'); - - return 0; - } - } - - // Phase 6: Delete User Profile - if (! $this->deleteUserProfile()) { - $this->info('User deletion would be cancelled at final phase.'); - - return 0; - } - - $this->newLine(); - $this->info('✅ DRY RUN completed successfully! No data was deleted.'); + return 0; + } finally { + // Ensure lock is always released + $lock->release(); } - - return 0; } private function showUserOverview(): bool @@ -683,24 +701,21 @@ private function deleteUserProfile(): bool private function getSubscriptionMonthlyValue(string $planId): int { - // Map plan IDs to monthly values based on config - $subscriptionConfigs = config('subscription'); + // Try to get pricing from subscription metadata or config + // Since we're using dynamic pricing, return 0 for now + // This could be enhanced by fetching the actual price from Stripe API - foreach ($subscriptionConfigs as $key => $value) { - if ($value === $planId && str_contains($key, 'stripe_price_id_')) { - // Extract price from key pattern: stripe_price_id_basic_monthly -> basic - $planType = str($key)->after('stripe_price_id_')->before('_')->toString(); + // Check if this is a dynamic pricing plan + $dynamicMonthlyPlanId = config('subscription.stripe_price_id_dynamic_monthly'); + $dynamicYearlyPlanId = config('subscription.stripe_price_id_dynamic_yearly'); - // Map to known prices (you may need to adjust these based on your actual pricing) - return match ($planType) { - 'basic' => 29, - 'pro' => 49, - 'ultimate' => 99, - default => 0 - }; - } + if ($planId === $dynamicMonthlyPlanId || $planId === $dynamicYearlyPlanId) { + // For dynamic pricing, we can't determine the exact amount without calling Stripe API + // Return 0 to indicate dynamic/usage-based pricing + return 0; } + // For any other plans, return 0 as we don't have hardcoded prices return 0; } @@ -716,6 +731,13 @@ private function logAction(string $message): void // Also log to a dedicated user deletion log file $logFile = storage_path('logs/user-deletions.log'); + + // Ensure the logs directory exists + $logDir = dirname($logFile); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + $timestamp = now()->format('Y-m-d H:i:s'); file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX); } diff --git a/app/Console/Commands/Cloud/CloudFixSubscription.php b/app/Console/Commands/Cloud/CloudFixSubscription.php new file mode 100644 index 000000000..194e9bb5f --- /dev/null +++ b/app/Console/Commands/Cloud/CloudFixSubscription.php @@ -0,0 +1,879 @@ +option('verify-all')) { + return $this->verifyAllActiveSubscriptions($stripe); + } + + if ($this->option('fix-canceled-subs') || $this->option('dry-run')) { + return $this->fixCanceledSubscriptions($stripe); + } + + $activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get(); + + $out = fopen('php://output', 'w'); + // CSV header + fputcsv($out, [ + 'team_id', + 'invoice_status', + 'stripe_customer_url', + 'stripe_subscription_id', + 'subscription_status', + 'subscription_url', + 'note', + ]); + + foreach ($activeSubscribers as $team) { + $stripeSubscriptionId = $team->subscription->stripe_subscription_id; + $stripeInvoicePaid = $team->subscription->stripe_invoice_paid; + $stripeCustomerId = $team->subscription->stripe_customer_id; + + if (! $stripeSubscriptionId && str($stripeInvoicePaid)->lower() != 'past_due') { + fputcsv($out, [ + $team->id, + $stripeInvoicePaid, + $stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null, + null, + null, + null, + 'Missing subscription ID while invoice not past_due', + ]); + + continue; + } + + if (! $stripeSubscriptionId) { + // No subscription ID and invoice is past_due, still record for visibility + fputcsv($out, [ + $team->id, + $stripeInvoicePaid, + $stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null, + null, + null, + null, + 'Missing subscription ID', + ]); + + continue; + } + + $subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId); + if ($subscription->status === 'active') { + continue; + } + + fputcsv($out, [ + $team->id, + $stripeInvoicePaid, + $stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null, + $stripeSubscriptionId, + $subscription->status, + "https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}", + 'Subscription not active', + ]); + } + + fclose($out); + } + + /** + * Fix canceled subscriptions in the database + */ + private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe) + { + $isDryRun = $this->option('dry-run'); + $checkOne = $this->option('one'); + + if ($isDryRun) { + $this->info('DRY RUN MODE - No changes will be made'); + if ($checkOne) { + $this->info('Checking only the first canceled subscription...'); + } else { + $this->info('Checking for canceled subscriptions...'); + } + } else { + if ($checkOne) { + $this->info('Checking and fixing only the first canceled subscription...'); + } else { + $this->info('Checking and fixing canceled subscriptions...'); + } + } + + $teamsWithSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get(); + $toFixCount = 0; + $fixedCount = 0; + $errors = []; + $canceledSubscriptions = []; + + foreach ($teamsWithSubscriptions as $team) { + $subscription = $team->subscription; + + if (! $subscription->stripe_subscription_id) { + continue; + } + + try { + $stripeSubscription = $stripe->subscriptions->retrieve( + $subscription->stripe_subscription_id + ); + + if ($stripeSubscription->status === 'canceled') { + $toFixCount++; + + // Get team members' emails + $memberEmails = $team->members->pluck('email')->toArray(); + + $canceledSubscriptions[] = [ + 'team_id' => $team->id, + 'team_name' => $team->name, + 'customer_id' => $subscription->stripe_customer_id, + 'subscription_id' => $subscription->stripe_subscription_id, + 'status' => 'canceled', + 'member_emails' => $memberEmails, + 'subscription_model' => $subscription->toArray(), + ]; + + if ($isDryRun) { + $this->warn('Would fix canceled subscription:'); + $this->line(" Team ID: {$team->id}"); + $this->line(" Team Name: {$team->name}"); + $this->line(' Team Members: '.implode(', ', $memberEmails)); + $this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}"); + $this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}"); + $this->line(' Current Subscription Data:'); + foreach ($subscription->getAttributes() as $key => $value) { + if (is_null($value)) { + $this->line(" - {$key}: null"); + } elseif (is_bool($value)) { + $this->line(" - {$key}: ".($value ? 'true' : 'false')); + } else { + $this->line(" - {$key}: {$value}"); + } + } + $this->newLine(); + } else { + $this->warn("Found canceled subscription for Team ID: {$team->id}"); + + // Send internal notification with all details before fixing + $notificationMessage = "Fixing canceled subscription:\n"; + $notificationMessage .= "Team ID: {$team->id}\n"; + $notificationMessage .= "Team Name: {$team->name}\n"; + $notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n"; + $notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n"; + $notificationMessage .= "Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}\n"; + $notificationMessage .= "Subscription Data:\n"; + foreach ($subscription->getAttributes() as $key => $value) { + if (is_null($value)) { + $notificationMessage .= " - {$key}: null\n"; + } elseif (is_bool($value)) { + $notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n"; + } else { + $notificationMessage .= " - {$key}: {$value}\n"; + } + } + send_internal_notification($notificationMessage); + + // Apply the same logic as customer.subscription.deleted webhook + $team->subscriptionEnded(); + + $fixedCount++; + $this->info(" ✓ Fixed subscription for Team ID: {$team->id}"); + $this->line(' Team Members: '.implode(', ', $memberEmails)); + $this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}"); + $this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}"); + } + + // Break if --one flag is set + if ($checkOne) { + break; + } + } + } catch (\Stripe\Exception\InvalidRequestException $e) { + if ($e->getStripeCode() === 'resource_missing') { + $toFixCount++; + + // Get team members' emails + $memberEmails = $team->members->pluck('email')->toArray(); + + $canceledSubscriptions[] = [ + 'team_id' => $team->id, + 'team_name' => $team->name, + 'customer_id' => $subscription->stripe_customer_id, + 'subscription_id' => $subscription->stripe_subscription_id, + 'status' => 'missing', + 'member_emails' => $memberEmails, + 'subscription_model' => $subscription->toArray(), + ]; + + if ($isDryRun) { + $this->error('Would fix missing subscription (not found in Stripe):'); + $this->line(" Team ID: {$team->id}"); + $this->line(" Team Name: {$team->name}"); + $this->line(' Team Members: '.implode(', ', $memberEmails)); + $this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}"); + $this->line(" Subscription ID (missing): {$subscription->stripe_subscription_id}"); + $this->line(' Current Subscription Data:'); + foreach ($subscription->getAttributes() as $key => $value) { + if (is_null($value)) { + $this->line(" - {$key}: null"); + } elseif (is_bool($value)) { + $this->line(" - {$key}: ".($value ? 'true' : 'false')); + } else { + $this->line(" - {$key}: {$value}"); + } + } + $this->newLine(); + } else { + $this->error("Subscription not found in Stripe for Team ID: {$team->id}"); + + // Send internal notification with all details before fixing + $notificationMessage = "Fixing missing subscription (not found in Stripe):\n"; + $notificationMessage .= "Team ID: {$team->id}\n"; + $notificationMessage .= "Team Name: {$team->name}\n"; + $notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n"; + $notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n"; + $notificationMessage .= "Subscription ID (missing): {$subscription->stripe_subscription_id}\n"; + $notificationMessage .= "Subscription Data:\n"; + foreach ($subscription->getAttributes() as $key => $value) { + if (is_null($value)) { + $notificationMessage .= " - {$key}: null\n"; + } elseif (is_bool($value)) { + $notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n"; + } else { + $notificationMessage .= " - {$key}: {$value}\n"; + } + } + send_internal_notification($notificationMessage); + + // Apply the same logic as customer.subscription.deleted webhook + $team->subscriptionEnded(); + + $fixedCount++; + $this->info(" ✓ Fixed missing subscription for Team ID: {$team->id}"); + $this->line(' Team Members: '.implode(', ', $memberEmails)); + $this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}"); + } + + // Break if --one flag is set + if ($checkOne) { + break; + } + } else { + $errors[] = "Team ID {$team->id}: ".$e->getMessage(); + } + } catch (\Exception $e) { + $errors[] = "Team ID {$team->id}: ".$e->getMessage(); + } + } + + $this->newLine(); + $this->info('Summary:'); + + if ($isDryRun) { + $this->info(" - Found {$toFixCount} canceled/missing subscriptions that would be fixed"); + + if ($toFixCount > 0) { + $this->newLine(); + $this->comment('Run with --fix-canceled-subs to apply these changes'); + } + } else { + $this->info(" - Fixed {$fixedCount} canceled/missing subscriptions"); + } + + if (! empty($errors)) { + $this->newLine(); + $this->error('Errors encountered:'); + foreach ($errors as $error) { + $this->error(" - {$error}"); + } + } + + return 0; + } + + /** + * Verify all active subscriptions against Stripe API + */ + private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe) + { + $isDryRun = $this->option('dry-run'); + $shouldFix = $this->option('fix-verified'); + + $this->info('Verifying all active subscriptions against Stripe...'); + if ($isDryRun) { + $this->info('DRY RUN MODE - No changes will be made'); + } + if ($shouldFix && ! $isDryRun) { + $this->warn('FIX MODE - Discrepancies will be corrected'); + } + + // Get all teams with active subscriptions + $teamsWithActiveSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get(); + $totalCount = $teamsWithActiveSubscriptions->count(); + + $this->info("Found {$totalCount} teams with active subscriptions in database"); + $this->newLine(); + + $out = fopen('php://output', 'w'); + + // CSV header + fputcsv($out, [ + 'team_id', + 'team_name', + 'customer_id', + 'subscription_id', + 'db_status', + 'stripe_status', + 'action', + 'member_emails', + 'customer_url', + 'subscription_url', + ]); + + $stats = [ + 'total' => $totalCount, + 'valid_active' => 0, + 'valid_past_due' => 0, + 'canceled' => 0, + 'missing' => 0, + 'invalid' => 0, + 'fixed' => 0, + 'errors' => 0, + ]; + + $processedCount = 0; + + foreach ($teamsWithActiveSubscriptions as $team) { + $subscription = $team->subscription; + $memberEmails = $team->members->pluck('email')->toArray(); + + // Database state + $dbStatus = 'active'; + if ($subscription->stripe_past_due) { + $dbStatus = 'past_due'; + } + + $stripeStatus = null; + $action = 'none'; + + if (! $subscription->stripe_subscription_id) { + $this->line("Team {$team->id}: Missing subscription ID, searching in Stripe..."); + + $foundResult = null; + $searchMethod = null; + + // Search by customer ID + if ($subscription->stripe_customer_id) { + $this->line(" → Searching by customer ID: {$subscription->stripe_customer_id}"); + $foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id); + if ($foundResult) { + $searchMethod = $foundResult['method']; + } + } else { + $this->line(' → No customer ID available'); + } + + // Search by emails if not found + if (! $foundResult && count($memberEmails) > 0) { + $foundResult = $this->searchSubscriptionsByEmails($stripe, $memberEmails); + if ($foundResult) { + $searchMethod = $foundResult['method']; + + // Update customer ID if different + if (isset($foundResult['customer_id']) && $subscription->stripe_customer_id !== $foundResult['customer_id']) { + if ($isDryRun) { + $this->warn(" ⚠ Would update customer ID from {$subscription->stripe_customer_id} to {$foundResult['customer_id']}"); + } elseif ($shouldFix) { + $subscription->update(['stripe_customer_id' => $foundResult['customer_id']]); + $this->info(" ✓ Updated customer ID to {$foundResult['customer_id']}"); + } + } + } + } + + if ($foundResult && isset($foundResult['subscription'])) { + // Check if it's an active/past_due subscription + if (in_array($foundResult['status'], ['active', 'past_due'])) { + // Found an active subscription, handle update + $result = $this->handleFoundSubscription( + $team, + $subscription, + $foundResult['subscription'], + $searchMethod, + $isDryRun, + $shouldFix, + $stats + ); + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $result['id'], + $dbStatus, + $result['status'], + $result['action'], + implode(', ', $memberEmails), + $subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A', + $result['url'], + ]); + } else { + // Found subscription but it's canceled/expired - needs to be deactivated + $this->warn(" → Found {$foundResult['status']} subscription {$foundResult['subscription']->id} - needs deactivation"); + + $result = $this->handleMissingSubscription($team, $subscription, $foundResult['status'], $isDryRun, $shouldFix, $stats); + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $foundResult['subscription']->id, + $dbStatus, + $foundResult['status'], + 'needs_fix', + implode(', ', $memberEmails), + $subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A', + "https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}", + ]); + } + } else { + // No subscription found at all + $this->line(' → No subscription found'); + + $stripeStatus = 'not_found'; + $result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats); + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + 'N/A', + $dbStatus, + $result['status'], + $result['action'], + implode(', ', $memberEmails), + $subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A', + 'N/A', + ]); + } + } else { + // First validate the subscription ID format + if (! str_starts_with($subscription->stripe_subscription_id, 'sub_')) { + $this->warn(" ⚠ Invalid subscription ID format (doesn't start with 'sub_')"); + } + + try { + $stripeSubscription = $stripe->subscriptions->retrieve( + $subscription->stripe_subscription_id + ); + + $stripeStatus = $stripeSubscription->status; + + // Determine if action is needed + switch ($stripeStatus) { + case 'active': + $stats['valid_active']++; + $action = 'valid'; + break; + + case 'past_due': + $stats['valid_past_due']++; + $action = 'valid'; + // Ensure past_due flag is set + if (! $subscription->stripe_past_due) { + if ($isDryRun) { + $this->info("Would set stripe_past_due=true for Team {$team->id}"); + } elseif ($shouldFix) { + $subscription->update(['stripe_past_due' => true]); + } + } + break; + + case 'canceled': + case 'incomplete_expired': + case 'unpaid': + case 'incomplete': + $stats['canceled']++; + $action = 'needs_fix'; + + // Only output problematic subscriptions + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $subscription->stripe_subscription_id, + $dbStatus, + $stripeStatus, + $action, + implode(', ', $memberEmails), + "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}", + "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}", + ]); + + if ($isDryRun) { + $this->info("Would deactivate subscription for Team {$team->id} - status: {$stripeStatus}"); + } elseif ($shouldFix) { + $this->fixSubscription($team, $subscription, $stripeStatus); + $stats['fixed']++; + } + break; + + default: + $stats['invalid']++; + $action = 'unknown'; + + // Only output problematic subscriptions + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $subscription->stripe_subscription_id, + $dbStatus, + $stripeStatus, + $action, + implode(', ', $memberEmails), + "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}", + "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}", + ]); + break; + } + + } catch (\Stripe\Exception\InvalidRequestException $e) { + $this->error(' → Error: '.$e->getMessage()); + + if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) { + // Subscription doesn't exist, try to find by customer ID + $this->warn(" → Subscription not found, checking customer's subscriptions..."); + + $foundResult = null; + if ($subscription->stripe_customer_id) { + $foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id); + } + + if ($foundResult && isset($foundResult['subscription']) && in_array($foundResult['status'], ['active', 'past_due'])) { + // Found an active subscription with different ID + $this->warn(" → ID mismatch! DB: {$subscription->stripe_subscription_id}, Stripe: {$foundResult['subscription']->id}"); + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + "WRONG ID: {$subscription->stripe_subscription_id} → {$foundResult['subscription']->id}", + $dbStatus, + $foundResult['status'], + 'id_mismatch', + implode(', ', $memberEmails), + "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}", + "https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}", + ]); + + if ($isDryRun) { + $this->warn(" → Would update subscription ID to {$foundResult['subscription']->id}"); + } elseif ($shouldFix) { + $subscription->update([ + 'stripe_subscription_id' => $foundResult['subscription']->id, + 'stripe_invoice_paid' => true, + 'stripe_past_due' => $foundResult['status'] === 'past_due', + ]); + $stats['fixed']++; + $this->info(' → Updated subscription ID'); + } + + $stats[$foundResult['status'] === 'active' ? 'valid_active' : 'valid_past_due']++; + } else { + // No active subscription found + $stripeStatus = $foundResult ? $foundResult['status'] : 'not_found'; + $result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats); + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $subscription->stripe_subscription_id, + $dbStatus, + $result['status'], + $result['action'], + implode(', ', $memberEmails), + $subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A', + $foundResult && isset($foundResult['subscription']) ? "https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}" : 'N/A', + ]); + } + } else { + // Other API error + $stats['errors']++; + $this->error(' → API Error - not marking as deleted'); + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $subscription->stripe_subscription_id, + $dbStatus, + 'error: '.$e->getStripeCode(), + 'error', + implode(', ', $memberEmails), + $subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A', + $subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A', + ]); + } + } catch (\Exception $e) { + $this->error(' → Unexpected error: '.$e->getMessage()); + $stats['errors']++; + + fputcsv($out, [ + $team->id, + $team->name, + $subscription->stripe_customer_id, + $subscription->stripe_subscription_id, + $dbStatus, + 'error', + 'error', + implode(', ', $memberEmails), + $subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A', + $subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A', + ]); + } + } + + $processedCount++; + if ($processedCount % 100 === 0) { + $this->info("Processed {$processedCount}/{$totalCount} subscriptions..."); + } + } + + fclose($out); + + // Print summary + $this->newLine(2); + $this->info('=== Verification Summary ==='); + $this->info("Total subscriptions checked: {$stats['total']}"); + $this->newLine(); + + $this->info('Valid subscriptions in Stripe:'); + $this->line(" - Active: {$stats['valid_active']}"); + $this->line(" - Past Due: {$stats['valid_past_due']}"); + $validTotal = $stats['valid_active'] + $stats['valid_past_due']; + $this->info(" Total valid: {$validTotal}"); + + $this->newLine(); + $this->warn('Invalid subscriptions:'); + $this->line(" - Canceled/Expired: {$stats['canceled']}"); + $this->line(" - Missing/Not Found: {$stats['missing']}"); + $this->line(" - Unknown status: {$stats['invalid']}"); + $invalidTotal = $stats['canceled'] + $stats['missing'] + $stats['invalid']; + $this->warn(" Total invalid: {$invalidTotal}"); + + if ($stats['errors'] > 0) { + $this->newLine(); + $this->error("Errors encountered: {$stats['errors']}"); + } + + if ($shouldFix && ! $isDryRun) { + $this->newLine(); + $this->info("Fixed subscriptions: {$stats['fixed']}"); + } elseif ($invalidTotal > 0 && ! $shouldFix) { + $this->newLine(); + $this->comment('Run with --fix-verified to fix the discrepancies'); + } + + return 0; + } + + /** + * Fix a subscription based on its status + */ + private function fixSubscription($team, $subscription, $status) + { + $message = "Fixing subscription for Team ID: {$team->id} (Status: {$status})\n"; + $message .= "Team Name: {$team->name}\n"; + $message .= "Customer ID: {$subscription->stripe_customer_id}\n"; + $message .= "Subscription ID: {$subscription->stripe_subscription_id}\n"; + + send_internal_notification($message); + + // Call the team's subscription ended method which properly cleans up + $team->subscriptionEnded(); + } + + /** + * Search for subscriptions by customer ID + */ + private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false) + { + try { + $subscriptions = $stripe->subscriptions->all([ + 'customer' => $customerId, + 'limit' => 10, + 'status' => 'all', + ]); + + $this->line(' → Found '.count($subscriptions->data).' subscription(s) for customer'); + + // Look for active/past_due first + foreach ($subscriptions->data as $sub) { + $this->line(" - Subscription {$sub->id}: status={$sub->status}"); + if (in_array($sub->status, ['active', 'past_due'])) { + $this->info(" ✓ Found active/past_due subscription: {$sub->id}"); + + return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id']; + } + } + + // If not requiring active and there are subscriptions, return first one + if (! $requireActive && count($subscriptions->data) > 0) { + $sub = $subscriptions->data[0]; + $this->warn(" ⚠ Only found {$sub->status} subscription: {$sub->id}"); + + return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id_first']; + } + + return null; + } catch (\Exception $e) { + $this->error(' → Error searching by customer ID: '.$e->getMessage()); + + return null; + } + } + + /** + * Search for subscriptions by team member emails + */ + private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails) + { + $this->line(' → Searching by team member emails...'); + + foreach ($emails as $email) { + $this->line(" → Checking email: {$email}"); + + try { + $customers = $stripe->customers->all([ + 'email' => $email, + 'limit' => 5, + ]); + + if (count($customers->data) === 0) { + $this->line(' - No customers found'); + + continue; + } + + $this->line(' - Found '.count($customers->data).' customer(s)'); + + foreach ($customers->data as $customer) { + $this->line(" - Checking customer {$customer->id}"); + + $result = $this->searchSubscriptionsByCustomer($stripe, $customer->id, true); + if ($result) { + $result['method'] = "email:{$email}"; + $result['customer_id'] = $customer->id; + + return $result; + } + } + } catch (\Exception $e) { + $this->error(" - Error searching for email {$email}: ".$e->getMessage()); + } + } + + return null; + } + + /** + * Handle found subscription update (only for active/past_due subscriptions) + */ + private function handleFoundSubscription($team, $subscription, $foundSub, $searchMethod, $isDryRun, $shouldFix, &$stats) + { + $stripeStatus = $foundSub->status; + $this->info(" ✓ FOUND active/past_due subscription {$foundSub->id} (status: {$stripeStatus})"); + + // Only update if it's active or past_due + if (! in_array($stripeStatus, ['active', 'past_due'])) { + $this->error(" ERROR: handleFoundSubscription called with {$stripeStatus} subscription!"); + + return [ + 'id' => $foundSub->id, + 'status' => $stripeStatus, + 'action' => 'error', + 'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}", + ]; + } + + if ($isDryRun) { + $this->warn(" → Would update subscription ID to {$foundSub->id} (status: {$stripeStatus})"); + } elseif ($shouldFix) { + $subscription->update([ + 'stripe_subscription_id' => $foundSub->id, + 'stripe_invoice_paid' => true, + 'stripe_past_due' => $stripeStatus === 'past_due', + ]); + $stats['fixed']++; + $this->info(" → Updated subscription ID to {$foundSub->id}"); + } + + // Update stats + $stats[$stripeStatus === 'active' ? 'valid_active' : 'valid_past_due']++; + + return [ + 'id' => "FOUND: {$foundSub->id}", + 'status' => $stripeStatus, + 'action' => "will_update (via {$searchMethod})", + 'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}", + ]; + } + + /** + * Handle missing subscription + */ + private function handleMissingSubscription($team, $subscription, $status, $isDryRun, $shouldFix, &$stats) + { + $stats['missing']++; + + if ($isDryRun) { + $statusMsg = $status !== 'not_found' ? "status: {$status}" : 'no subscription found in Stripe'; + $this->warn(" → Would deactivate subscription - {$statusMsg}"); + } elseif ($shouldFix) { + $this->fixSubscription($team, $subscription, $status); + $stats['fixed']++; + $this->info(' → Deactivated subscription'); + } + + return [ + 'id' => 'N/A', + 'status' => $status, + 'action' => 'needs_fix', + 'url' => 'N/A', + ]; + } +} diff --git a/app/Console/Commands/CloudCheckSubscription.php b/app/Console/Commands/CloudCheckSubscription.php deleted file mode 100644 index 6e237e84b..000000000 --- a/app/Console/Commands/CloudCheckSubscription.php +++ /dev/null @@ -1,49 +0,0 @@ -get(); - foreach ($activeSubscribers as $team) { - $stripeSubscriptionId = $team->subscription->stripe_subscription_id; - $stripeInvoicePaid = $team->subscription->stripe_invoice_paid; - $stripeCustomerId = $team->subscription->stripe_customer_id; - if (! $stripeSubscriptionId) { - echo "Team {$team->id} has no subscription, but invoice status is: {$stripeInvoicePaid}\n"; - echo "Link on Stripe: https://dashboard.stripe.com/customers/{$stripeCustomerId}\n"; - - continue; - } - $subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId); - if ($subscription->status === 'active') { - continue; - } - echo "Subscription {$stripeSubscriptionId} is not active ({$subscription->status})\n"; - echo "Link on Stripe: https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}\n"; - } - } -} diff --git a/app/Console/Commands/CloudCleanupSubscriptions.php b/app/Console/Commands/CloudCleanupSubscriptions.php deleted file mode 100644 index ab676c927..000000000 --- a/app/Console/Commands/CloudCleanupSubscriptions.php +++ /dev/null @@ -1,101 +0,0 @@ -error('This command can only be run on cloud'); - - return; - } - $this->info('Cleaning up subcriptions teams'); - $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); - - $teams = Team::all()->filter(function ($team) { - return $team->id !== 0; - })->sortBy('id'); - foreach ($teams as $team) { - if ($team) { - $this->info("Checking team {$team->id}"); - } - if (! data_get($team, 'subscription')) { - $this->disableServers($team); - - continue; - } - // If the team has no subscription id and the invoice is paid, we need to reset the invoice paid status - if (! (data_get($team, 'subscription.stripe_subscription_id'))) { - $this->info("Resetting invoice paid status for team {$team->id}"); - - $team->subscription->update([ - 'stripe_invoice_paid' => false, - 'stripe_trial_already_ended' => false, - 'stripe_subscription_id' => null, - ]); - $this->disableServers($team); - - continue; - } else { - $subscription = $stripe->subscriptions->retrieve(data_get($team, 'subscription.stripe_subscription_id'), []); - $status = data_get($subscription, 'status'); - if ($status === 'active') { - $team->subscription->update([ - 'stripe_invoice_paid' => true, - 'stripe_trial_already_ended' => false, - ]); - - continue; - } - $this->info('Subscription status: '.$status); - $this->info('Subscription id: '.data_get($team, 'subscription.stripe_subscription_id')); - $confirm = $this->confirm('Do you want to cancel the subscription?', true); - if (! $confirm) { - $this->info("Skipping team {$team->id}"); - } else { - $this->info("Cancelling subscription for team {$team->id}"); - $team->subscription->update([ - 'stripe_invoice_paid' => false, - 'stripe_trial_already_ended' => false, - 'stripe_subscription_id' => null, - ]); - $this->disableServers($team); - } - } - } - } catch (\Exception $e) { - $this->error($e->getMessage()); - - return; - } - } - - private function disableServers(Team $team) - { - foreach ($team->servers as $server) { - if ($server->settings->is_usable === true || $server->settings->is_reachable === true || $server->ip !== '1.2.3.4') { - $this->info("Disabling server {$server->id} {$server->name}"); - $server->settings()->update([ - 'is_usable' => false, - 'is_reachable' => false, - ]); - $server->update([ - 'ip' => '1.2.3.4', - ]); - - ServerReachabilityChanged::dispatch($server); - } - } - } -} diff --git a/app/Events/ApplicationConfigurationChanged.php b/app/Events/ApplicationConfigurationChanged.php new file mode 100644 index 000000000..3dd532b19 --- /dev/null +++ b/app/Events/ApplicationConfigurationChanged.php @@ -0,0 +1,35 @@ +check() && auth()->user()->currentTeam()) { + $teamId = auth()->user()->currentTeam()->id; + } + $this->teamId = $teamId; + } + + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index cd640df17..065d7f767 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1512,9 +1512,32 @@ private function create_application(Request $request, $type) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - if (! $request->docker_registry_image_tag) { - $request->offsetSet('docker_registry_image_tag', 'latest'); + // Process docker image name and tag for SHA256 digests + $dockerImageName = $request->docker_registry_image_name; + $dockerImageTag = $request->docker_registry_image_tag; + + // Strip 'sha256:' prefix if user provided it in the tag + if ($dockerImageTag) { + $dockerImageTag = preg_replace('/^sha256:/i', '', trim($dockerImageTag)); } + + // Remove @sha256 from image name if user added it + if ($dockerImageName) { + $dockerImageName = preg_replace('/@sha256$/i', '', trim($dockerImageName)); + } + + // Check if tag is a valid SHA256 hash (64 hex characters) + $isSha256Hash = $dockerImageTag && preg_match('/^[a-f0-9]{64}$/i', $dockerImageTag); + + // Append @sha256 to image name if using digest and not already present + if ($isSha256Hash && ! str_ends_with($dockerImageName, '@sha256')) { + $dockerImageName .= '@sha256'; + } + + // Set processed values back to request + $request->offsetSet('docker_registry_image_name', $dockerImageName); + $request->offsetSet('docker_registry_image_tag', $dockerImageTag ?: 'latest'); + $application = new Application; removeUnnecessaryFieldsFromRequest($request); @@ -3380,11 +3403,12 @@ private function validateDataApplications(Request $request, Server $server) $fqdn = str($fqdn)->replaceStart(',', '')->trim(); $errors = []; $fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) { + $domain = trim($domain); if (filter_var($domain, FILTER_VALIDATE_URL) === false) { $errors[] = 'Invalid domain: '.$domain; } - return str($domain)->trim()->lower(); + return str($domain)->lower(); }); if (count($errors) > 0) { return response()->json([ diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 389d119bd..5871f481a 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -9,11 +9,15 @@ use App\Actions\Database\StopDatabaseProxy; use App\Enums\NewDatabaseTypes; use App\Http\Controllers\Controller; +use App\Jobs\DatabaseBackupJob; use App\Jobs\DeleteResourceJob; use App\Models\Project; +use App\Models\S3Storage; +use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandalonePostgresql; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; class DatabasesController extends Controller @@ -79,13 +83,88 @@ public function databases(Request $request) foreach ($projects as $project) { $databases = $databases->merge($project->databases()); } - $databases = $databases->map(function ($database) { + + $databaseIds = $databases->pluck('id')->toArray(); + + $backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('latest_log') + ->whereIn('database_id', $databaseIds) + ->get() + ->groupBy('database_id'); + + $databases = $databases->map(function ($database) use ($backupConfigs) { + $database->backup_configs = $backupConfigs->get($database->id, collect())->values(); + return $this->removeSensitiveData($database); }); return response()->json($databases); } + #[OA\Get( + summary: 'Get', + description: 'Get backups details by database UUID.', + path: '/databases/{uuid}/backups', + operationId: 'get-database-backups-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all backups for a database', + content: new OA\JsonContent( + type: 'string', + example: 'Content is very complex. Will be implemented later.', + ), + ), + 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 database_backup_details_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + if (! $request->uuid) { + return response()->json(['message' => 'UUID is required.'], 404); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('view', $database); + + $backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('executions')->where('database_id', $database->id)->get(); + + return response()->json($backupConfig); + } + #[OA\Get( summary: 'Get', description: 'Get database by UUID.', @@ -248,6 +327,7 @@ public function update_by_uuid(Request $request) return invalidTokenResponse(); } + // this check if the request is a valid json $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -499,7 +579,8 @@ public function update_by_uuid(Request $request) $whatToDoWithDatabaseProxy = 'start'; } - $database->update($request->all()); + // Only update database fields, not backup configuration + $database->update($request->only($allowedFields)); if ($whatToDoWithDatabaseProxy === 'start') { StartDatabaseProxy::dispatch($database); @@ -512,6 +593,197 @@ public function update_by_uuid(Request $request) ]); } + #[OA\Patch( + summary: 'Update', + description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID', + path: '/databases/{uuid}/backups/{scheduled_backup_uuid}', + operationId: 'update-database-backup', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'scheduled_backup_uuid', + in: 'path', + description: 'UUID of the backup configuration.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Database backup configuration data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'], + 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'], + 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'], + 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'], + 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], + 'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'], + 'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'], + 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'], + 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'], + 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], + 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], + 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], + 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Database backup configuration updated', + ), + 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 update_backup(Request $request) + { + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + // this check if the request is a valid json + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + $validator = customApiValidator($request->all(), [ + 'save_s3' => 'boolean', + 'backup_now' => 'boolean|nullable', + 'enabled' => 'boolean', + 'dump_all' => 'boolean', + 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', + 'databases_to_backup' => 'string|nullable', + 'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly', + 'database_backup_retention_amount_locally' => 'integer|min:0', + 'database_backup_retention_days_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_amount_s3' => 'integer|min:0', + 'database_backup_retention_days_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'integer|min:0', + ]); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + if (! $request->uuid) { + return response()->json(['message' => 'UUID is required.'], 404); + } + + // Validate scheduled_backup_uuid is provided + if (! $request->scheduled_backup_uuid) { + return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); + } + + $uuid = $request->uuid; + removeUnnecessaryFieldsFromRequest($request); + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']], + ], 422); + } + if ($request->filled('s3_storage_uuid')) { + $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); + if (! $existsInTeam) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], + ], 422); + } + } + + $backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id) + ->where('uuid', $request->scheduled_backup_uuid) + ->first(); + if (! $backupConfig) { + return response()->json(['message' => 'Backup config not found.'], 404); + } + + $extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']); + if (! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $backupData = $request->only($backupConfigFields); + + // Convert s3_storage_uuid to s3_storage_id + if (isset($backupData['s3_storage_uuid'])) { + $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); + if ($s3Storage) { + $backupData['s3_storage_id'] = $s3Storage->id; + } elseif ($request->boolean('save_s3')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], + ], 422); + } + unset($backupData['s3_storage_uuid']); + } + + $backupConfig->update($backupData); + + if ($request->backup_now) { + dispatch(new DatabaseBackupJob($backupConfig)); + } + + return response()->json([ + 'message' => 'Database backup configuration updated', + ]); + } + #[OA\Post( summary: 'Create (PostgreSQL)', description: 'Create a new PostgreSQL database.', @@ -1630,6 +1902,344 @@ public function delete_by_uuid(Request $request) ]); } + #[OA\Delete( + summary: 'Delete backup configuration', + description: 'Deletes a backup configuration and all its executions.', + path: '/databases/{uuid}/backups/{scheduled_backup_uuid}', + operationId: 'delete-backup-configuration-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + description: 'UUID of the database', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'scheduled_backup_uuid', + in: 'path', + required: true, + description: 'UUID of the backup configuration to delete', + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + new OA\Parameter( + name: 'delete_s3', + in: 'query', + required: false, + description: 'Whether to delete all backup files from S3', + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Backup configuration deleted.', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'message' => new OA\Schema(type: 'string', example: 'Backup configuration and all executions deleted.'), + ] + ) + ), + new OA\Response( + response: 404, + description: 'Backup configuration not found.', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'message' => new OA\Schema(type: 'string', example: 'Backup configuration not found.'), + ] + ) + ), + ] + )] + public function delete_backup_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + // Validate scheduled_backup_uuid is provided + if (! $request->scheduled_backup_uuid) { + return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + // Find the backup configuration by its UUID + $backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id) + ->where('uuid', $request->scheduled_backup_uuid) + ->first(); + + if (! $backup) { + return response()->json(['message' => 'Backup configuration not found.'], 404); + } + + $deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN); + + try { + DB::beginTransaction(); + // Get all executions for this backup configuration + $executions = $backup->executions()->get(); + + // Delete all execution files (locally and optionally from S3) + foreach ($executions as $execution) { + if ($execution->filename) { + deleteBackupsLocally($execution->filename, $database->destination->server); + + if ($deleteS3 && $backup->s3) { + deleteBackupsS3($execution->filename, $backup->s3); + } + } + + $execution->delete(); + } + + // Delete the backup configuration itself + $backup->delete(); + DB::commit(); + + return response()->json([ + 'message' => 'Backup configuration and all executions deleted.', + ]); + } catch (\Exception $e) { + DB::rollBack(); + + return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500); + } + } + + #[OA\Delete( + summary: 'Delete backup execution', + description: 'Deletes a specific backup execution.', + path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', + operationId: 'delete-backup-execution-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + description: 'UUID of the database', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'scheduled_backup_uuid', + in: 'path', + required: true, + description: 'UUID of the backup configuration', + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + new OA\Parameter( + name: 'execution_uuid', + in: 'path', + required: true, + description: 'UUID of the backup execution to delete', + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + new OA\Parameter( + name: 'delete_s3', + in: 'query', + required: false, + description: 'Whether to delete the backup from S3', + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Backup execution deleted.', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'message' => new OA\Schema(type: 'string', example: 'Backup execution deleted.'), + ] + ) + ), + new OA\Response( + response: 404, + description: 'Backup execution not found.', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'message' => new OA\Schema(type: 'string', example: 'Backup execution not found.'), + ] + ) + ), + ] + )] + public function delete_execution_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + // Validate parameters + if (! $request->scheduled_backup_uuid) { + return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); + } + if (! $request->execution_uuid) { + return response()->json(['message' => 'Execution UUID is required.'], 400); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + // Find the backup configuration by its UUID + $backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id) + ->where('uuid', $request->scheduled_backup_uuid) + ->first(); + + if (! $backup) { + return response()->json(['message' => 'Backup configuration not found.'], 404); + } + + // Find the specific execution + $execution = $backup->executions()->where('uuid', $request->execution_uuid)->first(); + if (! $execution) { + return response()->json(['message' => 'Backup execution not found.'], 404); + } + + $deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN); + + try { + if ($execution->filename) { + deleteBackupsLocally($execution->filename, $database->destination->server); + + if ($deleteS3 && $backup->s3) { + deleteBackupsS3($execution->filename, $backup->s3); + } + } + + $execution->delete(); + + return response()->json([ + 'message' => 'Backup execution deleted.', + ]); + } catch (\Exception $e) { + return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500); + } + } + + #[OA\Get( + summary: 'List backup executions', + description: 'Get all executions for a specific backup configuration.', + path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions', + operationId: 'list-backup-executions', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + description: 'UUID of the database', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'scheduled_backup_uuid', + in: 'path', + required: true, + description: 'UUID of the backup configuration', + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of backup executions', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'executions' => new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string'], + 'filename' => ['type' => 'string'], + 'size' => ['type' => 'integer'], + 'created_at' => ['type' => 'string'], + 'message' => ['type' => 'string'], + 'status' => ['type' => 'string'], + ] + ) + ), + ] + ) + ), + new OA\Response( + response: 404, + description: 'Backup configuration not found.', + ), + ] + )] + public function list_backup_executions(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + // Validate scheduled_backup_uuid is provided + if (! $request->scheduled_backup_uuid) { + return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + // Find the backup configuration by its UUID + $backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id) + ->where('uuid', $request->scheduled_backup_uuid) + ->first(); + + if (! $backup) { + return response()->json(['message' => 'Backup configuration not found.'], 404); + } + + // Get all executions for this backup configuration + $executions = $backup->executions() + ->orderBy('created_at', 'desc') + ->get() + ->map(function ($execution) { + return [ + 'uuid' => $execution->uuid, + 'filename' => $execution->filename, + 'size' => $execution->size, + 'created_at' => $execution->created_at->toIso8601String(), + 'message' => $execution->message, + 'status' => $execution->status, + ]; + }); + + return response()->json([ + 'executions' => $executions, + ]); + } + #[OA\Get( summary: 'Start', description: 'Start database. `Post` request is also accepted.', diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php new file mode 100644 index 000000000..8c8c87238 --- /dev/null +++ b/app/Http/Controllers/Api/GithubController.php @@ -0,0 +1,661 @@ + []], + ], + tags: ['GitHub Apps'], + requestBody: new OA\RequestBody( + description: 'GitHub app creation payload.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'Name of the GitHub app.'], + 'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'Organization to associate the app with.'], + 'api_url' => ['type' => 'string', 'description' => 'API URL for the GitHub app (e.g., https://api.github.com).'], + 'html_url' => ['type' => 'string', 'description' => 'HTML URL for the GitHub app (e.g., https://github.com).'], + 'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'], + 'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'], + 'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID from GitHub.'], + 'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID.'], + 'client_id' => ['type' => 'string', 'description' => 'GitHub OAuth App Client ID.'], + 'client_secret' => ['type' => 'string', 'description' => 'GitHub OAuth App Client Secret.'], + 'webhook_secret' => ['type' => 'string', 'description' => 'Webhook secret for GitHub webhooks.'], + 'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'], + 'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'], + ], + required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'], + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'GitHub app created successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'organization' => ['type' => 'string', 'nullable' => true], + 'api_url' => ['type' => 'string'], + 'html_url' => ['type' => 'string'], + 'custom_user' => ['type' => 'string'], + 'custom_port' => ['type' => 'integer'], + 'app_id' => ['type' => 'integer'], + 'installation_id' => ['type' => 'integer'], + 'client_id' => ['type' => 'string'], + 'private_key_id' => ['type' => 'integer'], + 'is_system_wide' => ['type' => 'boolean'], + 'team_id' => ['type' => 'integer'], + ] + ) + ), + ] + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_github_app(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $allowedFields = [ + 'name', + 'organization', + 'api_url', + 'html_url', + 'custom_user', + 'custom_port', + 'app_id', + 'installation_id', + 'client_id', + 'client_secret', + 'webhook_secret', + 'private_key_uuid', + 'is_system_wide', + ]; + + $validator = customApiValidator($request->all(), [ + 'name' => 'required|string|max:255', + 'organization' => 'nullable|string|max:255', + 'api_url' => 'required|string|url', + 'html_url' => 'required|string|url', + 'custom_user' => 'nullable|string|max:255', + 'custom_port' => 'nullable|integer|min:1|max:65535', + 'app_id' => 'required|integer', + 'installation_id' => 'required|integer', + 'client_id' => 'required|string|max:255', + 'client_secret' => 'required|string', + 'webhook_secret' => 'required|string', + 'private_key_uuid' => 'required|string', + 'is_system_wide' => '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); + } + + try { + // Verify the private key belongs to the team + $privateKey = PrivateKey::where('uuid', $request->input('private_key_uuid')) + ->where('team_id', $teamId) + ->first(); + + if (! $privateKey) { + return response()->json([ + 'message' => 'Private key not found or does not belong to your team.', + ], 404); + } + + $payload = [ + 'uuid' => Str::uuid(), + 'name' => $request->input('name'), + 'organization' => $request->input('organization'), + 'api_url' => $request->input('api_url'), + 'html_url' => $request->input('html_url'), + 'custom_user' => $request->input('custom_user', 'git'), + 'custom_port' => $request->input('custom_port', 22), + 'app_id' => $request->input('app_id'), + 'installation_id' => $request->input('installation_id'), + 'client_id' => $request->input('client_id'), + 'client_secret' => $request->input('client_secret'), + 'webhook_secret' => $request->input('webhook_secret'), + 'private_key_id' => $privateKey->id, + 'is_public' => false, + 'team_id' => $teamId, + ]; + + if (! isCloud()) { + $payload['is_system_wide'] = $request->input('is_system_wide', false); + } + + $githubApp = GithubApp::create($payload); + + return response()->json($githubApp, 201); + } catch (\Throwable $e) { + return handleError($e); + } + } + + #[OA\Get( + path: '/github-apps/{github_app_id}/repositories', + summary: 'Load Repositories for a GitHub App', + description: 'Fetch repositories from GitHub for a given GitHub app.', + operationId: 'load-repositories', + tags: ['GitHub Apps'], + security: [ + ['bearerAuth' => []], + ], + parameters: [ + new OA\Parameter( + name: 'github_app_id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer'), + description: 'GitHub App ID' + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Repositories loaded successfully.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'repositories' => new OA\Schema( + type: 'array', + items: new OA\Items(type: 'object') + ), + ] + ) + ) + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function load_repositories($github_app_id) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + try { + $githubApp = GithubApp::where('id', $github_app_id) + ->where('team_id', $teamId) + ->firstOrFail(); + + $token = generateGithubInstallationToken($githubApp); + $repositories = collect(); + $page = 1; + $maxPages = 100; // Safety limit: max 10,000 repositories + + while ($page <= $maxPages) { + $response = Http::GitHub($githubApp->api_url, $token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get('/installation/repositories', [ + 'per_page' => 100, + 'page' => $page, + ]); + + if ($response->status() !== 200) { + return response()->json([ + 'message' => $response->json()['message'] ?? 'Failed to load repositories', + ], $response->status()); + } + + $json = $response->json(); + $repos = $json['repositories'] ?? []; + + if (empty($repos)) { + break; // No more repositories to load + } + + $repositories = $repositories->concat($repos); + $page++; + } + + return response()->json([ + 'repositories' => $repositories->sortBy('name')->values(), + ]); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->json(['message' => 'GitHub app not found'], 404); + } catch (\Throwable $e) { + return handleError($e); + } + } + + #[OA\Get( + path: '/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', + summary: 'Load Branches for a GitHub Repository', + description: 'Fetch branches from GitHub for a given repository.', + operationId: 'load-branches', + tags: ['GitHub Apps'], + security: [ + ['bearerAuth' => []], + ], + parameters: [ + new OA\Parameter( + name: 'github_app_id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer'), + description: 'GitHub App ID' + ), + new OA\Parameter( + name: 'owner', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + description: 'Repository owner' + ), + new OA\Parameter( + name: 'repo', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string'), + description: 'Repository name' + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Branches loaded successfully.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'branches' => new OA\Schema( + type: 'array', + items: new OA\Items(type: 'object') + ), + ] + ) + ) + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function load_branches($github_app_id, $owner, $repo) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + try { + $githubApp = GithubApp::where('id', $github_app_id) + ->where('team_id', $teamId) + ->firstOrFail(); + + $token = generateGithubInstallationToken($githubApp); + + $response = Http::GitHub($githubApp->api_url, $token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get("/repos/{$owner}/{$repo}/branches"); + + if ($response->status() !== 200) { + return response()->json([ + 'message' => 'Error loading branches from GitHub.', + 'error' => $response->json('message'), + ], $response->status()); + } + + $branches = $response->json(); + + return response()->json([ + 'branches' => $branches, + ]); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->json(['message' => 'GitHub app not found'], 404); + } catch (\Throwable $e) { + return handleError($e); + } + } + + /** + * Update a GitHub app. + */ + #[OA\Patch( + path: '/github-apps/{github_app_id}', + operationId: 'updateGithubApp', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitHub Apps'], + summary: 'Update GitHub App', + description: 'Update an existing GitHub app.', + parameters: [ + new OA\Parameter( + name: 'github_app_id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer'), + description: 'GitHub App ID' + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'GitHub App name'], + 'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'GitHub organization'], + 'api_url' => ['type' => 'string', 'description' => 'GitHub API URL'], + 'html_url' => ['type' => 'string', 'description' => 'GitHub HTML URL'], + 'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'], + 'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'], + 'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID'], + 'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID'], + 'client_id' => ['type' => 'string', 'description' => 'GitHub Client ID'], + 'client_secret' => ['type' => 'string', 'description' => 'GitHub Client Secret'], + 'webhook_secret' => ['type' => 'string', 'description' => 'GitHub Webhook Secret'], + 'private_key_uuid' => ['type' => 'string', 'description' => 'Private key UUID'], + 'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'], + ] + ) + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'GitHub app updated successfully', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'GitHub app updated successfully'], + 'data' => ['type' => 'object', 'description' => 'Updated GitHub app data'], + ] + ) + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'GitHub app not found'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] + public function update_github_app(Request $request, $github_app_id) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + try { + $githubApp = GithubApp::where('id', $github_app_id) + ->where('team_id', $teamId) + ->firstOrFail(); + + // Define allowed fields for update + $allowedFields = [ + 'name', + 'organization', + 'api_url', + 'html_url', + 'custom_user', + 'custom_port', + 'app_id', + 'installation_id', + 'client_id', + 'client_secret', + 'webhook_secret', + 'private_key_uuid', + ]; + + if (! isCloud()) { + $allowedFields[] = 'is_system_wide'; + } + + $payload = $request->only($allowedFields); + + // Validate the request + $rules = []; + if (isset($payload['name'])) { + $rules['name'] = 'string'; + } + if (isset($payload['organization'])) { + $rules['organization'] = 'nullable|string'; + } + if (isset($payload['api_url'])) { + $rules['api_url'] = 'url'; + } + if (isset($payload['html_url'])) { + $rules['html_url'] = 'url'; + } + if (isset($payload['custom_user'])) { + $rules['custom_user'] = 'string'; + } + if (isset($payload['custom_port'])) { + $rules['custom_port'] = 'integer|min:1|max:65535'; + } + if (isset($payload['app_id'])) { + $rules['app_id'] = 'integer'; + } + if (isset($payload['installation_id'])) { + $rules['installation_id'] = 'integer'; + } + if (isset($payload['client_id'])) { + $rules['client_id'] = 'string'; + } + if (isset($payload['client_secret'])) { + $rules['client_secret'] = 'string'; + } + if (isset($payload['webhook_secret'])) { + $rules['webhook_secret'] = 'string'; + } + if (isset($payload['private_key_uuid'])) { + $rules['private_key_uuid'] = 'string|uuid'; + } + if (! isCloud() && isset($payload['is_system_wide'])) { + $rules['is_system_wide'] = 'boolean'; + } + + $validator = customApiValidator($payload, $rules); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation error', + 'errors' => $validator->errors(), + ], 422); + } + + // Handle private_key_uuid -> private_key_id conversion + if (isset($payload['private_key_uuid'])) { + $privateKey = PrivateKey::where('team_id', $teamId) + ->where('uuid', $payload['private_key_uuid']) + ->first(); + + if (! $privateKey) { + return response()->json([ + 'message' => 'Private key not found or does not belong to your team', + ], 404); + } + + unset($payload['private_key_uuid']); + $payload['private_key_id'] = $privateKey->id; + } + + // Update the GitHub app + $githubApp->update($payload); + + return response()->json([ + 'message' => 'GitHub app updated successfully', + 'data' => $githubApp, + ]); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->json([ + 'message' => 'GitHub app not found', + ], 404); + } + } + + /** + * Delete a GitHub app. + */ + #[OA\Delete( + path: '/github-apps/{github_app_id}', + operationId: 'deleteGithubApp', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitHub Apps'], + summary: 'Delete GitHub App', + description: 'Delete a GitHub app if it\'s not being used by any applications.', + parameters: [ + new OA\Parameter( + name: 'github_app_id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer'), + description: 'GitHub App ID' + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'GitHub app deleted successfully', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'GitHub app deleted successfully'], + ] + ) + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'GitHub app not found'), + new OA\Response( + response: 409, + description: 'Conflict - GitHub app is in use', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'This GitHub app is being used by 5 application(s). Please delete all applications first.'], + ] + ) + ) + ), + ] + )] + public function delete_github_app($github_app_id) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + try { + $githubApp = GithubApp::where('id', $github_app_id) + ->where('team_id', $teamId) + ->firstOrFail(); + + // Check if the GitHub app is being used by any applications + if ($githubApp->applications->isNotEmpty()) { + $count = $githubApp->applications->count(); + + return response()->json([ + 'message' => "This GitHub app is being used by {$count} application(s). Please delete all applications first.", + ], 409); + } + + $githubApp->delete(); + + return response()->json([ + 'message' => 'GitHub app deleted successfully', + ]); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->json([ + 'message' => 'GitHub app not found', + ], 404); + } + } +} diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index d4b24d8ab..e12d83542 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -179,6 +179,8 @@ public function members_by_id(Request $request) $members = $team->members; $members->makeHidden([ 'pivot', + 'email_change_code', + 'email_change_code_expires_at', ]); return response()->json( @@ -264,6 +266,8 @@ public function current_team_members(Request $request) $team = auth()->user()->currentTeam(); $team->members->makeHidden([ 'pivot', + 'email_change_code', + 'email_change_code_expires_at', ]); return response()->json( diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index ae89649af..8ffaabde5 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -5,6 +5,7 @@ use App\Actions\Docker\GetContainersStatus; use App\Enums\ApplicationDeploymentStatus; use App\Enums\ProcessStatus; +use App\Events\ApplicationConfigurationChanged; use App\Events\ServiceStatusChanged; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; @@ -17,6 +18,7 @@ use App\Models\SwarmDocker; use App\Notifications\Application\DeploymentFailed; use App\Notifications\Application\DeploymentSuccess; +use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\ExecuteRemoteCommand; use Carbon\Carbon; use Exception; @@ -34,11 +36,10 @@ use Symfony\Component\Yaml\Yaml; use Throwable; use Visus\Cuid2\Cuid2; -use Yosymfony\Toml\Toml; class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { - use Dispatchable, ExecuteRemoteCommand, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, EnvironmentVariableAnalyzer, ExecuteRemoteCommand, InteractsWithQueue, Queueable, SerializesModels; public $tries = 1; @@ -115,16 +116,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private $env_args; - private $environment_variables; - private $env_nixpacks_args; private $docker_compose; private $docker_compose_base64; - private ?string $env_filename = null; - private ?string $nixpacks_plan = null; private Collection $nixpacks_plan_json; @@ -147,6 +144,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private Collection $saved_outputs; + private ?string $secrets_hash_key = null; + private ?string $full_healthcheck_url = null; private string $serverUser = 'root'; @@ -500,7 +499,12 @@ private function deploy_dockerimage_buildpack() } else { $this->dockerImageTag = $this->application->docker_registry_image_tag; } - $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}."); + + // Check if this is an image hash deployment + $isImageHash = str($this->dockerImageTag)->startsWith('sha256-'); + $displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}"; + + $this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}."); $this->generate_image_names(); $this->prepare_builder_image(); $this->generate_compose_file(); @@ -568,7 +572,6 @@ private function deploy_docker_compose_buildpack() if ($this->application->settings->is_raw_compose_deployment_enabled) { $this->application->oldRawParser(); $yaml = $composeFile = $this->application->docker_compose_raw; - $this->generate_runtime_environment_variables(); // For raw compose, we cannot automatically add secrets configuration // User must define it manually in their docker-compose file @@ -577,16 +580,14 @@ private function deploy_docker_compose_buildpack() } } else { $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); - $this->generate_runtime_environment_variables(); - if (filled($this->env_filename)) { - $services = collect(data_get($composeFile, 'services', [])); - $services = $services->map(function ($service, $name) { - $service['env_file'] = [$this->env_filename]; + // Always add .env file to services + $services = collect(data_get($composeFile, 'services', [])); + $services = $services->map(function ($service, $name) { + $service['env_file'] = ['.env']; - return $service; - }); - $composeFile['services'] = $services->toArray(); - } + return $service; + }); + $composeFile['services'] = $services->toArray(); if (empty($composeFile)) { $this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.'); $this->fail('Failed to parse docker-compose file.'); @@ -606,9 +607,15 @@ private function deploy_docker_compose_buildpack() executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}{$this->docker_compose_location} > /dev/null"), 'hidden' => true, ]); + + // Modify Dockerfiles for ARGs and build secrets + $this->modify_dockerfiles_for_compose($composeFile); // Build new container to limit downtime. $this->application_deployment_queue->addLogEntry('Pulling & building required images.'); + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + if ($this->docker_compose_custom_build_command) { // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported $build_command = $this->docker_compose_custom_build_command; @@ -624,19 +631,31 @@ private function deploy_docker_compose_buildpack() if ($this->dockerBuildkitSupported) { $command = "DOCKER_BUILDKIT=1 {$command}"; } - if (filled($this->env_filename)) { - $command .= " --env-file {$this->workdir}/{$this->env_filename}"; - } + // Use build-time .env file from /artifacts (outside Docker context to prevent it from being in the image) + $command .= ' --env-file /artifacts/build-time.env'; if ($this->force_rebuild) { $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache"; } else { $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull"; } + + 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); + $command .= " {$build_args_string}"; + $this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.'); + } + $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $command), 'hidden' => true], ); } + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->stop_running_container(force: true); $this->application_deployment_queue->addLogEntry('Starting new application.'); $networkId = $this->application->uuid; @@ -670,9 +689,8 @@ private function deploy_docker_compose_buildpack() $this->docker_compose_location = '/docker-compose.yaml'; $command = "{$this->coolify_variables} docker compose"; - if (filled($this->env_filename)) { - $command .= " --env-file {$server_workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$server_workdir}/.env"; $command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( ['command' => $command, 'hidden' => true], @@ -687,9 +705,8 @@ private function deploy_docker_compose_buildpack() } else { $command = "{$this->coolify_variables} docker compose"; if ($this->preserveRepository) { - if (filled($this->env_filename)) { - $command .= " --env-file {$server_workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$server_workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $this->write_deployment_configurations(); @@ -697,9 +714,8 @@ private function deploy_docker_compose_buildpack() ['command' => $command, 'hidden' => true], ); } else { - if (filled($this->env_filename)) { - $command .= " --env-file {$this->workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$this->workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $command), 'hidden' => true], @@ -733,9 +749,18 @@ private function deploy_dockerfile_buildpack() } $this->cleanup_git(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); $this->add_build_env_variables_to_dockerfile(); $this->build_image(); + + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); } @@ -759,11 +784,15 @@ private function deploy_nixpacks_buildpack() $this->cleanup_git(); $this->generate_nixpacks_confs(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build for Nixpacks + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); $this->build_image(); // For Nixpacks, save runtime environment variables AFTER the build - // to prevent them from being accessible during the build process + // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); @@ -787,7 +816,16 @@ private function deploy_static_buildpack() $this->clone_repository(); $this->cleanup_git(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->build_static_image(); + + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); } @@ -919,7 +957,13 @@ private function generate_image_names() $this->production_image_name = "{$this->application->uuid}:latest"; } } elseif ($this->application->build_pack === 'dockerimage') { - $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; + // Check if this is an image hash deployment + if (str($this->dockerImageTag)->startsWith('sha256-')) { + $hash = str($this->dockerImageTag)->after('sha256-'); + $this->production_image_name = "{$this->dockerImage}@sha256:{$hash}"; + } else { + $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; + } } elseif ($this->pull_request_id !== 0) { if ($this->application->docker_registry_image_name) { $this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build"; @@ -961,6 +1005,10 @@ private function should_skip_build() $this->skip_build = true; $this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); $this->generate_compose_file(); + + // Save runtime environment variables even when skipping build + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); @@ -970,6 +1018,10 @@ private function should_skip_build() $this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); $this->skip_build = true; $this->generate_compose_file(); + + // Save runtime environment variables even when skipping build + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); @@ -1034,8 +1086,6 @@ private function generate_runtime_environment_variables() $envs->push($key.'='.$item); }); if ($this->pull_request_id === 0) { - $this->env_filename = '.env'; - // Generate SERVICE_ variables first for dockercompose if ($this->build_pack === 'dockercompose') { $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); @@ -1094,8 +1144,6 @@ private function generate_runtime_environment_variables() $envs->push('HOST=0.0.0.0'); } } else { - $this->env_filename = '.env'; - // Generate SERVICE_ variables first for dockercompose preview if ($this->build_pack === 'dockercompose') { $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); @@ -1150,99 +1198,250 @@ private function generate_runtime_environment_variables() $envs->push('HOST=0.0.0.0'); } } - if ($envs->isEmpty()) { - if ($this->env_filename) { - if ($this->use_build_server) { - $this->server = $this->original_server; - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - $this->server = $this->build_server; - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - } else { - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - } - } - $this->env_filename = null; - } else { - // For Nixpacks builds, we save the .env file AFTER the build to prevent - // runtime-only variables from being accessible during the build process - if ($this->application->build_pack !== 'nixpacks' || $this->skip_build) { - $envs_base64 = base64_encode($envs->implode("\n")); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), - ], - ); - if ($this->use_build_server) { - $this->server = $this->original_server; - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - $this->server = $this->build_server; - } else { - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - } - } - } - $this->environment_variables = $envs; + // Return the generated environment variables instead of storing them globally + return $envs; } private function save_runtime_environment_variables() { - // This method saves the .env file with runtime variables - // It should be called AFTER the build for Nixpacks to prevent runtime-only variables - // from being accessible during the build process + // This method saves the .env file with ALL runtime variables + // For builds, it should be called AFTER the build to include runtime-only variables - if ($this->environment_variables && $this->environment_variables->isNotEmpty() && $this->env_filename) { - $envs_base64 = base64_encode($this->environment_variables->implode("\n")); + // Generate runtime environment variables locally + $environment_variables = $this->generate_runtime_environment_variables(); - // Write .env file to workdir (for container runtime) + // Handle empty environment variables + if ($environment_variables->isEmpty()) { + // For Docker Compose, we need to create an empty .env file + // because we always reference it in the compose file + if ($this->build_pack === 'dockercompose') { + $this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).'); + + // Create empty .env file + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "touch $this->workdir/.env"), + ] + ); + + // Also create in configuration directory + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + "touch $this->configuration_dir/.env", + ] + ); + $this->server = $this->build_server; + } else { + $this->execute_remote_command( + [ + "touch $this->configuration_dir/.env", + ] + ); + } + } else { + // For non-Docker Compose deployments, clean up any existing .env files + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/.env", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + $this->server = $this->build_server; + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/.env", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + } else { + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/.env", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + } + } + + return; + } + + // Write the environment variables to file + $envs_base64 = base64_encode($environment_variables->implode("\n")); + + // Write .env file to workdir (for container runtime) + $this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for build phase.', hidden: true); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"), + 'hidden' => true, + + ] + ); + + // Write .env file to configuration directory + if ($this->use_build_server) { + $this->server = $this->original_server; $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", + ] + ); + $this->server = $this->build_server; + } else { + $this->execute_remote_command( + [ + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", + ] + ); + } + } + + private function generate_buildtime_environment_variables() + { + $envs = collect([]); + $coolify_envs = $this->generate_coolify_env_variables(); + + // Add COOLIFY variables + $coolify_envs->each(function ($item, $key) use ($envs) { + $envs->push($key.'='.$item); + }); + + // Add SERVICE_NAME variables for Docker Compose builds + if ($this->build_pack === 'dockercompose') { + if ($this->pull_request_id === 0) { + // Generate SERVICE_NAME for dockercompose services from processed compose + if ($this->application->settings->is_raw_compose_deployment_enabled) { + $dockerCompose = Yaml::parse($this->application->docker_compose_raw); + } else { + $dockerCompose = Yaml::parse($this->application->docker_compose); + } + $services = data_get($dockerCompose, 'services', []); + foreach ($services as $serviceName => $_) { + $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName); + } + + // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments + $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); + } + } + } else { + // Generate SERVICE_NAME for preview deployments + $rawDockerCompose = Yaml::parse($this->application->docker_compose_raw); + $rawServices = data_get($rawDockerCompose, 'services', []); + foreach ($rawServices as $rawServiceName => $_) { + $envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)); + } + + // Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains + $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); + } + } + } + } + + // Add build-time user variables only + if ($this->pull_request_id === 0) { + $sorted_environment_variables = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) // ONLY build-time variables + ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') + ->get(); + + // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these + if ($this->build_pack === 'dockercompose') { + $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + }); + } + + foreach ($sorted_environment_variables as $env) { + $envs->push($env->key.'='.$env->real_value); + } + } else { + $sorted_environment_variables = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) // ONLY build-time variables + ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') + ->get(); + + // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values + if ($this->build_pack === 'dockercompose') { + $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + }); + } + + foreach ($sorted_environment_variables as $env) { + $envs->push($env->key.'='.$env->real_value); + } + } + + // Return the generated environment variables + return $envs; + } + + private function save_buildtime_environment_variables() + { + // Generate build-time environment variables locally + $environment_variables = $this->generate_buildtime_environment_variables(); + + // Save .env file for build phase in /artifacts to prevent it from being copied into Docker images + if ($environment_variables->isNotEmpty()) { + $envs_base64 = base64_encode($environment_variables->implode("\n")); + + $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true); + + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee /artifacts/build-time.env > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build-time.env'), + 'hidden' => true, ], ); + } elseif ($this->build_pack === 'dockercompose') { + // For Docker Compose, create an empty .env file even if there are no build-time variables + // This ensures the file exists when referenced in docker-compose commands + $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); - // Write .env file to configuration directory - if ($this->use_build_server) { - $this->server = $this->original_server; - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - $this->server = $this->build_server; - } else { - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - } + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, 'touch /artifacts/build-time.env'), + ] + ); } } @@ -1457,15 +1656,18 @@ private function deploy_pull_request() $this->generate_nixpacks_confs(); } $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); if ($this->application->build_pack === 'dockerfile') { $this->add_build_env_variables_to_dockerfile(); } $this->build_image(); - // For Nixpacks, save runtime environment variables AFTER the build - if ($this->application->build_pack === 'nixpacks') { - $this->save_runtime_environment_variables(); - } + + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } @@ -1500,7 +1702,7 @@ private function create_workdir() } } - private function prepare_builder_image() + private function prepare_builder_image(bool $firstTry = true) { $this->checkForCancellation(); $settings = instanceSettings(); @@ -1511,7 +1713,6 @@ private function prepare_builder_image() $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); $env_flags = $this->generate_docker_env_flags_for_secrets(); - if ($this->use_build_server) { if ($this->dockerConfigFileExists === 'NOK') { throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); @@ -1524,7 +1725,12 @@ private function prepare_builder_image() $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } } - $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage."); + if ($firstTry) { + $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage"); + } else { + $this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.'); + } + $this->graceful_shutdown_container($this->deployment_uuid); $this->execute_remote_command( [ @@ -1538,6 +1744,18 @@ private function prepare_builder_image() $this->run_pre_deployment_command(); } + private function restart_builder_container_with_actual_commit() + { + // Stop and remove the current helper container + $this->graceful_shutdown_container($this->deployment_uuid); + + // Clear cached env_args to force regeneration with actual SOURCE_COMMIT value + $this->env_args = null; + + // Restart the helper container with updated environment variables (including actual SOURCE_COMMIT) + $this->prepare_builder_image(firstTry: false); + } + private function deploy_to_additional_destinations() { if ($this->application->additional_networks->count() === 0) { @@ -1606,6 +1824,8 @@ private function set_coolify_variables() if (isset($this->application->git_branch)) { $this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} "; } + $this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} "; + $this->coolify_variables .= "COOLIFY_CONTAINER_NAME={$this->container_name} "; } private function check_git_if_build_needed() @@ -1673,6 +1893,12 @@ private function check_git_if_build_needed() $this->application_deployment_queue->save(); } $this->set_coolify_variables(); + + // Restart helper container with actual SOURCE_COMMIT value + if ($this->application->settings->use_build_secrets && $this->commit !== 'HEAD') { + $this->application_deployment_queue->addLogEntry('Restarting helper container with actual SOURCE_COMMIT value.'); + $this->restart_builder_container_with_actual_commit(); + } } private function clone_repository() @@ -1746,7 +1972,7 @@ private function generate_nixpacks_confs() if ($this->nixpacks_plan) { $this->application_deployment_queue->addLogEntry("Found application type: {$this->nixpacks_type}."); $this->application_deployment_queue->addLogEntry("If you need further customization, please check the documentation of Nixpacks: https://nixpacks.com/docs/providers/{$this->nixpacks_type}"); - $parsed = Toml::Parse($this->nixpacks_plan); + $parsed = json_decode($this->nixpacks_plan, true); // Do any modifications here // We need to generate envs here because nixpacks need to know to generate a proper Dockerfile @@ -1775,6 +2001,15 @@ private function generate_nixpacks_confs() if ($this->nixpacks_type === 'elixir') { $this->elixir_finetunes(); } + if ($this->nixpacks_type === 'node') { + // Check if NIXPACKS_NODE_VERSION is set + $variables = data_get($parsed, 'variables', []); + if (! isset($variables['NIXPACKS_NODE_VERSION'])) { + $this->application_deployment_queue->addLogEntry('----------------------------------------'); + $this->application_deployment_queue->addLogEntry('⚠️ NIXPACKS_NODE_VERSION not set. Nixpacks will use Node.js 18 by default, which is EOL.'); + $this->application_deployment_queue->addLogEntry('You can override this by setting NIXPACKS_NODE_VERSION=22 in your environment variables.'); + } + } $this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT); $this->nixpacks_plan_json = collect($parsed); $this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true); @@ -1790,7 +2025,7 @@ private function generate_nixpacks_confs() private function nixpacks_build_cmd() { $this->generate_nixpacks_env_variables(); - $nixpacks_command = "nixpacks plan -f toml {$this->env_nixpacks_args}"; + $nixpacks_command = "nixpacks plan -f json {$this->env_nixpacks_args}"; if ($this->application->build_command) { $nixpacks_command .= " --build-cmd \"{$this->application->build_command}\""; } @@ -1920,11 +2155,14 @@ private function generate_env_variables() { $this->env_args = collect([]); $this->env_args->put('SOURCE_COMMIT', $this->commit); + $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs->each(function ($value, $key) { + $this->env_args->put($key, $value); + }); // For build process, include only environment variables where is_buildtime = true if ($this->pull_request_id === 0) { - // Get environment variables that are marked as available during build $envs = $this->application->environment_variables() ->where('key', 'not like', 'NIXPACKS_%') ->where('is_buildtime', true) @@ -1933,24 +2171,9 @@ private function generate_env_variables() foreach ($envs as $env) { if (! is_null($env->real_value)) { $this->env_args->put($env->key, $env->real_value); - if (str($env->real_value)->startsWith('$')) { - $variable_key = str($env->real_value)->after('$'); - if ($variable_key->startsWith('COOLIFY_')) { - $variable = $coolify_envs->get($variable_key->value()); - if (filled($variable)) { - $this->env_args->prepend($variable, $variable_key->value()); - } - } else { - $variable = $this->application->environment_variables()->where('key', $variable_key)->first(); - if ($variable) { - $this->env_args->prepend($variable->real_value, $env->key); - } - } - } } } } else { - // Get preview environment variables that are marked as available during build $envs = $this->application->environment_variables_preview() ->where('key', 'not like', 'NIXPACKS_%') ->where('is_buildtime', true) @@ -1959,20 +2182,6 @@ private function generate_env_variables() foreach ($envs as $env) { if (! is_null($env->real_value)) { $this->env_args->put($env->key, $env->real_value); - if (str($env->real_value)->startsWith('$')) { - $variable_key = str($env->real_value)->after('$'); - if ($variable_key->startsWith('COOLIFY_')) { - $variable = $coolify_envs->get($variable_key->value()); - if (filled($variable)) { - $this->env_args->prepend($variable, $variable_key->value()); - } - } else { - $variable = $this->application->environment_variables_preview()->where('key', $variable_key)->first(); - if ($variable) { - $this->env_args->prepend($variable->real_value, $env->key); - } - } - } } } } @@ -1986,7 +2195,6 @@ private function generate_compose_file() $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->application->fileStorages()->get(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); - $this->generate_runtime_environment_variables(); if (data_get($this->application, 'custom_labels')) { $this->application->parseContainerLabels(); $labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels))); @@ -2055,9 +2263,8 @@ private function generate_compose_file() ], ], ]; - if (filled($this->env_filename)) { - $docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename]; - } + // Always use .env file + $docker_compose['services'][$this->container_name]['env_file'] = ['.env']; $docker_compose['services'][$this->container_name]['healthcheck'] = [ 'test' => [ 'CMD-SHELL', @@ -2342,6 +2549,18 @@ private function build_static_image() $this->application_deployment_queue->addLogEntry('Building docker image completed.'); } + /** + * Wrap a docker build command with environment export from /artifacts/build-time.env + * This enables shell interpolation of variables (e.g., APP_URL=$COOLIFY_URL) + * + * @param string $build_command The docker build command to wrap + * @return string The wrapped command with export statement + */ + private function wrap_build_command_with_env_export(string $build_command): string + { + return "cd {$this->workdir} && set -a && source /artifacts/build-time.env && set +a && {$build_command}"; + } + private function build_image() { // Add Coolify related variables to the build args/secrets @@ -2349,13 +2568,12 @@ private function build_image() // Coolify variables are already included in the secrets from generate_build_env_variables // build_secrets is already a string at this point } else { - // Traditional build args approach - $this->environment_variables->filter(function ($key, $value) { - return str($key)->startsWith('COOLIFY_'); - })->each(function ($key, $value) { + // Traditional build args approach - generate COOLIFY_ variables locally + // Generate COOLIFY_ variables locally for build args + $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs->each(function ($value, $key) { $this->build_args->push("--build-arg '{$key}'"); }); - $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection ? $this->build_args->implode(' ') : (string) $this->build_args; @@ -2392,12 +2610,13 @@ private function build_image() // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"); } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets - $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); + ray($build_command); } else { - $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } } else { $this->execute_remote_command([ @@ -2407,13 +2626,18 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"); + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } } @@ -2440,16 +2664,25 @@ private function build_image() $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { - $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } else { - $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + } + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = "docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"); } else { - $build_command = "docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -2482,7 +2715,7 @@ private function build_image() $nginx_config = base64_encode(defaultNginxConfiguration()); } } - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ @@ -2519,9 +2752,9 @@ private function build_image() } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); } else { - $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -2551,13 +2784,18 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } else { $this->execute_remote_command([ @@ -2567,13 +2805,18 @@ private function build_image() executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; - $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -2594,20 +2837,31 @@ private function build_image() $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); } else { // Dockerfile buildpack - if ($this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); // Use BuildKit with secrets $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { - $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); } else { - $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); + } + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); + } else { + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); } else { - $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); @@ -2697,27 +2951,92 @@ private function start_by_compose_file() $this->application_deployment_queue->addLogEntry('New container started.'); } + private function analyzeBuildTimeVariables($variables) + { + $userDefinedVariables = collect([]); + + $dbVariables = $this->pull_request_id === 0 + ? $this->application->environment_variables() + ->where('is_buildtime', true) + ->pluck('key') + : $this->application->environment_variables_preview() + ->where('is_buildtime', true) + ->pluck('key'); + + foreach ($variables as $key => $value) { + if ($dbVariables->contains($key)) { + $userDefinedVariables->put($key, $value); + } + } + + if ($userDefinedVariables->isEmpty()) { + return; + } + + $variablesArray = $userDefinedVariables->toArray(); + $warnings = self::analyzeBuildVariables($variablesArray); + + if (empty($warnings)) { + return; + } + $this->application_deployment_queue->addLogEntry('----------------------------------------'); + foreach ($warnings as $warning) { + $messages = self::formatBuildWarning($warning); + foreach ($messages as $message) { + $this->application_deployment_queue->addLogEntry($message, type: 'warning'); + } + $this->application_deployment_queue->addLogEntry(''); + } + + // Add general advice + $this->application_deployment_queue->addLogEntry('💡 Tips to resolve build issues:', type: 'info'); + $this->application_deployment_queue->addLogEntry(' 1. Set these variables as "Runtime only" in the environment variables settings', type: 'info'); + $this->application_deployment_queue->addLogEntry(' 2. Use different values for build-time (e.g., NODE_ENV=development for build)', type: 'info'); + $this->application_deployment_queue->addLogEntry(' 3. Consider using multi-stage Docker builds to separate build and runtime environments', type: 'info'); + } + private function generate_build_env_variables() { if ($this->application->build_pack === 'nixpacks') { $variables = collect($this->nixpacks_plan_json->get('variables')); } else { - // Generate environment variables for build process (filters by is_buildtime = true) $this->generate_env_variables(); $variables = collect([])->merge($this->env_args); } + // Analyze build variables for potential issues + if ($variables->isNotEmpty()) { + $this->analyzeBuildTimeVariables($variables); + } - // Check if build secrets are enabled and BuildKit is supported if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { $this->generate_build_secrets($variables); $this->build_args = ''; } else { - // Fall back to traditional build args - $this->build_args = $variables->map(function ($value, $key) { - $value = escapeshellarg($value); + $secrets_hash = ''; + if ($variables->isNotEmpty()) { + $secrets_hash = $this->generate_secrets_hash($variables); + } - return "--build-arg {$key}={$value}"; + $env_vars = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); + + // Map variables to include is_multiline flag + $vars_with_metadata = $variables->map(function ($value, $key) use ($env_vars) { + $env = $env_vars->firstWhere('key', $key); + + return [ + 'key' => $key, + 'value' => $value, + 'is_multiline' => $env ? $env->is_multiline : false, + ]; }); + + $this->build_args = generateDockerBuildArgs($vars_with_metadata); + + if ($secrets_hash) { + $this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); + } } } @@ -2728,21 +3047,40 @@ private function generate_docker_env_flags_for_secrets() return ''; } - $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; if ($variables->isEmpty()) { return ''; } - return $variables - ->map(function ($env) { - $escaped_value = escapeshellarg($env->real_value); + $secrets_hash = $this->generate_secrets_hash($variables); - return "-e {$env->key}={$escaped_value}"; - }) - ->implode(' '); + // Get database env vars to check for multiline flag + $env_vars = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); + + // Map to simple array format for the helper function + $vars_array = $variables->map(function ($value, $key) use ($env_vars) { + $env = $env_vars->firstWhere('key', $key); + + return [ + 'key' => $key, + 'value' => $value, + 'is_multiline' => $env ? $env->is_multiline : false, + ]; + }); + + $env_flags = generateDockerEnvFlags($vars_array); + $env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"; + + return $env_flags; } private function generate_build_secrets(Collection $variables) @@ -2758,6 +3096,36 @@ private function generate_build_secrets(Collection $variables) return "--secret id={$key},env={$key}"; }) ->implode(' '); + + $this->build_secrets .= ' --secret id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; + } + + private function generate_secrets_hash($variables) + { + if (! $this->secrets_hash_key) { + $this->secrets_hash_key = bin2hex(random_bytes(32)); + } + + if ($variables instanceof Collection) { + $secrets_string = $variables + ->mapWithKeys(function ($value, $key) { + return [$key => $value]; + }) + ->sortKeys() + ->map(function ($value, $key) { + return "{$key}={$value}"; + }) + ->implode('|'); + } else { + $secrets_string = $variables + ->map(function ($env) { + return "{$env->key}={$env->real_value}"; + }) + ->sort() + ->implode('|'); + } + + return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key); } private function add_build_env_variables_to_dockerfile() @@ -2769,9 +3137,9 @@ private function add_build_env_variables_to_dockerfile() executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), 'hidden' => true, 'save' => 'dockerfile', + 'ignore_errors' => true, ]); $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); - if ($this->pull_request_id === 0) { // Only add environment variables that are available during build $envs = $this->application->environment_variables() @@ -2785,6 +3153,17 @@ private function add_build_env_variables_to_dockerfile() $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); } } + // Add Coolify variables as ARGs + if ($this->coolify_variables) { + $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) + ->filter() + ->map(function ($var) { + return "ARG {$var}"; + }); + foreach ($coolify_vars as $arg) { + $dockerfile->splice(1, 0, [$arg]); + } + } } else { // Only add preview environment variables that are available during build $envs = $this->application->environment_variables_preview() @@ -2798,12 +3177,36 @@ private function add_build_env_variables_to_dockerfile() $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); } } + // Add Coolify variables as ARGs + if ($this->coolify_variables) { + $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) + ->filter() + ->map(function ($var) { + return "ARG {$var}"; + }); + foreach ($coolify_vars as $arg) { + $dockerfile->splice(1, 0, [$arg]); + } + } } + + if ($envs->isNotEmpty()) { + $secrets_hash = $this->generate_secrets_hash($envs); + $dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]); + } + $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), - 'hidden' => true, - ]); + $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info'); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), + 'hidden' => true, + 'ignore_errors' => true, + ]); } } @@ -2828,17 +3231,22 @@ private function modify_dockerfile_for_secrets($dockerfile_path) $dockerfile->prepend('# syntax=docker/dockerfile:1'); } - // Get environment variables for secrets - $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + $variables = $this->env_args; if ($variables->isEmpty()) { return; } // Generate mount strings for all secrets - $mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' '); + $mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' '); + + // Add mount for the secrets hash to ensure cache invalidation + $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; $modified = false; $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) { @@ -2863,26 +3271,182 @@ private function modify_dockerfile_for_secrets($dockerfile_path) executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"), 'hidden' => true, ]); + } + } - $this->application_deployment_queue->addLogEntry('Modified Dockerfile to use build secrets.'); + private function modify_dockerfiles_for_compose($composeFile) + { + if ($this->application->build_pack !== 'dockercompose') { + 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()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; + + if ($variables->isEmpty()) { + $this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.'); + + return; + } + + $services = data_get($composeFile, 'services', []); + + foreach ($services as $serviceName => $service) { + if (! isset($service['build'])) { + continue; + } + + $context = '.'; + $dockerfile = 'Dockerfile'; + + if (is_string($service['build'])) { + $context = $service['build']; + } elseif (is_array($service['build'])) { + $context = data_get($service['build'], 'context', '.'); + $dockerfile = data_get($service['build'], 'dockerfile', 'Dockerfile'); + } + + $dockerfilePath = rtrim($context, '/').'/'.ltrim($dockerfile, '/'); + if (str_starts_with($dockerfilePath, './')) { + $dockerfilePath = substr($dockerfilePath, 2); + } + if (str_starts_with($dockerfilePath, '/')) { + $dockerfilePath = substr($dockerfilePath, 1); + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/{$dockerfilePath} && echo 'exists' || echo 'not found'"), + 'hidden' => true, + 'save' => 'dockerfile_check_'.$serviceName, + ]); + + if (str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString() !== 'exists') { + $this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection."); + + continue; + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/{$dockerfilePath}"), + 'hidden' => true, + 'save' => 'dockerfile_content_'.$serviceName, + ]); + + $dockerfileContent = $this->saved_outputs->get('dockerfile_content_'.$serviceName); + if (! $dockerfileContent) { + continue; + } + + $dockerfile_lines = collect(str($dockerfileContent)->trim()->explode("\n")); + + $fromIndices = []; + $dockerfile_lines->each(function ($line, $index) use (&$fromIndices) { + if (str($line)->trim()->startsWith('FROM')) { + $fromIndices[] = $index; + } + }); + + if (empty($fromIndices)) { + $this->application_deployment_queue->addLogEntry("No FROM instruction found in Dockerfile for service {$serviceName}, skipping."); + + continue; + } + + $isMultiStage = count($fromIndices) > 1; + + $argsToAdd = collect([]); + foreach ($variables as $key => $value) { + $argsToAdd->push("ARG {$key}"); + } + + if ($argsToAdd->isEmpty()) { + $this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add."); + + continue; + } + + $totalAdded = 0; + $offset = 0; + + foreach ($fromIndices as $stageIndex => $fromIndex) { + $adjustedIndex = $fromIndex + $offset; + + $stageStart = $adjustedIndex + 1; + $stageEnd = isset($fromIndices[$stageIndex + 1]) + ? $fromIndices[$stageIndex + 1] + $offset + : $dockerfile_lines->count(); + + $existingStageArgs = collect([]); + for ($i = $stageStart; $i < $stageEnd; $i++) { + $line = $dockerfile_lines->get($i); + if (! $line || ! str($line)->trim()->startsWith('ARG')) { + break; + } + $parts = explode(' ', trim($line), 2); + if (count($parts) >= 2) { + $argPart = $parts[1]; + $keyValue = explode('=', $argPart, 2); + $existingStageArgs->push($keyValue[0]); + } + } + + $stageArgsToAdd = $argsToAdd->filter(function ($arg) use ($existingStageArgs) { + $key = str($arg)->after('ARG ')->trim()->toString(); + + return ! $existingStageArgs->contains($key); + }); + + if ($stageArgsToAdd->isNotEmpty()) { + $dockerfile_lines->splice($adjustedIndex + 1, 0, $stageArgsToAdd->toArray()); + $totalAdded += $stageArgsToAdd->count(); + $offset += $stageArgsToAdd->count(); + } + } + + if ($totalAdded > 0) { + $dockerfile_base64 = base64_encode($dockerfile_lines->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}/{$dockerfilePath} > /dev/null"), + 'hidden' => true, + ]); + + $stageInfo = $isMultiStage ? ' (multi-stage build, added to '.count($fromIndices).' stages)' : ''; + $this->application_deployment_queue->addLogEntry("Added {$totalAdded} ARG declarations to Dockerfile for service {$serviceName}{$stageInfo}."); + } else { + $this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist."); + } + + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}"; + $this->modify_dockerfile_for_secrets($fullDockerfilePath); + $this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets."); + } } } private function add_build_secrets_to_compose($composeFile) { - // Get environment variables for secrets - $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; if ($variables->isEmpty()) { return $composeFile; } $secrets = []; - foreach ($variables as $env) { - $secrets[$env->key] = [ - 'environment' => $env->key, + foreach ($variables as $key => $value) { + $secrets[$key] = [ + 'environment' => $key, ]; } @@ -2897,9 +3461,9 @@ private function add_build_secrets_to_compose($composeFile) if (! isset($service['build']['secrets'])) { $service['build']['secrets'] = []; } - foreach ($variables as $env) { - if (! in_array($env->key, $service['build']['secrets'])) { - $service['build']['secrets'][] = $env->key; + foreach ($variables as $key => $value) { + if (! in_array($key, $service['build']['secrets'])) { + $service['build']['secrets'][] = $key; } } } @@ -3018,6 +3582,8 @@ private function next(string $status) queue_next_deployment($this->application); if ($status === ApplicationDeploymentStatus::FINISHED->value) { + event(new ApplicationConfigurationChanged($this->application->team()->id)); + if (! $this->only_this_server) { $this->deploy_to_additional_destinations(); } diff --git a/app/Jobs/ApplicationPullRequestUpdateJob.php b/app/Jobs/ApplicationPullRequestUpdateJob.php index ef8e6efb6..471edb4c6 100755 --- a/app/Jobs/ApplicationPullRequestUpdateJob.php +++ b/app/Jobs/ApplicationPullRequestUpdateJob.php @@ -49,7 +49,7 @@ public function handle() } elseif ($this->status === ProcessStatus::ERROR) { $this->body = "The preview deployment failed. 🔴\n\n"; } - $this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/{$this->application->environment->name}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; + $this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; $this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n"; $this->body .= 'Last updated at: '.now()->toDateTimeString().' CET'; diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 6ac9ae1e6..3cc372fd1 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -15,6 +15,7 @@ use App\Models\Team; use App\Notifications\Database\BackupFailed; use App\Notifications\Database\BackupSuccess; +use App\Notifications\Database\BackupSuccessWithS3Warning; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -74,7 +75,6 @@ public function __construct(public ScheduledDatabaseBackup $backup) { $this->onQueue('high'); $this->timeout = $backup->timeout; - $this->backup_log_uuid = (string) new Cuid2; } @@ -288,7 +288,22 @@ public function handle(): void $this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip"; } foreach ($databasesToBackup as $database) { + // Generate unique UUID for each database backup execution + $attempts = 0; + do { + $this->backup_log_uuid = (string) new Cuid2; + $exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists(); + $attempts++; + if ($attempts >= 3 && $exists) { + throw new \Exception('Unable to generate unique UUID for backup execution after 3 attempts'); + } + } while ($exists); + $size = 0; + $localBackupSucceeded = false; + $s3UploadError = null; + + // Step 1: Create local backup try { if (str($databaseType)->contains('postgres')) { $this->backup_file = "/pg-dump-$database-".Carbon::now()->timestamp.'.dmp'; @@ -301,6 +316,7 @@ public function handle(): void 'database_name' => $database, 'filename' => $this->backup_location, 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, ]); $this->backup_standalone_postgresql($database); } elseif (str($databaseType)->contains('mongo')) { @@ -321,6 +337,7 @@ public function handle(): void 'database_name' => $databaseName, 'filename' => $this->backup_location, 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, ]); $this->backup_standalone_mongodb($database); } elseif (str($databaseType)->contains('mysql')) { @@ -334,6 +351,7 @@ public function handle(): void 'database_name' => $database, 'filename' => $this->backup_location, 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, ]); $this->backup_standalone_mysql($database); } elseif (str($databaseType)->contains('mariadb')) { @@ -347,56 +365,77 @@ public function handle(): void 'database_name' => $database, 'filename' => $this->backup_location, 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, ]); $this->backup_standalone_mariadb($database); } else { throw new \Exception('Unsupported database type'); } + $size = $this->calculate_size(); - if ($this->backup->save_s3) { + + // Verify local backup succeeded + if ($size > 0) { + $localBackupSucceeded = true; + } else { + throw new \Exception('Local backup file is empty or was not created'); + } + } catch (\Throwable $e) { + // Local backup failed + if ($this->backup_log) { + $this->backup_log->update([ + 'status' => 'failed', + 'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(), + 'size' => $size, + 'filename' => null, + 's3_uploaded' => null, + ]); + } + $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + + continue; + } + + // Step 2: Upload to S3 if enabled (independent of local backup) + $localStorageDeleted = false; + if ($this->backup->save_s3 && $localBackupSucceeded) { + try { $this->upload_to_s3(); // If local backup is disabled, delete the local file immediately after S3 upload if ($this->backup->disable_local_backup) { deleteBackupsLocally($this->backup_location, $this->server); + $localStorageDeleted = true; } + } catch (\Throwable $e) { + // S3 upload failed but local backup succeeded + $s3UploadError = $e->getMessage(); } + } - $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); + // Step 3: Update status and send notifications based on results + if ($localBackupSucceeded) { + $message = $this->backup_output; + + if ($s3UploadError) { + $message = $message + ? $message."\n\nWarning: S3 upload failed: ".$s3UploadError + : 'Warning: S3 upload failed: '.$s3UploadError; + } $this->backup_log->update([ 'status' => 'success', - 'message' => $this->backup_output, + 'message' => $message, 'size' => $size, + 's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null, + 'local_storage_deleted' => $localStorageDeleted, ]); - } catch (\Throwable $e) { - // Check if backup actually failed or if it's just a post-backup issue - $actualBackupFailed = ! $this->s3_uploaded && $this->backup->save_s3; - if ($actualBackupFailed || $size === 0) { - // Real backup failure - if ($this->backup_log) { - $this->backup_log->update([ - 'status' => 'failed', - 'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(), - 'size' => $size, - 'filename' => null, - ]); - } - $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + // Send appropriate notification + if ($s3UploadError) { + $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError)); } else { - // Backup succeeded but post-processing failed (cleanup, notification, etc.) - if ($this->backup_log) { - $this->backup_log->update([ - 'status' => 'success', - 'message' => $this->backup_output ? $this->backup_output."\nWarning: Post-backup cleanup encountered an issue: ".$e->getMessage() : 'Warning: '.$e->getMessage(), - 'size' => $size, - ]); - } - // Send success notification since the backup itself succeeded $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); - // Log the post-backup issue - ray('Post-backup operation failed but backup was successful: '.$e->getMessage()); } } } @@ -582,24 +621,24 @@ private function upload_to_s3(): void $fullImageName = $this->getFullImageName(); - $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup->uuid}"], $this->server, false); + $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false); if (filled($containerExists)) { - instant_remote_process(["docker rm -f backup-of-{$this->backup->uuid}"], $this->server, false); + instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false); } if (isDev()) { if ($this->database->name === 'coolify-db') { $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file; - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; } else { $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file; - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; } } else { - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; + $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; } - $commands[] = "docker exec backup-of-{$this->backup->uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\""; - $commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\""; + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; instant_remote_process($commands, $this->server); $this->s3_uploaded = true; @@ -608,7 +647,7 @@ private function upload_to_s3(): void $this->add_to_error_output($e->getMessage()); throw $e; } finally { - $command = "docker rm -f backup-of-{$this->backup->uuid}"; + $command = "docker rm -f backup-of-{$this->backup_log_uuid}"; instant_remote_process([$command], $this->server); } } diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index 088b6c67d..aebceaa6d 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -93,20 +93,66 @@ public function handle(): void break; case 'invoice.paid': $customerId = data_get($data, 'customer'); + $invoiceAmount = data_get($data, 'amount_paid', 0); + $subscriptionId = data_get($data, 'subscription'); $planId = data_get($data, 'lines.data.0.plan.id'); if (Str::contains($excludedPlans, $planId)) { // send_internal_notification('Subscription excluded.'); break; } $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); - if ($subscription) { - $subscription->update([ - 'stripe_invoice_paid' => true, - 'stripe_past_due' => false, - ]); - } else { + if (! $subscription) { throw new \RuntimeException("No subscription found for customer: {$customerId}"); } + + if ($subscription->stripe_subscription_id) { + try { + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripeSubscription = $stripe->subscriptions->retrieve( + $subscription->stripe_subscription_id + ); + + switch ($stripeSubscription->status) { + case 'active': + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + ]); + break; + + case 'past_due': + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => true, + ]); + break; + + case 'canceled': + case 'incomplete_expired': + case 'unpaid': + send_internal_notification( + "Invoice paid for {$stripeSubscription->status} subscription. ". + "Customer: {$customerId}, Amount: \${$invoiceAmount}" + ); + break; + + default: + VerifyStripeSubscriptionStatusJob::dispatch($subscription) + ->delay(now()->addSeconds(20)); + break; + } + } catch (\Exception $e) { + VerifyStripeSubscriptionStatusJob::dispatch($subscription) + ->delay(now()->addSeconds(20)); + + send_internal_notification( + 'Failed to verify subscription status in invoice.paid: '.$e->getMessage() + ); + } + } else { + VerifyStripeSubscriptionStatusJob::dispatch($subscription) + ->delay(now()->addSeconds(20)); + } break; case 'invoice.payment_failed': $customerId = data_get($data, 'customer'); diff --git a/app/Jobs/VerifyStripeSubscriptionStatusJob.php b/app/Jobs/VerifyStripeSubscriptionStatusJob.php new file mode 100644 index 000000000..58b6944a2 --- /dev/null +++ b/app/Jobs/VerifyStripeSubscriptionStatusJob.php @@ -0,0 +1,106 @@ +onQueue('high'); + } + + public function handle(): void + { + // If no subscription ID yet, try to find it via customer + if (! $this->subscription->stripe_subscription_id && + $this->subscription->stripe_customer_id) { + try { + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $subscriptions = $stripe->subscriptions->all([ + 'customer' => $this->subscription->stripe_customer_id, + 'limit' => 1, + ]); + + if ($subscriptions->data) { + $this->subscription->update([ + 'stripe_subscription_id' => $subscriptions->data[0]->id, + ]); + } + } catch (\Exception $e) { + // Continue without subscription ID + } + } + + if (! $this->subscription->stripe_subscription_id) { + return; + } + + try { + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $stripeSubscription = $stripe->subscriptions->retrieve( + $this->subscription->stripe_subscription_id + ); + + switch ($stripeSubscription->status) { + case 'active': + $this->subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + 'stripe_cancel_at_period_end' => $stripeSubscription->cancel_at_period_end, + ]); + break; + + case 'past_due': + // Keep subscription active but mark as past_due + $this->subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => true, + 'stripe_cancel_at_period_end' => $stripeSubscription->cancel_at_period_end, + ]); + break; + + case 'canceled': + case 'incomplete_expired': + case 'unpaid': + // Ensure subscription is marked as inactive + $this->subscription->update([ + 'stripe_invoice_paid' => false, + 'stripe_past_due' => false, + ]); + + // Trigger subscription ended logic if canceled + if ($stripeSubscription->status === 'canceled') { + $team = $this->subscription->team; + if ($team) { + $team->subscriptionEnded(); + } + } + break; + + default: + send_internal_notification( + 'Unknown subscription status in VerifyStripeSubscriptionStatusJob: '.$stripeSubscription->status. + ' for customer: '.$this->subscription->stripe_customer_id + ); + break; + } + } catch (\Exception $e) { + send_internal_notification( + 'VerifyStripeSubscriptionStatusJob failed for subscription ID '.$this->subscription->id.': '.$e->getMessage() + ); + } + } +} diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 18dbde0d3..57ecaa8a2 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -2,63 +2,25 @@ namespace App\Livewire; -use App\Models\Application; -use App\Models\ApplicationDeploymentQueue; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Artisan; use Livewire\Component; class Dashboard extends Component { - public $projects = []; + public Collection $projects; public Collection $servers; public Collection $privateKeys; - public array $deploymentsPerServer = []; - public function mount() { $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get(); $this->servers = Server::ownedByCurrentTeam()->get(); $this->projects = Project::ownedByCurrentTeam()->get(); - $this->loadDeployments(); - } - - public function cleanupQueue() - { - try { - $this->authorize('cleanupDeploymentQueue', Application::class); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { - return handleError($e, $this); - } - - Artisan::queue('cleanup:deployment-queue', [ - '--team-id' => currentTeam()->id, - ]); - } - - public function loadDeployments() - { - $this->deploymentsPerServer = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('server_id', $this->servers->pluck('id'))->get([ - 'id', - 'application_id', - 'application_name', - 'deployment_url', - 'pull_request_id', - 'server_name', - 'server_id', - 'status', - ])->sortBy('id')->groupBy('server_name')->toArray(); - } - - public function navigateToProject($projectUuid) - { - return $this->redirect(collect($this->projects)->firstWhere('uuid', $projectUuid)->navigateTo(), navigate: false); } public function render() diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php new file mode 100644 index 000000000..ac9cfd1c2 --- /dev/null +++ b/app/Livewire/DeploymentsIndicator.php @@ -0,0 +1,50 @@ +get(); + + return ApplicationDeploymentQueue::with(['application.environment.project']) + ->whereIn('status', ['in_progress', 'queued']) + ->whereIn('server_id', $servers->pluck('id')) + ->orderBy('id') + ->get([ + 'id', + 'application_id', + 'application_name', + 'deployment_url', + 'pull_request_id', + 'server_name', + 'server_id', + 'status', + ]); + } + + #[Computed] + public function deploymentCount() + { + return $this->deployments->count(); + } + + public function toggleExpanded() + { + $this->expanded = ! $this->expanded; + } + + public function render() + { + return view('livewire.deployments-indicator'); + } +} diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index dacc0d4db..679926738 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -3,6 +3,8 @@ namespace App\Livewire; use App\Models\Application; +use App\Models\Environment; +use App\Models\Project; use App\Models\Server; use App\Models\Service; use App\Models\StandaloneClickhouse; @@ -26,12 +28,21 @@ class GlobalSearch extends Component public $allSearchableItems = []; + public $isCreateMode = false; + + public $creatableItems = []; + + public $autoOpenResource = null; + public function mount() { $this->searchQuery = ''; $this->isModalOpen = false; $this->searchResults = []; $this->allSearchableItems = []; + $this->isCreateMode = false; + $this->creatableItems = []; + $this->autoOpenResource = null; } public function openSearchModal() @@ -60,7 +71,63 @@ public static function clearTeamCache($teamId) public function updatedSearchQuery() { - $this->search(); + $query = strtolower(trim($this->searchQuery)); + + if (str_starts_with($query, 'new')) { + $this->isCreateMode = true; + $this->loadCreatableItems(); + $this->searchResults = []; + + // Check for sub-commands like "new project", "new server", etc. + // Use original query (not trimmed) to ensure exact match without trailing spaces + $this->autoOpenResource = $this->detectSpecificResource(strtolower($this->searchQuery)); + } else { + $this->isCreateMode = false; + $this->creatableItems = []; + $this->autoOpenResource = null; + $this->search(); + } + } + + private function detectSpecificResource(string $query): ?string + { + // Map of keywords to resource types - order matters for multi-word matches + $resourceMap = [ + 'new project' => 'project', + 'new server' => 'server', + 'new team' => 'team', + 'new storage' => 'storage', + 'new s3' => 'storage', + 'new private key' => 'private-key', + 'new privatekey' => 'private-key', + 'new key' => 'private-key', + 'new github' => 'source', + 'new source' => 'source', + 'new git' => 'source', + ]; + + foreach ($resourceMap as $command => $type) { + if ($query === $command) { + // Check if user has permission for this resource type + if ($this->canCreateResource($type)) { + return $type; + } + } + } + + return null; + } + + private function canCreateResource(string $type): bool + { + $user = auth()->user(); + + return match ($type) { + 'project', 'source' => $user->can('createAnyResource'), + 'server', 'storage', 'private-key' => $user->isAdmin() || $user->isOwner(), + 'team' => true, + default => false, + }; } private function loadSearchableItems() @@ -335,11 +402,81 @@ private function loadSearchableItems() ]; }); + // Get all projects + $projects = Project::ownedByCurrentTeam() + ->withCount(['environments', 'applications', 'services']) + ->get() + ->map(function ($project) { + $resourceCount = $project->applications_count + $project->services_count; + $resourceSummary = $resourceCount > 0 + ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') + : 'No resources'; + + return [ + 'id' => $project->id, + 'name' => $project->name, + 'type' => 'project', + 'uuid' => $project->uuid, + 'description' => $project->description, + 'link' => $project->navigateTo(), + 'project' => null, + 'environment' => null, + 'resource_count' => $resourceSummary, + 'environment_count' => $project->environments_count, + 'search_text' => strtolower($project->name.' '.$project->description.' project'), + ]; + }); + + // Get all environments + $environments = Environment::query() + ->whereHas('project', function ($query) { + $query->where('team_id', auth()->user()->currentTeam()->id); + }) + ->with('project') + ->withCount(['applications', 'services']) + ->get() + ->map(function ($environment) { + $resourceCount = $environment->applications_count + $environment->services_count; + $resourceSummary = $resourceCount > 0 + ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') + : 'No resources'; + + // Build description with project context + $descriptionParts = []; + if ($environment->project) { + $descriptionParts[] = "Project: {$environment->project->name}"; + } + if ($environment->description) { + $descriptionParts[] = $environment->description; + } + if (empty($descriptionParts)) { + $descriptionParts[] = $resourceSummary; + } + + return [ + 'id' => $environment->id, + 'name' => $environment->name, + 'type' => 'environment', + 'uuid' => $environment->uuid, + 'description' => implode(' • ', $descriptionParts), + 'link' => route('project.resource.index', [ + 'project_uuid' => $environment->project->uuid, + 'environment_uuid' => $environment->uuid, + ]), + 'project' => $environment->project->name ?? null, + 'environment' => null, + 'resource_count' => $resourceSummary, + 'search_text' => strtolower($environment->name.' '.$environment->description.' '.$environment->project->name.' environment'), + ]; + }); + // Merge all collections $items = $items->merge($applications) ->merge($services) ->merge($databases) - ->merge($servers); + ->merge($servers) + ->merge($projects) + ->merge($environments); return $items->toArray(); }); @@ -365,6 +502,72 @@ private function search() ->toArray(); } + private function loadCreatableItems() + { + $items = collect(); + $user = auth()->user(); + + // Project - can be created if user has createAnyResource permission + if ($user->can('createAnyResource')) { + $items->push([ + 'name' => 'Project', + 'description' => 'Create a new project to organize your resources', + 'type' => 'project', + 'component' => 'project.add-empty', + ]); + } + + // Server - can be created if user is admin or owner + if ($user->isAdmin() || $user->isOwner()) { + $items->push([ + 'name' => 'Server', + 'description' => 'Add a new server to deploy your applications', + 'type' => 'server', + 'component' => 'server.create', + ]); + } + + // Team - can be created by anyone (they become owner of new team) + $items->push([ + 'name' => 'Team', + 'description' => 'Create a new team to collaborate with others', + 'type' => 'team', + 'component' => 'team.create', + ]); + + // Storage - can be created if user is admin or owner + if ($user->isAdmin() || $user->isOwner()) { + $items->push([ + 'name' => 'S3 Storage', + 'description' => 'Add S3 storage for backups and file uploads', + 'type' => 'storage', + 'component' => 'storage.create', + ]); + } + + // Private Key - can be created if user is admin or owner + if ($user->isAdmin() || $user->isOwner()) { + $items->push([ + 'name' => 'Private Key', + 'description' => 'Add an SSH private key for server access', + 'type' => 'private-key', + 'component' => 'security.private-key.create', + ]); + } + + // GitHub Source - can be created if user has createAnyResource permission + if ($user->can('createAnyResource')) { + $items->push([ + 'name' => 'GitHub App', + 'description' => 'Connect a GitHub app for source control', + 'type' => 'source', + 'component' => 'source.github.create', + ]); + } + + $this->creatableItems = $items->toArray(); + } + public function render() { return view('livewire.global-search'); diff --git a/app/Livewire/Project/AddEmpty.php b/app/Livewire/Project/AddEmpty.php index 751b4945b..974f0608a 100644 --- a/app/Livewire/Project/AddEmpty.php +++ b/app/Livewire/Project/AddEmpty.php @@ -37,7 +37,12 @@ public function submit() 'uuid' => (string) new Cuid2, ]); - return redirect()->route('project.show', $project->uuid); + $productionEnvironment = $project->environments()->where('name', 'production')->first(); + + return redirect()->route('project.resource.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $productionEnvironment->uuid, + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index dccd1e499..ebdc014ae 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -50,6 +50,28 @@ public function force_start() } } + public function copyLogsToClipboard(): string + { + $logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); + + if (! $logs) { + return ''; + } + + $markdown = "# Deployment Logs\n\n"; + $markdown .= "```\n"; + + foreach ($logs as $log) { + if (isset($log['output'])) { + $markdown .= $log['output']."\n"; + } + } + + $markdown .= "```\n"; + + return $markdown; + } + public function cancel() { $deployment_uuid = $this->application_deployment_queue->deployment_uuid; diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index c77d050cb..b42f29fa5 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -210,10 +210,10 @@ public function mount() } } $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; - // Convert service names with dots to use underscores for HTML form binding + // Convert service names with dots and dashes to use underscores for HTML form binding $sanitizedDomains = []; foreach ($this->parsedServiceDomains as $serviceName => $domain) { - $sanitizedKey = str($serviceName)->slug('_')->toString(); + $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); $sanitizedDomains[$sanitizedKey] = $domain; } $this->parsedServiceDomains = $sanitizedDomains; @@ -305,10 +305,10 @@ public function loadComposeFile($isInit = false, $showToast = true) // Refresh parsedServiceDomains to reflect any changes in docker_compose_domains $this->application->refresh(); $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; - // Convert service names with dots to use underscores for HTML form binding + // Convert service names with dots and dashes to use underscores for HTML form binding $sanitizedDomains = []; foreach ($this->parsedServiceDomains as $serviceName => $domain) { - $sanitizedKey = str($serviceName)->slug('_')->toString(); + $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); $sanitizedDomains[$sanitizedKey] = $domain; } $this->parsedServiceDomains = $sanitizedDomains; @@ -334,7 +334,7 @@ public function generateDomain(string $serviceName) $uuid = new Cuid2; $domain = generateUrl(server: $this->application->destination->server, random: $uuid); - $sanitizedKey = str($serviceName)->slug('_')->toString(); + $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); $this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain; // Convert back to original service names for storage @@ -344,7 +344,7 @@ public function generateDomain(string $serviceName) $originalServiceName = $key; if (isset($this->parsedServices['services'])) { foreach ($this->parsedServices['services'] as $originalName => $service) { - if (str($originalName)->slug('_')->toString() === $key) { + if (str($originalName)->replace('-', '_')->replace('.', '_')->toString() === $key) { $originalServiceName = $originalName; break; } @@ -544,12 +544,16 @@ public function submit($showToaster = true) { try { $this->authorize('update', $this->application); + + $this->validate(); + $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $domain = trim($domain); Url::fromString($domain, ['http', 'https']); - return str($domain)->trim()->lower(); + return str($domain)->lower(); }); $this->application->fqdn = $this->application->fqdn->unique()->implode(','); @@ -583,7 +587,6 @@ public function submit($showToaster = true) return; } } - $this->validate(); if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) { $this->resetDefaultLabels(); diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php index 2632509ea..cfb364b6d 100644 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ b/app/Livewire/Project/Application/PreviewsCompose.php @@ -72,10 +72,13 @@ public function generate() $template = $this->preview->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->preview->pull_request_id, $preview_fqdn); + $preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn); $preview_fqdn = "$schema://$preview_fqdn"; } diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 29be68b6c..ab2517f2b 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -47,6 +47,21 @@ public function mount() } } + public function updatedGitRepository() + { + $this->gitRepository = trim($this->gitRepository); + } + + public function updatedGitBranch() + { + $this->gitBranch = trim($this->gitBranch); + } + + public function updatedGitCommitSha() + { + $this->gitCommitSha = trim($this->gitCommitSha); + } + public function syncData(bool $toModel = false) { if ($toModel) { @@ -57,6 +72,9 @@ public function syncData(bool $toModel = false) 'git_commit_sha' => $this->gitCommitSha, 'private_key_id' => $this->privateKeyId, ]); + // Refresh to get the trimmed values from the model + $this->application->refresh(); + $this->syncData(false); } else { $this->gitRepository = $this->application->git_repository; $this->gitBranch = $this->application->git_branch; diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 98d076ac0..b3df79008 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -208,7 +208,7 @@ private function customValidate() // Validate that disable_local_backup can only be true when S3 backup is enabled if ($this->backup->disable_local_backup && ! $this->backup->save_s3) { - throw new \Exception('Local backup can only be disabled when S3 backup is enabled.'); + $this->backup->disable_local_backup = $this->disableLocalBackup = false; } $isValid = validate_cron_expression($this->backup->frequency); diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 2f3aae8cf..0b6d8338b 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -202,11 +202,6 @@ public function server() public function render() { - return view('livewire.project.database.backup-executions', [ - 'checkboxes' => [ - ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'], - // ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently from SFTP Storage'], - ], - ]); + return view('livewire.project.database.backup-executions'); } } diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 5381fa78d..a27a3652f 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -21,6 +21,14 @@ public function mount() $this->projects = Project::ownedByCurrentTeam()->get()->map(function ($project) { $project->settingsRoute = route('project.edit', ['project_uuid' => $project->uuid]); $project->canUpdate = auth()->user()->can('update', $project); + $project->canCreateResource = auth()->user()->can('createAnyResource'); + $firstEnvironment = $project->environments->first(); + $project->addResourceRoute = $firstEnvironment + ? route('project.resource.create', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $firstEnvironment->uuid, + ]) + : null; return $project; }); diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index dbb223de2..e105c956a 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -12,7 +12,11 @@ class DockerImage extends Component { - public string $dockerImage = ''; + public string $imageName = ''; + + public string $imageTag = ''; + + public string $imageSha256 = ''; public array $parameters; @@ -26,12 +30,41 @@ public function mount() public function submit() { + // Strip 'sha256:' prefix if user pasted it + if ($this->imageSha256) { + $this->imageSha256 = preg_replace('/^sha256:/i', '', trim($this->imageSha256)); + } + + // Remove @sha256 from image name if user added it + if ($this->imageName) { + $this->imageName = preg_replace('/@sha256$/i', '', trim($this->imageName)); + } + $this->validate([ - 'dockerImage' => 'required', + 'imageName' => ['required', 'string'], + 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'], + 'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'], ]); + // Validate that either tag or sha256 is provided, but not both + if ($this->imageTag && $this->imageSha256) { + $this->addError('imageTag', 'Provide either a tag or SHA256 digest, not both.'); + $this->addError('imageSha256', 'Provide either a tag or SHA256 digest, not both.'); + + return; + } + + // Build the full Docker image string + if ($this->imageSha256) { + $dockerImage = $this->imageName.'@sha256:'.$this->imageSha256; + } elseif ($this->imageTag) { + $dockerImage = $this->imageName.':'.$this->imageTag; + } else { + $dockerImage = $this->imageName.':latest'; + } + $parser = new DockerImageParser; - $parser->parse($this->dockerImage); + $parser->parse($dockerImage); $destination_uuid = $this->query['destination']; $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); @@ -45,6 +78,16 @@ public function submit() $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + + // Determine the image tag based on whether it's a hash or regular tag + $imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag(); + + // Append @sha256 to image name if using digest and not already present + $imageName = $parser->getFullImageNameWithoutTag(); + if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) { + $imageName .= '@sha256'; + } + $application = Application::create([ 'name' => 'docker-image-'.new Cuid2, 'repository_project_id' => 0, @@ -52,7 +95,7 @@ public function submit() 'git_branch' => 'main', 'build_pack' => 'dockerimage', 'ports_exposes' => 80, - 'docker_registry_image_name' => $parser->getFullImageNameWithoutTag(), + 'docker_registry_image_name' => $imageName, 'docker_registry_image_tag' => $parser->getTag(), 'environment_id' => $environment->id, 'destination_id' => $destination->id, diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 0f496e6db..27ecacb99 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -55,7 +55,7 @@ class GithubPrivateRepository extends Component public ?string $publish_directory = null; // In case of docker compose - public ?string $base_directory = null; + public ?string $base_directory = '/'; public ?string $docker_compose_location = '/docker-compose.yaml'; // End of docker compose @@ -143,7 +143,13 @@ public function loadBranches() protected function loadBranchByPage() { - $response = Http::withToken($this->token)->get("{$this->github_app->api_url}/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches?per_page=100&page={$this->page}"); + $response = Http::GitHub($this->github_app->api_url, $this->token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [ + 'per_page' => 100, + 'page' => $this->page, + ]); $json = $response->json(); if ($response->status() !== 200) { return $this->dispatch('error', $json['message']); @@ -192,6 +198,7 @@ public function submit() 'build_pack' => $this->build_pack, 'ports_exposes' => $this->port, 'publish_directory' => $this->publish_directory, + 'base_directory' => $this->base_directory, 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, @@ -206,7 +213,6 @@ public function submit() } if ($this->build_pack === 'dockercompose') { $application['docker_compose_location'] = $this->docker_compose_location; - $application['base_directory'] = $this->base_directory; } $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 5ff8f9137..77b106200 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -90,7 +90,7 @@ protected function rules() public function mount() { if (isDev()) { - $this->repository_url = 'https://github.com/coollabsio/coolify-examples'; + $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x'; } $this->parameters = get_route_parameters(); $this->query = request()->query(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index f5978aea1..89814ee7f 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -100,7 +100,7 @@ protected function rules() public function mount() { if (isDev()) { - $this->repository_url = 'https://github.com/coollabsio/coolify-examples'; + $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x'; $this->port = 3000; } $this->parameters = get_route_parameters(); @@ -176,13 +176,16 @@ public function loadBranch() str($this->repository_url)->startsWith('http://')) && ! str($this->repository_url)->endsWith('.git') && (! str($this->repository_url)->contains('github.com') || - ! str($this->repository_url)->contains('git.sr.ht')) + ! str($this->repository_url)->contains('git.sr.ht')) && + ! str($this->repository_url)->contains('tangled') ) { + $this->repository_url = $this->repository_url.'.git'; } if (str($this->repository_url)->contains('github.com') && str($this->repository_url)->endsWith('.git')) { $this->repository_url = str($this->repository_url)->beforeLast('.git')->value(); } + } catch (\Throwable $e) { return handleError($e, $this); } @@ -190,6 +193,9 @@ public function loadBranch() $this->branchFound = false; $this->getGitSource(); $this->getBranch(); + if (str($this->repository_url)->contains('tangled')) { + $this->git_branch = 'master'; + } $this->selectedBranch = $this->git_branch; } catch (\Throwable $e) { if ($this->rate_limit_remaining == 0) { diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index 5ce170b99..7c718393d 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -41,9 +41,10 @@ public function submit() $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $domain = trim($domain); Url::fromString($domain, ['http', 'https']); - return str($domain)->trim()->lower(); + return str($domain)->lower(); }); $this->application->fqdn = $this->application->fqdn->unique()->implode(','); $warning = sslipDomainWarning($this->application->fqdn); diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 2933a8cca..7f0caaba3 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -34,6 +34,8 @@ class FileStorage extends Component public bool $permanently_delete = true; + public bool $isReadOnly = false; + protected $rules = [ 'fileStorage.is_directory' => 'required', 'fileStorage.fs_path' => 'required', @@ -52,6 +54,8 @@ public function mount() $this->workdir = null; $this->fs_path = $this->fileStorage->fs_path; } + + $this->isReadOnly = $this->fileStorage->isReadOnlyVolume(); } public function convertToDirectory() diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 3ac12cfe9..e37b6ad86 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -149,9 +149,10 @@ public function submit() $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $domain = trim($domain); Url::fromString($domain, ['http', 'https']); - return str($domain)->trim()->lower(); + return str($domain)->lower(); }); $this->application->fqdn = $this->application->fqdn->unique()->implode(','); $warning = sslipDomainWarning($this->application->fqdn); diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 26cd54425..db171db24 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -14,6 +14,22 @@ class Storage extends Component public $fileStorage; + public $isSwarm = false; + + public string $name = ''; + + public string $mount_path = ''; + + public ?string $host_path = null; + + public string $file_storage_path = ''; + + public ?string $file_storage_content = null; + + public string $file_storage_directory_source = ''; + + public string $file_storage_directory_destination = ''; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -27,6 +43,18 @@ public function getListeners() public function mount() { + if (str($this->resource->getMorphClass())->contains('Standalone')) { + $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}"; + } else { + $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}"; + } + + if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->destination->server->isSwarm()) { + $this->isSwarm = true; + } + } + $this->refreshStorages(); } @@ -39,30 +67,151 @@ public function refreshStoragesFromEvent() public function refreshStorages() { $this->fileStorage = $this->resource->fileStorages()->get(); - $this->dispatch('$refresh'); + $this->resource->refresh(); } - public function addNewVolume($data) + public function getFilesProperty() + { + return $this->fileStorage->where('is_directory', false); + } + + public function getDirectoriesProperty() + { + return $this->fileStorage->where('is_directory', true); + } + + public function getVolumeCountProperty() + { + return $this->resource->persistentStorages()->count(); + } + + public function getFileCountProperty() + { + return $this->files->count(); + } + + public function getDirectoryCountProperty() + { + return $this->directories->count(); + } + + public function submitPersistentVolume() { try { $this->authorize('update', $this->resource); + $this->validate([ + 'name' => 'required|string', + 'mount_path' => 'required|string', + 'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable', + ]); + + $name = $this->resource->uuid.'-'.$this->name; + LocalPersistentVolume::create([ - 'name' => $data['name'], - 'mount_path' => $data['mount_path'], - 'host_path' => $data['host_path'], + 'name' => $name, + 'mount_path' => $this->mount_path, + 'host_path' => $this->host_path, 'resource_id' => $this->resource->id, 'resource_type' => $this->resource->getMorphClass(), ]); $this->resource->refresh(); - $this->dispatch('success', 'Storage added successfully'); - $this->dispatch('clearAddStorage'); - $this->dispatch('refreshStorages'); + $this->dispatch('success', 'Volume added successfully'); + $this->dispatch('closeStorageModal', 'volume'); + $this->clearForm(); + $this->refreshStorages(); } catch (\Throwable $e) { return handleError($e, $this); } } + public function submitFileStorage() + { + try { + $this->authorize('update', $this->resource); + + $this->validate([ + 'file_storage_path' => 'required|string', + 'file_storage_content' => 'nullable|string', + ]); + + $this->file_storage_path = trim($this->file_storage_path); + $this->file_storage_path = str($this->file_storage_path)->start('/')->value(); + + if ($this->resource->getMorphClass() === \App\Models\Application::class) { + $fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; + } elseif (str($this->resource->getMorphClass())->contains('Standalone')) { + $fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; + } else { + throw new \Exception('No valid resource type for file mount storage type!'); + } + + \App\Models\LocalFileVolume::create([ + 'fs_path' => $fs_path, + 'mount_path' => $this->file_storage_path, + 'content' => $this->file_storage_content, + 'is_directory' => false, + 'resource_id' => $this->resource->id, + 'resource_type' => get_class($this->resource), + ]); + + $this->dispatch('success', 'File mount added successfully'); + $this->dispatch('closeStorageModal', 'file'); + $this->clearForm(); + $this->refreshStorages(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function submitFileStorageDirectory() + { + try { + $this->authorize('update', $this->resource); + + $this->validate([ + 'file_storage_directory_source' => 'required|string', + 'file_storage_directory_destination' => 'required|string', + ]); + + $this->file_storage_directory_source = trim($this->file_storage_directory_source); + $this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value(); + $this->file_storage_directory_destination = trim($this->file_storage_directory_destination); + $this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value(); + + \App\Models\LocalFileVolume::create([ + 'fs_path' => $this->file_storage_directory_source, + 'mount_path' => $this->file_storage_directory_destination, + 'is_directory' => true, + 'resource_id' => $this->resource->id, + 'resource_type' => get_class($this->resource), + ]); + + $this->dispatch('success', 'Directory mount added successfully'); + $this->dispatch('closeStorageModal', 'directory'); + $this->clearForm(); + $this->refreshStorages(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function clearForm() + { + $this->name = ''; + $this->mount_path = ''; + $this->host_path = null; + $this->file_storage_path = ''; + $this->file_storage_content = null; + $this->file_storage_directory_destination = ''; + + if (str($this->resource->getMorphClass())->contains('Standalone')) { + $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}"; + } else { + $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}"; + } + } + public function render() { return view('livewire.project.service.storage'); diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index ab9f3785d..ce9ce7780 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -20,7 +20,15 @@ class ConfigurationChecker extends Component public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; - protected $listeners = ['configurationChanged']; + public function getListeners() + { + $teamId = auth()->user()->currentTeam()->id; + + return [ + "echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged', + 'configurationChanged' => 'configurationChanged', + ]; + } public function mount() { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 23a2cd59d..5f5e12e0a 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -2,12 +2,13 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Traits\EnvironmentVariableAnalyzer; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Add extends Component { - use AuthorizesRequests; + use AuthorizesRequests, EnvironmentVariableAnalyzer; public $parameters; @@ -27,6 +28,8 @@ class Add extends Component public bool $is_buildtime = true; + public array $problematicVariables = []; + protected $listeners = ['clearAddEnv' => 'clear']; protected $rules = [ @@ -50,6 +53,7 @@ class Add extends Component public function mount() { $this->parameters = get_route_parameters(); + $this->problematicVariables = self::getProblematicVariablesForFrontend(); } public function submit() diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 639c025c7..07938d9d0 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -212,6 +212,12 @@ private function handleSingleSubmit($data) $environment = $this->createEnvironmentVariable($data); $environment->order = $maxOrder + 1; $environment->save(); + + // Clear computed property cache to force refresh + unset($this->environmentVariables); + unset($this->environmentVariablesPreview); + + $this->dispatch('success', 'Environment variable added.'); } private function createEnvironmentVariable($data) @@ -300,6 +306,9 @@ private function updateOrCreateVariables($isPreview, $variables) public function refreshEnvs() { $this->resource->refresh(); + // Clear computed property cache to force refresh + unset($this->environmentVariables); + unset($this->environmentVariablesPreview); $this->getDevView(); } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 0d0467c13..3b8d244cc 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -4,13 +4,14 @@ use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Models\SharedEnvironmentVariable; +use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { - use AuthorizesRequests, EnvironmentVariableProtection; + use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection; public $parameters; @@ -48,6 +49,8 @@ class Show extends Component public bool $is_redis_credential = false; + public array $problematicVariables = []; + protected $listeners = [ 'refreshEnvs' => 'refresh', 'refresh', @@ -77,6 +80,7 @@ public function mount() if ($this->type === 'standalone-redis' && ($this->env->key === 'REDIS_PASSWORD' || $this->env->key === 'REDIS_USERNAME')) { $this->is_redis_credential = true; } + $this->problematicVariables = self::getProblematicVariablesForFrontend(); } public function getResourceProperty() diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index ae94f7cf2..c0714fe03 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -47,6 +47,24 @@ public function submit() } } + public function toggleHealthcheck() + { + try { + $this->authorize('update', $this->resource); + $wasEnabled = $this->resource->health_check_enabled; + $this->resource->health_check_enabled = ! $this->resource->health_check_enabled; + $this->resource->save(); + + if ($this->resource->health_check_enabled && ! $wasEnabled && $this->resource->isRunning()) { + $this->dispatch('info', 'Health check has been enabled. A restart is required to apply the new settings.'); + } else { + $this->dispatch('success', 'Health check '.($this->resource->health_check_enabled ? 'enabled' : 'disabled').'.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.project.shared.health-checks'); diff --git a/app/Livewire/Project/Shared/Metrics.php b/app/Livewire/Project/Shared/Metrics.php index fdc35fc0f..e5b87b48c 100644 --- a/app/Livewire/Project/Shared/Metrics.php +++ b/app/Livewire/Project/Shared/Metrics.php @@ -8,7 +8,7 @@ class Metrics extends Component { public $resource; - public $chartId = 'container-cpu'; + public $chartId = 'metrics'; public $data; diff --git a/app/Livewire/Project/Shared/Storages/Add.php b/app/Livewire/Project/Shared/Storages/Add.php deleted file mode 100644 index 006d41c14..000000000 --- a/app/Livewire/Project/Shared/Storages/Add.php +++ /dev/null @@ -1,174 +0,0 @@ - 'required|string', - 'mount_path' => 'required|string', - 'host_path' => 'string|nullable', - 'file_storage_path' => 'string', - 'file_storage_content' => 'nullable|string', - 'file_storage_directory_source' => 'string', - 'file_storage_directory_destination' => 'string', - ]; - - protected $listeners = ['clearAddStorage' => 'clear']; - - protected $validationAttributes = [ - 'name' => 'name', - 'mount_path' => 'mount', - 'host_path' => 'host', - 'file_storage_path' => 'file storage path', - 'file_storage_content' => 'file storage content', - 'file_storage_directory_source' => 'file storage directory source', - 'file_storage_directory_destination' => 'file storage directory destination', - ]; - - public function mount() - { - if (str($this->resource->getMorphClass())->contains('Standalone')) { - $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}"; - } else { - $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}"; - } - $this->uuid = $this->resource->uuid; - $this->parameters = get_route_parameters(); - if (data_get($this->parameters, 'application_uuid')) { - $applicationUuid = $this->parameters['application_uuid']; - $application = Application::where('uuid', $applicationUuid)->first(); - if (! $application) { - abort(404); - } - if ($application->destination->server->isSwarm()) { - $this->isSwarm = true; - $this->rules['host_path'] = 'required|string'; - } - } - } - - public function submitFileStorage() - { - try { - $this->authorize('update', $this->resource); - - $this->validate([ - 'file_storage_path' => 'string', - 'file_storage_content' => 'nullable|string', - ]); - - $this->file_storage_path = trim($this->file_storage_path); - $this->file_storage_path = str($this->file_storage_path)->start('/')->value(); - - if ($this->resource->getMorphClass() === \App\Models\Application::class) { - $fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; - } elseif (str($this->resource->getMorphClass())->contains('Standalone')) { - $fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; - } else { - throw new \Exception('No valid resource type for file mount storage type!'); - } - - LocalFileVolume::create( - [ - 'fs_path' => $fs_path, - 'mount_path' => $this->file_storage_path, - 'content' => $this->file_storage_content, - 'is_directory' => false, - 'resource_id' => $this->resource->id, - 'resource_type' => get_class($this->resource), - ], - ); - $this->dispatch('refreshStorages'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function submitFileStorageDirectory() - { - try { - $this->authorize('update', $this->resource); - - $this->validate([ - 'file_storage_directory_source' => 'string', - 'file_storage_directory_destination' => 'string', - ]); - - $this->file_storage_directory_source = trim($this->file_storage_directory_source); - $this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value(); - $this->file_storage_directory_destination = trim($this->file_storage_directory_destination); - $this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value(); - - LocalFileVolume::create( - [ - 'fs_path' => $this->file_storage_directory_source, - 'mount_path' => $this->file_storage_directory_destination, - 'is_directory' => true, - 'resource_id' => $this->resource->id, - 'resource_type' => get_class($this->resource), - ], - ); - $this->dispatch('refreshStorages'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function submitPersistentVolume() - { - try { - $this->authorize('update', $this->resource); - - $this->validate([ - 'name' => 'required|string', - 'mount_path' => 'required|string', - 'host_path' => 'string|nullable', - ]); - $name = $this->uuid.'-'.$this->name; - $this->dispatch('addNewVolume', [ - 'name' => $name, - 'mount_path' => $this->mount_path, - 'host_path' => $this->host_path, - ]); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function clear() - { - $this->name = ''; - $this->mount_path = ''; - $this->host_path = null; - } -} diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 3928ee1d4..4f57cbfa6 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -37,6 +37,11 @@ class Show extends Component 'host_path' => 'host', ]; + public function mount() + { + $this->isReadOnly = $this->storage->isReadOnlyVolume(); + } + public function submit() { $this->authorize('update', $this->resource); diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index 760c4df0d..8d17bb557 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -2,10 +2,7 @@ namespace App\Livewire\Server; -use App\Models\InstanceSettings; use App\Models\Server; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Validate; use Livewire\Component; @@ -27,9 +24,6 @@ class Advanced extends Component #[Validate(['integer', 'min:1'])] public int $dynamicTimeout = 1; - #[Validate(['boolean'])] - public bool $isTerminalEnabled = false; - public function mount(string $server_uuid) { try { @@ -42,37 +36,6 @@ public function mount(string $server_uuid) } } - public function toggleTerminal($password) - { - try { - // Check if user is admin or owner - if (! auth()->user()->isAdmin()) { - 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; - } - } - - // Toggle the terminal setting - $this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled; - $this->server->settings->save(); - - // Update the local property - $this->isTerminalEnabled = $this->server->settings->is_terminal_enabled; - - $status = $this->isTerminalEnabled ? 'enabled' : 'disabled'; - $this->dispatch('success', "Terminal access has been {$status}."); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - public function syncData(bool $toModel = false) { if ($toModel) { @@ -88,7 +51,6 @@ public function syncData(bool $toModel = false) $this->dynamicTimeout = $this->server->settings->dynamic_timeout; $this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold; $this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency; - $this->isTerminalEnabled = $this->server->settings->is_terminal_enabled; } } diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index 845d568ce..fd55717fa 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -5,6 +5,7 @@ use App\Models\PrivateKey; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Show extends Component @@ -35,19 +36,20 @@ public function setPrivateKey($privateKeyId) return; } - - $originalPrivateKeyId = $this->server->getOriginal('private_key_id'); try { $this->authorize('update', $this->server); - $this->server->update(['private_key_id' => $privateKeyId]); - ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true); - if ($uptime) { - $this->dispatch('success', 'Private key updated successfully.'); - } else { - throw new \Exception($error); - } + DB::transaction(function () use ($ownedPrivateKey) { + $this->server->privateKey()->associate($ownedPrivateKey); + $this->server->save(); + ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true); + if (! $uptime) { + throw new \Exception($error); + } + }); + $this->dispatch('success', 'Private key updated successfully.'); + $this->dispatch('refreshServerShow'); } catch (\Exception $e) { - $this->server->update(['private_key_id' => $originalPrivateKeyId]); + $this->server->refresh(); $this->server->validateConnection(); $this->dispatch('error', $e->getMessage()); } @@ -59,6 +61,7 @@ public function checkConnection() ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); if ($uptime) { $this->dispatch('success', 'Server is reachable.'); + $this->dispatch('refreshServerShow'); } else { $this->dispatch('error', 'Server is not reachable.

Check this documentation for further help.

Error: '.$error); diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 6ccca644a..5ef559862 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -45,7 +45,7 @@ public function mount() public function getConfigurationFilePathProperty() { - return $this->server->proxyPath().'/docker-compose.yml'; + return $this->server->proxyPath().'docker-compose.yml'; } public function changeProxy() diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php new file mode 100644 index 000000000..284eea7dd --- /dev/null +++ b/app/Livewire/Server/Security/TerminalAccess.php @@ -0,0 +1,85 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->authorize('update', $this->server); + $this->parameters = get_route_parameters(); + $this->syncData(); + + } catch (\Throwable) { + return redirect()->route('server.index'); + } + } + + public function toggleTerminal($password) + { + try { + $this->authorize('update', $this->server); + + // Check if user is admin or owner + if (! auth()->user()->isAdmin()) { + 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; + } + } + + // Toggle the terminal setting + $this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled; + $this->server->settings->save(); + + // Update the local property + $this->isTerminalEnabled = $this->server->settings->is_terminal_enabled; + + $status = $this->isTerminalEnabled ? 'enabled' : 'disabled'; + $this->dispatch('success', "Terminal access has been {$status}."); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function syncData(bool $toModel = false) + { + if ($toModel) { + $this->authorize('update', $this->server); + $this->validate(); + // No other fields to sync for terminal access + } else { + $this->isTerminalEnabled = $this->server->settings->is_terminal_enabled; + } + } + + public function render() + { + return view('livewire.server.security.terminal-access'); + } +} diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 473e0b60e..db4dc9b88 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -271,7 +271,7 @@ public function restartSentinel() $this->authorize('manageSentinel', $this->server); $customImage = isDev() ? $this->sentinelCustomDockerImage : null; $this->server->restartSentinel($customImage); - $this->dispatch('success', 'Restarting Sentinel.'); + $this->dispatch('info', 'Restarting Sentinel.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -355,7 +355,7 @@ public function regenerateSentinelToken() public function instantSave() { try { - $this->submit(); + $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); } @@ -365,7 +365,7 @@ public function submit() { try { $this->syncData(true); - $this->dispatch('success', 'Server updated.'); + $this->dispatch('success', 'Server settings updated.'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index c75474e44..bf0b7b6a5 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -146,7 +146,7 @@ public function validateDockerVersion() StartProxy::dispatch($this->server); } else { $requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); - $this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not instaled. Please install Docker manually before continuing: documentation.'; + $this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: documentation.'; $this->server->update([ 'validation_logs' => $this->error, ]); diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index 57cb79fca..84f5c6081 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -120,6 +120,8 @@ public function addCoolifyDatabase() public function submit() { + $this->validate(); + $this->database->update([ 'name' => $this->name, 'description' => $this->description, diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 41541f6b9..9438b7727 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -5,6 +5,7 @@ use App\Models\S3Storage; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Form extends Component @@ -91,9 +92,24 @@ public function submit() try { $this->authorize('update', $this->storage); - $this->validate(); - $this->testConnection(); + DB::transaction(function () { + $this->validate(); + $this->storage->save(); + + // Test connection with new values - if this fails, transaction will rollback + $this->storage->testConnection(shouldSave: false); + + // If we get here, the connection test succeeded + $this->storage->is_usable = true; + $this->storage->unusable_email_sent = false; + $this->storage->save(); + }); + + $this->dispatch('success', 'Storage settings updated and connection verified.'); } catch (\Throwable $e) { + // Refresh the model to revert UI to database values after rollback + $this->storage->refresh(); + return handleError($e, $this); } } diff --git a/app/Livewire/Team/Create.php b/app/Livewire/Team/Create.php index d3d27556c..cd15be67d 100644 --- a/app/Livewire/Team/Create.php +++ b/app/Livewire/Team/Create.php @@ -35,7 +35,7 @@ public function submit() 'personal_team' => false, ]); auth()->user()->teams()->attach($team, ['role' => 'admin']); - refreshSession(); + refreshSession($team); return redirect()->route('team.index'); } catch (\Throwable $e) { diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index 0bac39db8..45f7e467f 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -48,6 +48,8 @@ private function generateInviteLink(bool $sendEmail = false) if (auth()->user()->role() === 'admin' && $this->role === 'owner') { throw new \Exception('Admins cannot invite owners.'); } + $this->email = strtolower($this->email); + $member_emails = currentTeam()->members()->get()->pluck('email'); if ($member_emails->contains($this->email)) { return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 094e5c82b..595ba1cde 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -155,6 +155,15 @@ protected static function booted() if ($application->isDirty('publish_directory')) { $payload['publish_directory'] = str($application->publish_directory)->trim(); } + if ($application->isDirty('git_repository')) { + $payload['git_repository'] = str($application->git_repository)->trim(); + } + if ($application->isDirty('git_branch')) { + $payload['git_branch'] = str($application->git_branch)->trim(); + } + if ($application->isDirty('git_commit_sha')) { + $payload['git_commit_sha'] = str($application->git_commit_sha)->trim(); + } if ($application->isDirty('status')) { $payload['last_online_at'] = now(); } @@ -173,6 +182,21 @@ protected static function booted() ]); $application->compose_parsing_version = self::$parserVersion; $application->save(); + + // Add default NIXPACKS_NODE_VERSION environment variable for Nixpacks applications + if ($application->build_pack === 'nixpacks') { + EnvironmentVariable::create([ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_multiline' => false, + 'is_literal' => false, + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + } }); static::forceDeleting(function ($application) { $application->update(['fqdn' => null]); @@ -730,9 +754,9 @@ public function environment_variables() return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) ->orderByRaw(" - CASE - WHEN LOWER(key) LIKE 'service_%' THEN 1 - WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + CASE + WHEN is_required = true THEN 1 + WHEN LOWER(key) LIKE 'service_%' THEN 2 ELSE 3 END, LOWER(key) ASC @@ -758,9 +782,9 @@ public function environment_variables_preview() return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) ->orderByRaw(" - CASE - WHEN LOWER(key) LIKE 'service_%' THEN 1 - WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + CASE + WHEN is_required = true THEN 1 + WHEN LOWER(key) LIKE 'service_%' THEN 2 ELSE 3 END, LOWER(key) ASC @@ -1477,16 +1501,17 @@ public function loadComposeFile($isInit = false) $this->save(); $parsedServices = $this->parse(); if ($this->docker_compose_domains) { - $json = collect(json_decode($this->docker_compose_domains)); + $decoded = json_decode($this->docker_compose_domains, true); + $json = collect(is_array($decoded) ? $decoded : []); + $normalized = collect(); foreach ($json as $key => $value) { - if (str($key)->contains('-')) { - $key = str($key)->replace('-', '_')->replace('.', '_'); - } - $json->put((string) $key, $value); + $normalizedKey = (string) str($key)->replace('-', '_')->replace('.', '_'); + $normalized->put($normalizedKey, $value); } + $json = $normalized; $services = collect(data_get($parsedServices, 'services', [])); foreach ($services as $name => $service) { - if (str($name)->contains('-')) { + if (str($name)->contains('-') || str($name)->contains('.')) { $replacedName = str($name)->replace('-', '_')->replace('.', '_'); $services->put((string) $replacedName, $service); $services->forget((string) $name); @@ -1555,40 +1580,206 @@ protected function buildGitCheckoutCommand($target): string return $command; } + private function parseWatchPaths($value) + { + if ($value) { + $watch_paths = collect(explode("\n", $value)) + ->map(function (string $path): string { + // Trim whitespace + $path = trim($path); + + if (str_starts_with($path, '!')) { + $negation = '!'; + $pathWithoutNegation = substr($path, 1); + $pathWithoutNegation = ltrim(trim($pathWithoutNegation), '/'); + + return $negation.$pathWithoutNegation; + } + + return ltrim($path, '/'); + }) + ->filter(function (string $path): bool { + return strlen($path) > 0; + }); + + return trim($watch_paths->implode("\n")); + } + } + public function watchPaths(): Attribute { return Attribute::make( set: function ($value) { if ($value) { - return trim($value); + return $this->parseWatchPaths($value); } } ); } + public function matchWatchPaths(Collection $modified_files, ?Collection $watch_paths): Collection + { + return self::matchPaths($modified_files, $watch_paths); + } + + /** + * Static method to match paths against watch patterns with negation support + * Uses order-based matching: last matching pattern wins + */ + public static function matchPaths(Collection $modified_files, ?Collection $watch_paths): Collection + { + if (is_null($watch_paths) || $watch_paths->isEmpty()) { + return collect([]); + } + + return $modified_files->filter(function ($file) use ($watch_paths) { + $shouldInclude = null; // null means no patterns matched + + // Process patterns in order - last match wins + foreach ($watch_paths as $pattern) { + $pattern = trim($pattern); + if (empty($pattern)) { + continue; + } + + $isExclusion = str_starts_with($pattern, '!'); + $matchPattern = $isExclusion ? substr($pattern, 1) : $pattern; + + if (self::globMatch($matchPattern, $file)) { + // This pattern matches - it determines the current state + $shouldInclude = ! $isExclusion; + } + } + + // If no patterns matched and we only have exclusion patterns, include by default + if ($shouldInclude === null) { + // Check if we only have exclusion patterns + $hasInclusionPatterns = $watch_paths->contains(fn ($p) => ! str_starts_with(trim($p), '!')); + + return ! $hasInclusionPatterns; + } + + return $shouldInclude; + })->values(); + } + + /** + * Check if a path matches a glob pattern + * Supports: *, **, ?, [abc], [!abc] + */ + public static function globMatch(string $pattern, string $path): bool + { + $regex = self::globToRegex($pattern); + + return preg_match($regex, $path) === 1; + } + + /** + * Convert a glob pattern to a regular expression + */ + public static function globToRegex(string $pattern): string + { + $regex = ''; + $inGroup = false; + $chars = str_split($pattern); + $len = count($chars); + + for ($i = 0; $i < $len; $i++) { + $c = $chars[$i]; + + switch ($c) { + case '*': + // Check for ** + if ($i + 1 < $len && $chars[$i + 1] === '*') { + // ** matches any number of directories + $regex .= '.*'; + $i++; // Skip next * + // Skip optional / + if ($i + 1 < $len && $chars[$i + 1] === '/') { + $i++; + } + } else { + // * matches anything except / + $regex .= '[^/]*'; + } + break; + + case '?': + // ? matches any single character except / + $regex .= '[^/]'; + break; + + case '[': + // Character class + $inGroup = true; + $regex .= '['; + // Check for negation + if ($i + 1 < $len && ($chars[$i + 1] === '!' || $chars[$i + 1] === '^')) { + $regex .= '^'; + $i++; + } + break; + + case ']': + if ($inGroup) { + $inGroup = false; + $regex .= ']'; + } else { + $regex .= preg_quote($c, '#'); + } + break; + + case '.': + case '(': + case ')': + case '+': + case '{': + case '}': + case '$': + case '^': + case '|': + case '\\': + // Escape regex special characters + $regex .= '\\'.$c; + break; + + default: + $regex .= $c; + break; + } + } + + // Wrap in delimiters and anchors + return '#^'.$regex.'$#'; + } + + public function normalizeWatchPaths(): void + { + if (is_null($this->watch_paths)) { + return; + } + + $normalized = $this->parseWatchPaths($this->watch_paths); + if ($normalized !== $this->watch_paths) { + $this->watch_paths = $normalized; + $this->save(); + } + } + public function isWatchPathsTriggered(Collection $modified_files): bool { if (is_null($this->watch_paths)) { return false; } - $watch_paths = collect(explode("\n", $this->watch_paths)) - ->map(function (string $path): string { - return trim($path); - }) - ->filter(function (string $path): bool { - return strlen($path) > 0; - }); - // If no valid patterns after filtering, don't trigger + $this->normalizeWatchPaths(); + + $watch_paths = collect(explode("\n", $this->watch_paths)); + if ($watch_paths->isEmpty()) { return false; } - - $matches = $modified_files->filter(function ($file) use ($watch_paths) { - return $watch_paths->contains(function ($glob) use ($file) { - return fnmatch($glob, $file); - }); - }); + $matches = $this->matchWatchPaths($modified_files, $watch_paths); return $matches->count() > 0; } diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 2a9bea67a..4e8eee10f 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -41,11 +41,9 @@ class ApplicationDeploymentQueue extends Model { protected $guarded = []; - public function application(): Attribute + public function application() { - return Attribute::make( - get: fn () => Application::find($this->application_id), - ); + return $this->belongsTo(Application::class); } public function server(): Attribute @@ -85,6 +83,47 @@ public function commitMessage() return str($this->commit_message)->value(); } + private function redactSensitiveInfo($text) + { + $text = remove_iip($text); + + $app = $this->application; + if (! $app) { + return $text; + } + + $lockedVars = collect([]); + + if ($app->environment_variables) { + $lockedVars = $lockedVars->merge( + $app->environment_variables + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + if ($this->pull_request_id !== 0 && $app->environment_variables_preview) { + $lockedVars = $lockedVars->merge( + $app->environment_variables_preview + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + foreach ($lockedVars as $key => $value) { + $escapedValue = preg_quote($value, '/'); + $text = preg_replace( + '/'.$escapedValue.'/', + REDACTED, + $text + ); + } + + return $text; + } + public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false) { if ($type === 'error') { @@ -96,7 +135,7 @@ public function addLogEntry(string $message, string $type = 'stdout', bool $hidd } $newLogEntry = [ 'command' => null, - 'output' => remove_iip($message), + 'output' => $this->redactSensitiveInfo($message), 'type' => $type, 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 437be7d87..bfeee01c9 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use OpenApi\Attributes as OA; @@ -19,6 +20,7 @@ )] class Environment extends BaseModel { + use ClearsGlobalSearchCache; use HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index b3e71d75d..376ea9c5e 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -5,6 +5,7 @@ use App\Events\FileStorageChanged; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Symfony\Component\Yaml\Yaml; class LocalFileVolume extends BaseModel { @@ -192,4 +193,61 @@ public function scopeWherePlainMountPath($query, $path) { return $query->get()->where('plain_mount_path', $path); } + + // Check if this volume is read-only by parsing the docker-compose content + public function isReadOnlyVolume(): bool + { + try { + // Only check for services + $service = $this->service; + if (! $service || ! method_exists($service, 'service')) { + return false; + } + + $actualService = $service->service; + if (! $actualService || ! $actualService->docker_compose_raw) { + return false; + } + + // Parse the docker-compose content + $compose = Yaml::parse($actualService->docker_compose_raw); + if (! isset($compose['services'])) { + return false; + } + + // Find the service that this volume belongs to + $serviceName = $service->name; + if (! isset($compose['services'][$serviceName]['volumes'])) { + return false; + } + + $volumes = $compose['services'][$serviceName]['volumes']; + + // Check each volume to find a match + foreach ($volumes as $volume) { + // Volume can be string like "host:container:ro" or "host:container" + if (is_string($volume)) { + $parts = explode(':', $volume); + + // Check if this volume matches our fs_path and mount_path + if (count($parts) >= 2) { + $hostPath = $parts[0]; + $containerPath = $parts[1]; + $options = $parts[2] ?? null; + + // Match based on fs_path and mount_path + if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) { + return $options === 'ro'; + } + } + } + } + + return false; + } catch (\Throwable $e) { + ray($e->getMessage(), 'Error checking read-only volume'); + + return false; + } + } } diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 00dc15fea..e7862478b 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; +use Symfony\Component\Yaml\Yaml; class LocalPersistentVolume extends Model { @@ -48,4 +49,69 @@ protected function hostPath(): Attribute } ); } + + // Check if this volume is read-only by parsing the docker-compose content + public function isReadOnlyVolume(): bool + { + try { + // Get the resource (can be application, service, or database) + $resource = $this->resource; + if (! $resource) { + return false; + } + + // Only check for services + if (! method_exists($resource, 'service')) { + return false; + } + + $actualService = $resource->service; + if (! $actualService || ! $actualService->docker_compose_raw) { + return false; + } + + // Parse the docker-compose content + $compose = Yaml::parse($actualService->docker_compose_raw); + if (! isset($compose['services'])) { + return false; + } + + // Find the service that this volume belongs to + $serviceName = $resource->name; + if (! isset($compose['services'][$serviceName]['volumes'])) { + return false; + } + + $volumes = $compose['services'][$serviceName]['volumes']; + + // Check each volume to find a match + foreach ($volumes as $volume) { + // Volume can be string like "host:container:ro" or "host:container" + if (is_string($volume)) { + $parts = explode(':', $volume); + + // Check if this volume matches our mount_path + if (count($parts) >= 2) { + $containerPath = $parts[1]; + $options = $parts[2] ?? null; + + // Match based on mount_path + // Remove leading slash from mount_path if present for comparison + $mountPath = str($this->mount_path)->ltrim('/')->toString(); + $containerPathClean = str($containerPath)->ltrim('/')->toString(); + + if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) { + return $options === 'ro'; + } + } + } + } + + return false; + } catch (\Throwable $e) { + ray($e->getMessage(), 'Error checking read-only persistent volume'); + + return false; + } + } } diff --git a/app/Models/Project.php b/app/Models/Project.php index 1c46042e3..a9bf76803 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use OpenApi\Attributes as OA; use Visus\Cuid2\Cuid2; @@ -24,6 +25,7 @@ )] class Project extends BaseModel { + use ClearsGlobalSearchCache; use HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 90204d8df..3ade21df8 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -10,6 +10,21 @@ class ScheduledDatabaseBackup extends BaseModel { protected $guarded = []; + public static function ownedByCurrentTeam() + { + return ScheduledDatabaseBackup::whereRelation('team', 'id', currentTeam()->id)->orderBy('created_at', 'desc'); + } + + public static function ownedByCurrentTeamAPI(int $teamId) + { + return ScheduledDatabaseBackup::whereRelation('team', 'id', $teamId)->orderBy('created_at', 'desc'); + } + + public function team() + { + return $this->belongsTo(Team::class); + } + public function database(): MorphTo { return $this->morphTo(); diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index b06dd5b45..c0298ecc8 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -8,6 +8,15 @@ class ScheduledDatabaseBackupExecution extends BaseModel { protected $guarded = []; + protected function casts(): array + { + return [ + 's3_uploaded' => 'boolean', + 'local_storage_deleted' => 'boolean', + 's3_storage_deleted' => 'boolean', + ]; + } + public function scheduledDatabaseBackup(): BelongsTo { return $this->belongsTo(ScheduledDatabaseBackup::class); diff --git a/app/Models/Service.php b/app/Models/Service.php index d42d471c6..c4b8623e0 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -547,6 +547,21 @@ public function extraFields() } $fields->put('Grafana', $data->toArray()); break; + case $image->contains('elasticsearch'): + $data = collect([]); + $elastic_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ELASTICSEARCH')->first(); + if ($elastic_password) { + $data = $data->merge([ + 'Password (default user: elastic)' => [ + 'key' => data_get($elastic_password, 'key'), + 'value' => data_get($elastic_password, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + $fields->put('Elasticsearch', $data->toArray()); + break; case $image->contains('directus'): $data = collect([]); $admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first(); @@ -1231,9 +1246,9 @@ public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->orderByRaw(" - CASE - WHEN LOWER(key) LIKE 'service_%' THEN 1 - WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + CASE + WHEN is_required = true THEN 1 + WHEN LOWER(key) LIKE 'service_%' THEN 2 ELSE 3 END, LOWER(key) ASC @@ -1263,6 +1278,21 @@ public function saveComposeConfigs() $commands[] = "cd $workdir"; $commands[] = 'rm -f .env || true'; + $envs = collect([]); + + // Generate SERVICE_NAME_* environment variables from docker-compose services + if ($this->docker_compose) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose); + $services = data_get($dockerCompose, 'services', []); + foreach ($services as $serviceName => $_) { + $envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName); + } + } catch (\Exception $e) { + ray($e->getMessage()); + } + } + $envs_from_coolify = $this->environment_variables()->get(); $sorted = $envs_from_coolify->sortBy(function ($env) { if (str($env->key)->startsWith('SERVICE_')) { @@ -1274,7 +1304,6 @@ public function saveComposeConfigs() return 3; }); - $envs = collect([]); foreach ($sorted as $env) { $envs->push("{$env->key}={$env->real_value}"); } diff --git a/app/Models/Team.php b/app/Models/Team.php index 81638e31c..51fdeffa4 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -10,6 +10,7 @@ use App\Traits\HasNotificationSettings; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; use OpenApi\Attributes as OA; @@ -37,7 +38,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack { - use HasNotificationSettings, HasSafeStringAttribute, Notifiable; + use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; protected $guarded = []; @@ -193,6 +194,7 @@ public function isAnyNotificationEnabled() public function subscriptionEnded() { $this->subscription->update([ + 'stripe_subscription_id' => null, 'stripe_cancel_at_period_end' => false, 'stripe_invoice_paid' => false, 'stripe_trial_already_ended' => false, diff --git a/app/Models/TeamInvitation.php b/app/Models/TeamInvitation.php index 0fea1806b..c322982ed 100644 --- a/app/Models/TeamInvitation.php +++ b/app/Models/TeamInvitation.php @@ -15,6 +15,14 @@ class TeamInvitation extends Model 'via', ]; + /** + * Set the email attribute to lowercase. + */ + public function setEmailAttribute(string $value): void + { + $this->attributes['email'] = strtolower($value); + } + public function team() { return $this->belongsTo(Team::class); diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php new file mode 100644 index 000000000..75ae2824c --- /dev/null +++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php @@ -0,0 +1,116 @@ +onQueue('high'); + + $this->name = $database->name; + $this->frequency = $backup->frequency; + + if ($backup->s3) { + $this->s3_storage_url = base_url().'/storages/'.$backup->s3->uuid; + } + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('backup_failure'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: Backup succeeded locally but S3 upload failed for {$this->database->name}"); + $mail->view('emails.backup-success-with-s3-warning', [ + 'name' => $this->name, + 'database_name' => $this->database_name, + 'frequency' => $this->frequency, + 's3_error' => $this->s3_error, + 's3_storage_url' => $this->s3_storage_url, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + $message = new DiscordMessage( + title: ':warning: Database backup succeeded locally, S3 upload failed', + description: "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.", + color: DiscordMessage::warningColor(), + ); + + $message->addField('Frequency', $this->frequency, true); + $message->addField('S3 Error', $this->s3_error); + + if ($this->s3_storage_url) { + $message->addField('S3 Storage', '[Check Configuration]('.$this->s3_storage_url.')'); + } + + return $message; + } + + public function toTelegram(): array + { + $message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} succeeded locally but failed to upload to S3.\n\nS3 Error:\n{$this->s3_error}"; + + if ($this->s3_storage_url) { + $message .= "\n\nCheck S3 Configuration: {$this->s3_storage_url}"; + } + + return [ + 'message' => $message, + ]; + } + + public function toPushover(): PushoverMessage + { + $message = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.

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

s3_storage_url}\">Check S3 Configuration"; + } + + return new PushoverMessage( + title: 'Database backup succeeded locally, S3 upload failed', + level: 'warning', + message: $message, + ); + } + + public function toSlack(): SlackMessage + { + $title = 'Database backup succeeded locally, S3 upload failed'; + $description = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3."; + + $description .= "\n\n*Frequency:* {$this->frequency}"; + $description .= "\n\n*S3 Error:* {$this->s3_error}"; + + if ($this->s3_storage_url) { + $description .= "\n\n*S3 Storage:* <{$this->s3_storage_url}|Check Configuration>"; + } + + return new SlackMessage( + title: $title, + description: $description, + color: SlackMessage::warningColor() + ); + } +} diff --git a/app/Rules/DockerImageFormat.php b/app/Rules/DockerImageFormat.php new file mode 100644 index 000000000..a6a78a76c --- /dev/null +++ b/app/Rules/DockerImageFormat.php @@ -0,0 +1,41 @@ +', '\n', '\r', '\0', '"', "'", - '\\', '!', '?', '*', '~', '^', '%', '=', '+', + '\\', '!', '?', '*', '^', '%', '=', '+', '#', // Comment character that could hide commands ]; @@ -85,7 +85,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } // Validate SSH URL format (git@host:user/repo.git) - if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.]+$/', $value)) { + if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.~]+$/', $value)) { $fail('The :attribute is not a valid SSH repository URL.'); return; @@ -136,14 +136,14 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Validate path contains only safe characters $path = $parsed['path'] ?? ''; - if (! empty($path) && ! preg_match('/^[a-zA-Z0-9\-_\/\.]+$/', $path)) { + if (! empty($path) && ! preg_match('/^[a-zA-Z0-9\-_\/\.@~]+$/', $path)) { $fail('The :attribute path contains invalid characters.'); return; } } elseif (str_starts_with($value, 'git://')) { - // Validate git:// protocol URL - if (! preg_match('/^git:\/\/[a-zA-Z0-9\.\-]+\/[a-zA-Z0-9\-_\/\.]+$/', $value)) { + // Validate git:// protocol URL (supports both git://host/path and git://host:port/path with tilde) + if (! preg_match('/^git:\/\/[a-zA-Z0-9\.\-]+(:[0-9]+)?[:\/][a-zA-Z0-9\-_\/\.~]+$/', $value)) { $fail('The :attribute is not a valid git:// URL.'); return; diff --git a/app/Services/DockerImageParser.php b/app/Services/DockerImageParser.php index 1fd6625b3..b483c979a 100644 --- a/app/Services/DockerImageParser.php +++ b/app/Services/DockerImageParser.php @@ -10,20 +10,33 @@ class DockerImageParser private string $tag = 'latest'; + private bool $isImageHash = false; + public function parse(string $imageString): self { - // First split by : to handle the tag, but be careful with registry ports - $lastColon = strrpos($imageString, ':'); - $hasSlash = str_contains($imageString, '/'); - - // If the last colon appears after the last slash, it's a tag - // Otherwise it might be a port in the registry URL - if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) { - $mainPart = substr($imageString, 0, $lastColon); - $this->tag = substr($imageString, $lastColon + 1); + // Check for @sha256: format first (e.g., nginx@sha256:abc123...) + if (preg_match('/^(.+)@sha256:([a-f0-9]{64})$/i', $imageString, $matches)) { + $mainPart = $matches[1]; + $this->tag = $matches[2]; + $this->isImageHash = true; } else { - $mainPart = $imageString; - $this->tag = 'latest'; + // Split by : to handle the tag, but be careful with registry ports + $lastColon = strrpos($imageString, ':'); + $hasSlash = str_contains($imageString, '/'); + + // If the last colon appears after the last slash, it's a tag + // Otherwise it might be a port in the registry URL + if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) { + $mainPart = substr($imageString, 0, $lastColon); + $this->tag = substr($imageString, $lastColon + 1); + + // Check if the tag is a SHA256 hash + $this->isImageHash = $this->isSha256Hash($this->tag); + } else { + $mainPart = $imageString; + $this->tag = 'latest'; + $this->isImageHash = false; + } } // Split the main part by / to handle registry and image name @@ -41,6 +54,37 @@ public function parse(string $imageString): self return $this; } + /** + * Check if the given string is a SHA256 hash + */ + private function isSha256Hash(string $hash): bool + { + // SHA256 hashes are 64 characters long and contain only hexadecimal characters + return preg_match('/^[a-f0-9]{64}$/i', $hash) === 1; + } + + /** + * Check if the current tag is an image hash + */ + public function isImageHash(): bool + { + return $this->isImageHash; + } + + /** + * Get the full image name with hash if present + */ + public function getFullImageNameWithHash(): string + { + $imageName = $this->getFullImageNameWithoutTag(); + + if ($this->isImageHash) { + return $imageName.'@sha256:'.$this->tag; + } + + return $imageName.':'.$this->tag; + } + public function getFullImageNameWithoutTag(): string { if ($this->registryUrl) { @@ -73,6 +117,10 @@ public function toString(): string } $parts[] = $this->imageName; + if ($this->isImageHash) { + return implode('/', $parts).'@sha256:'.$this->tag; + } + return implode('/', $parts).':'.$this->tag; } } diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php index 0bcc5d319..b9af70aba 100644 --- a/app/Traits/ClearsGlobalSearchCache.php +++ b/app/Traits/ClearsGlobalSearchCache.php @@ -3,79 +3,126 @@ namespace App\Traits; use App\Livewire\GlobalSearch; +use Illuminate\Database\Eloquent\Model; trait ClearsGlobalSearchCache { protected static function bootClearsGlobalSearchCache() { static::saving(function ($model) { - // Only clear cache if searchable fields are being changed - if ($model->hasSearchableChanges()) { - $teamId = $model->getTeamIdForCache(); - if (filled($teamId)) { - GlobalSearch::clearTeamCache($teamId); + try { + // Only clear cache if searchable fields are being changed + if ($model->hasSearchableChanges()) { + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } } + } catch (\Throwable $e) { + // Silently fail cache clearing - don't break the save operation + ray('Failed to clear global search cache on saving: '.$e->getMessage()); } }); static::created(function ($model) { - // Always clear cache when model is created - $teamId = $model->getTeamIdForCache(); - if (filled($teamId)) { - GlobalSearch::clearTeamCache($teamId); + try { + // Always clear cache when model is created + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + } catch (\Throwable $e) { + // Silently fail cache clearing - don't break the create operation + ray('Failed to clear global search cache on creation: '.$e->getMessage()); } }); static::deleted(function ($model) { - // Always clear cache when model is deleted - $teamId = $model->getTeamIdForCache(); - if (filled($teamId)) { - GlobalSearch::clearTeamCache($teamId); + try { + // Always clear cache when model is deleted + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + } catch (\Throwable $e) { + // Silently fail cache clearing - don't break the delete operation + ray('Failed to clear global search cache on deletion: '.$e->getMessage()); } }); } private function hasSearchableChanges(): bool { - // Define searchable fields based on model type - $searchableFields = ['name', 'description']; + try { + // Define searchable fields based on model type + $searchableFields = ['name', 'description']; - // Add model-specific searchable fields - if ($this instanceof \App\Models\Application) { - $searchableFields[] = 'fqdn'; - $searchableFields[] = 'docker_compose_domains'; - } elseif ($this instanceof \App\Models\Server) { - $searchableFields[] = 'ip'; - } elseif ($this instanceof \App\Models\Service) { - // Services don't have direct fqdn, but name and description are covered - } - // Database models only have name and description as searchable - - // Check if any searchable field is dirty - foreach ($searchableFields as $field) { - if ($this->isDirty($field)) { - return true; + // Add model-specific searchable fields + if ($this instanceof \App\Models\Application) { + $searchableFields[] = 'fqdn'; + $searchableFields[] = 'docker_compose_domains'; + } elseif ($this instanceof \App\Models\Server) { + $searchableFields[] = 'ip'; + } elseif ($this instanceof \App\Models\Service) { + // Services don't have direct fqdn, but name and description are covered + } elseif ($this instanceof \App\Models\Project || $this instanceof \App\Models\Environment) { + // Projects and environments only have name and description as searchable } - } + // Database models only have name and description as searchable - return false; + // Check if any searchable field is dirty + foreach ($searchableFields as $field) { + // Check if attribute exists before checking if dirty + if (array_key_exists($field, $this->getAttributes()) && $this->isDirty($field)) { + return true; + } + } + + return false; + } catch (\Throwable $e) { + // If checking changes fails, assume changes exist to be safe + ray('Failed to check searchable changes: '.$e->getMessage()); + + return true; + } } private function getTeamIdForCache() { - // For database models, team is accessed through environment.project.team - if (method_exists($this, 'team')) { - $team = $this->team(); - if (filled($team)) { - return is_object($team) ? $team->id : null; + try { + // For Project models (has direct team_id) + if ($this instanceof \App\Models\Project) { + return $this->team_id ?? null; } - } - // For models with direct team_id property - if (property_exists($this, 'team_id') || isset($this->team_id)) { - return $this->team_id; - } + // For Environment models (get team_id through project) + if ($this instanceof \App\Models\Environment) { + return $this->project?->team_id; + } - return null; + // For database models, team is accessed through environment.project.team + if (method_exists($this, 'team')) { + if ($this instanceof \App\Models\Server) { + $team = $this->team; + } else { + $team = $this->team(); + } + if (filled($team)) { + return is_object($team) ? $team->id : null; + } + } + + // For models with direct team_id property + if (property_exists($this, 'team_id') || isset($this->team_id)) { + return $this->team_id ?? null; + } + + return null; + } catch (\Throwable $e) { + // If we can't determine team ID, return null + ray('Failed to get team ID for cache: '.$e->getMessage()); + + return null; + } } } diff --git a/app/Traits/EnvironmentVariableAnalyzer.php b/app/Traits/EnvironmentVariableAnalyzer.php new file mode 100644 index 000000000..0b452a940 --- /dev/null +++ b/app/Traits/EnvironmentVariableAnalyzer.php @@ -0,0 +1,221 @@ + [ + 'problematic_values' => ['production', 'prod'], + 'affects' => 'Node.js/npm/yarn/bun/pnpm', + 'issue' => 'Skips devDependencies installation which are often required for building (webpack, typescript, etc.)', + 'recommendation' => 'Uncheck "Available at Buildtime" or use "development" during build', + ], + 'NPM_CONFIG_PRODUCTION' => [ + 'problematic_values' => ['true', '1', 'yes'], + 'affects' => 'npm/pnpm', + 'issue' => 'Forces npm to skip devDependencies', + 'recommendation' => 'Remove from build-time variables or set to false', + ], + 'YARN_PRODUCTION' => [ + 'problematic_values' => ['true', '1', 'yes'], + 'affects' => 'Yarn/pnpm', + 'issue' => 'Forces yarn to skip devDependencies', + 'recommendation' => 'Remove from build-time variables or set to false', + ], + 'COMPOSER_NO_DEV' => [ + 'problematic_values' => ['1', 'true', 'yes'], + 'affects' => 'PHP/Composer', + 'issue' => 'Skips require-dev packages which may include build tools', + 'recommendation' => 'Set as "Runtime only" or remove from build-time variables', + ], + 'MIX_ENV' => [ + 'problematic_values' => ['prod', 'production'], + 'affects' => 'Elixir/Phoenix', + 'issue' => 'Production mode may skip development dependencies needed for compilation', + 'recommendation' => 'Use "dev" for build or set as "Runtime only"', + ], + 'RAILS_ENV' => [ + 'problematic_values' => ['production'], + 'affects' => 'Ruby on Rails', + 'issue' => 'May affect asset precompilation and dependency handling', + 'recommendation' => 'Consider using "development" for build phase', + ], + 'RACK_ENV' => [ + 'problematic_values' => ['production'], + 'affects' => 'Ruby/Rack', + 'issue' => 'May affect dependency handling and build behavior', + 'recommendation' => 'Consider using "development" for build phase', + ], + 'BUNDLE_WITHOUT' => [ + 'problematic_values' => ['development', 'test', 'development:test'], + 'affects' => 'Ruby/Bundler', + 'issue' => 'Excludes gem groups that may contain build dependencies', + 'recommendation' => 'Remove from build-time variables or adjust groups', + ], + 'FLASK_ENV' => [ + 'problematic_values' => ['production'], + 'affects' => 'Python/Flask', + 'issue' => 'May affect debug mode and development tools availability', + 'recommendation' => 'Usually safe, but consider "development" for complex builds', + ], + 'DJANGO_SETTINGS_MODULE' => [ + 'problematic_values' => [], // Check if contains 'production' or 'prod' + 'affects' => 'Python/Django', + 'issue' => 'Production settings may disable debug tools needed during build', + 'recommendation' => 'Use development settings for build phase', + 'check_function' => 'checkDjangoSettings', + ], + 'APP_ENV' => [ + 'problematic_values' => ['production', 'prod'], + 'affects' => 'Laravel/Symfony', + 'issue' => 'May affect dependency installation and build optimizations', + 'recommendation' => 'Consider using "local" or "development" for build', + ], + 'ASPNETCORE_ENVIRONMENT' => [ + 'problematic_values' => ['Production'], + 'affects' => '.NET/ASP.NET Core', + 'issue' => 'May affect build-time configurations and optimizations', + 'recommendation' => 'Usually safe, but verify build requirements', + ], + 'CI' => [ + 'problematic_values' => ['true', '1', 'yes'], + 'affects' => 'Various tools', + 'issue' => 'Changes behavior in many tools (disables interactivity, changes caching)', + 'recommendation' => 'Usually beneficial for builds, but be aware of behavior changes', + ], + ]; + } + + /** + * Analyze an environment variable for potential build issues. + * Always returns a warning if the key is in our list, regardless of value. + */ + public static function analyzeBuildVariable(string $key, string $value): ?array + { + $problematicVars = self::getProblematicBuildVariables(); + + // Direct key match + if (isset($problematicVars[$key])) { + $config = $problematicVars[$key]; + + // Check if it has a custom check function + if (isset($config['check_function'])) { + $method = $config['check_function']; + if (method_exists(self::class, $method)) { + return self::{$method}($key, $value, $config); + } + } + + // Always return warning for known problematic variables + return [ + 'variable' => $key, + 'value' => $value, + 'affects' => $config['affects'], + 'issue' => $config['issue'], + 'recommendation' => $config['recommendation'], + ]; + } + + return null; + } + + /** + * Analyze multiple environment variables for potential build issues. + */ + public static function analyzeBuildVariables(array $variables): array + { + $warnings = []; + + foreach ($variables as $key => $value) { + $warning = self::analyzeBuildVariable($key, $value); + if ($warning) { + $warnings[] = $warning; + } + } + + return $warnings; + } + + /** + * Custom check for Django settings module. + */ + protected static function checkDjangoSettings(string $key, string $value, array $config): ?array + { + // Always return warning for DJANGO_SETTINGS_MODULE when it's set as build-time + return [ + 'variable' => $key, + 'value' => $value, + 'affects' => $config['affects'], + 'issue' => $config['issue'], + 'recommendation' => $config['recommendation'], + ]; + } + + /** + * Generate a formatted warning message for deployment logs. + */ + public static function formatBuildWarning(array $warning): array + { + $messages = [ + "⚠️ Build-time environment variable warning: {$warning['variable']}={$warning['value']}", + " Affects: {$warning['affects']}", + " Issue: {$warning['issue']}", + " Recommendation: {$warning['recommendation']}", + ]; + + return $messages; + } + + /** + * Check if a variable should show a warning in the UI. + */ + public static function shouldShowBuildWarning(string $key): bool + { + return isset(self::getProblematicBuildVariables()[$key]); + } + + /** + * Get UI warning message for a specific variable. + */ + public static function getUIWarningMessage(string $key): ?string + { + $problematicVars = self::getProblematicBuildVariables(); + + if (! isset($problematicVars[$key])) { + return null; + } + + $config = $problematicVars[$key]; + $problematicValuesStr = implode(', ', $config['problematic_values']); + + return "Setting {$key} to {$problematicValuesStr} as a build-time variable may cause issues. {$config['issue']} Consider: {$config['recommendation']}"; + } + + /** + * Get problematic variables configuration for frontend use. + */ + public static function getProblematicVariablesForFrontend(): array + { + $vars = self::getProblematicBuildVariables(); + $result = []; + + foreach ($vars as $key => $config) { + // Skip the check_function as it's PHP-specific + $result[$key] = [ + 'problematic_values' => $config['problematic_values'], + 'affects' => $config['affects'], + 'issue' => $config['issue'], + 'recommendation' => $config['recommendation'], + ]; + } + + return $result; + } +} diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 0c3414efe..4aa5aae8b 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -17,6 +17,46 @@ trait ExecuteRemoteCommand public static int $batch_counter = 0; + private function redact_sensitive_info($text) + { + $text = remove_iip($text); + + if (! isset($this->application)) { + return $text; + } + + $lockedVars = collect([]); + + if (isset($this->application->environment_variables)) { + $lockedVars = $lockedVars->merge( + $this->application->environment_variables + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) { + $lockedVars = $lockedVars->merge( + $this->application->environment_variables_preview + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + foreach ($lockedVars as $key => $value) { + $escapedValue = preg_quote($value, '/'); + $text = preg_replace( + '/'.$escapedValue.'/', + REDACTED, + $text + ); + } + + return $text; + } + public function execute_remote_command(...$commands) { static::$batch_counter++; @@ -74,7 +114,7 @@ public function execute_remote_command(...$commands) // Track SSH retry event in Sentry $this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [ 'server' => $this->server->name ?? $this->server->ip ?? 'unknown', - 'command' => remove_iip($command), + 'command' => $this->redact_sensitive_info($command), 'trait' => 'ExecuteRemoteCommand', ]); @@ -115,7 +155,7 @@ public function execute_remote_command(...$commands) private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors) { $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command); - $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) { + $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) { $output = str($output)->trim(); if ($output->startsWith('╔')) { $output = "\n".$output; @@ -125,8 +165,8 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe $sanitized_output = sanitize_utf8_text($output); $new_log_entry = [ - 'command' => remove_iip($command), - 'output' => remove_iip($sanitized_output), + 'command' => $this->redact_sensitive_info($command), + 'output' => $this->redact_sensitive_info($sanitized_output), 'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout', 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, @@ -162,13 +202,13 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe if ($this->save) { if (data_get($this->saved_outputs, $this->save, null) === null) { - data_set($this->saved_outputs, $this->save, str()); + $this->saved_outputs->put($this->save, str()); } if ($append) { - $this->saved_outputs[$this->save] .= str($sanitized_output)->trim(); - $this->saved_outputs[$this->save] = str($this->saved_outputs[$this->save]); + $current_value = $this->saved_outputs->get($this->save); + $this->saved_outputs->put($this->save, str($current_value.str($sanitized_output)->trim())); } else { - $this->saved_outputs[$this->save] = str($sanitized_output)->trim(); + $this->saved_outputs->put($this->save, str($sanitized_output)->trim()); } } }); @@ -194,7 +234,7 @@ private function addRetryLogEntry(int $attempt, int $maxRetries, int $delay, str $retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}"; $new_log_entry = [ - 'output' => remove_iip($retryMessage), + 'output' => $this->redact_sensitive_info($retryMessage), 'type' => 'stdout', 'timestamp' => Carbon::now('UTC'), 'hidden' => false, diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index b568e090c..36243e119 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -21,13 +21,23 @@ 'bitnami/mariadb', 'bitnami/mongodb', 'bitnami/redis', + 'bitnamilegacy/mariadb', + 'bitnamilegacy/mongodb', + 'bitnamilegacy/redis', + 'bitnamisecure/mariadb', + 'bitnamisecure/mongodb', + 'bitnamisecure/redis', 'mysql', 'bitnami/mysql', + 'bitnamilegacy/mysql', + 'bitnamisecure/mysql', 'mysql/mysql-server', 'mariadb', 'postgis/postgis', 'postgres', 'bitnami/postgresql', + 'bitnamilegacy/postgresql', + 'bitnamisecure/postgresql', 'supabase/postgres', 'elestio/postgres', 'mongo', diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 5dbd46b5e..aa7be3236 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -237,12 +237,11 @@ function removeOldBackups($backup): void { try { if ($backup->executions) { - // If local backup is disabled, mark all executions as having local storage deleted - if ($backup->disable_local_backup && $backup->save_s3) { - $backup->executions() - ->where('local_storage_deleted', false) - ->update(['local_storage_deleted' => true]); - } else { + // Delete old local backups (only if local backup is NOT disabled) + // Note: When disable_local_backup is enabled, each execution already marks its own + // local_storage_deleted status at the time of backup, so we don't need to retroactively + // update old executions + if (! $backup->disable_local_backup) { $localBackupsToDelete = deleteOldBackupsLocally($backup); if ($localBackupsToDelete->isNotEmpty()) { $backup->executions() @@ -261,18 +260,18 @@ function removeOldBackups($backup): void } } - // Delete executions where both local and S3 storage are marked as deleted - // or where only S3 is enabled and S3 storage is deleted - if ($backup->disable_local_backup && $backup->save_s3) { - $backup->executions() - ->where('s3_storage_deleted', true) - ->delete(); - } else { - $backup->executions() - ->where('local_storage_deleted', true) - ->where('s3_storage_deleted', true) - ->delete(); - } + // Delete execution records where all backup copies are gone + // Case 1: Both local and S3 backups are deleted + $backup->executions() + ->where('local_storage_deleted', true) + ->where('s3_storage_deleted', true) + ->delete(); + + // Case 2: Local backup is deleted and S3 was never used (s3_uploaded is null) + $backup->executions() + ->where('local_storage_deleted', true) + ->whereNull('s3_uploaded') + ->delete(); } catch (\Exception $e) { throw $e; diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 1491e4712..b63c3fc3b 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1119,3 +1119,53 @@ function escapeDollarSign($value) return str_replace($search, $replace, $value); } + +/** + * Generate Docker build arguments from environment variables collection + * Returns only keys (no values) since values are sourced from environment via export + * + * @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' + * @return \Illuminate\Support\Collection Collection of formatted --build-arg strings (keys only) + */ +function generateDockerBuildArgs($variables): \Illuminate\Support\Collection +{ + $variables = collect($variables); + + return $variables->map(function ($var) { + $key = is_array($var) ? data_get($var, 'key') : $var->key; + + // Only return the key - Docker will get the value from the environment + return "--build-arg {$key}"; + }); +} + +/** + * Generate Docker environment flags from environment variables collection + * + * @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' + * @return string Space-separated environment flags + */ +function generateDockerEnvFlags($variables): string +{ + $variables = collect($variables); + + return $variables + ->map(function ($var) { + $key = is_array($var) ? data_get($var, 'key') : $var->key; + $value = is_array($var) ? data_get($var, 'value') : $var->value; + $isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false); + + if ($isMultiline) { + // For multiline variables, strip surrounding quotes and escape for bash + $raw_value = trim($value, "'"); + $escaped_value = str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $raw_value); + + return "-e {$key}=\"{$escaped_value}\""; + } + + $escaped_value = escapeshellarg($value); + + return "-e {$key}={$escaped_value}"; + }) + ->implode(' '); +} diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index 0de2f2fd9..3b5f183fb 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -135,7 +135,13 @@ function getPermissionsPath(GithubApp $source) function loadRepositoryByPage(GithubApp $source, string $token, int $page) { - $response = Http::withToken($token)->get("{$source->api_url}/installation/repositories?per_page=100&page={$page}"); + $response = Http::GitHub($source->api_url, $token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get('/installation/repositories', [ + 'per_page' => 100, + 'page' => $page, + ]); $json = $response->json(); if ($response->status() !== 200) { return [ diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index d4701d251..a588ed882 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -385,21 +385,34 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, ]); if ($resource->build_pack === 'dockercompose') { - $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); - $domainExists = data_get($domains->get($fqdnFor), 'domain'); - $envExists = $resource->environment_variables()->where('key', $key->value())->first(); - if (str($domainExists)->replace('http://', '')->replace('https://', '')->value() !== $envExists->value) { - $envExists->update([ - 'value' => $url, - ]); + // Check if a service with this name actually exists + $serviceExists = false; + foreach ($services as $serviceName => $service) { + $transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + if ($transformedServiceName === $fqdnFor) { + $serviceExists = true; + break; + } } - if (is_null($domainExists)) { - // Put URL in the domains array instead of FQDN - $domains->put((string) $fqdnFor, [ - 'domain' => $url, - ]); - $resource->docker_compose_domains = $domains->toJson(); - $resource->save(); + + // Only add domain if the service exists + if ($serviceExists) { + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); + $domainExists = data_get($domains->get($fqdnFor), 'domain'); + $envExists = $resource->environment_variables()->where('key', $key->value())->first(); + if (str($domainExists)->replace('http://', '')->replace('https://', '')->value() !== $envExists->value) { + $envExists->update([ + 'value' => $url, + ]); + } + if (is_null($domainExists)) { + // Put URL in the domains array instead of FQDN + $domains->put((string) $fqdnFor, [ + 'domain' => $url, + ]); + $resource->docker_compose_domains = $domains->toJson(); + $resource->save(); + } } } } elseif ($command->value() === 'URL') { @@ -418,20 +431,33 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, ]); if ($resource->build_pack === 'dockercompose') { - $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); - $domainExists = data_get($domains->get($urlFor), 'domain'); - $envExists = $resource->environment_variables()->where('key', $key->value())->first(); - if ($domainExists !== $envExists->value) { - $envExists->update([ - 'value' => $url, - ]); + // Check if a service with this name actually exists + $serviceExists = false; + foreach ($services as $serviceName => $service) { + $transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + if ($transformedServiceName === $urlFor) { + $serviceExists = true; + break; + } } - if (is_null($domainExists)) { - $domains->put((string) $urlFor, [ - 'domain' => $url, - ]); - $resource->docker_compose_domains = $domains->toJson(); - $resource->save(); + + // Only add domain if the service exists + if ($serviceExists) { + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); + $domainExists = data_get($domains->get($urlFor), 'domain'); + $envExists = $resource->environment_variables()->where('key', $key->value())->first(); + if ($domainExists !== $envExists->value) { + $envExists->update([ + 'value' => $url, + ]); + } + if (is_null($domainExists)) { + $domains->put((string) $urlFor, [ + 'domain' => $url, + ]); + $resource->docker_compose_domains = $domains->toJson(); + $resource->save(); + } } } } else { @@ -910,7 +936,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview = $resource->previews()->find($preview_id); $docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))); if ($docker_compose_domains->count() > 0) { - $found_fqdn = data_get($docker_compose_domains, "$serviceName.domain"); + $found_fqdn = data_get($docker_compose_domains, "$changedServiceName.domain"); if ($found_fqdn) { $fqdns = collect($found_fqdn); } else { @@ -1146,6 +1172,9 @@ function serviceParser(Service $resource): Collection $parsedServices = collect([]); + // Generate SERVICE_NAME variables for docker compose services + $serviceNameEnvironments = generateDockerComposeServiceName($services); + $allMagicEnvironments = collect([]); // Presave services foreach ($services as $serviceName => $service) { @@ -1962,7 +1991,7 @@ function serviceParser(Service $resource): Collection $payload['volumes'] = $volumesParsed; } if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) { - $payload['environment'] = $environment->merge($coolifyEnvironments); + $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments); } if ($logging) { $payload['logging'] = $logging; diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 5bc1d005e..924bad307 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -108,7 +108,63 @@ function connectProxyToNetworks(Server $server) return $commands->flatten(); } -function generate_default_proxy_configuration(Server $server) +function extractCustomProxyCommands(Server $server, string $existing_config): array +{ + $custom_commands = []; + $proxy_type = $server->proxyType(); + + if ($proxy_type !== ProxyTypes::TRAEFIK->value || empty($existing_config)) { + return $custom_commands; + } + + try { + $yaml = Yaml::parse($existing_config); + $existing_commands = data_get($yaml, 'services.traefik.command', []); + + if (empty($existing_commands)) { + return $custom_commands; + } + + // Define default commands that Coolify generates + $default_command_prefixes = [ + '--ping=', + '--api.', + '--entrypoints.http.address=', + '--entrypoints.https.address=', + '--entrypoints.http.http.encodequerysemicolons=', + '--entryPoints.http.http2.maxConcurrentStreams=', + '--entrypoints.https.http.encodequerysemicolons=', + '--entryPoints.https.http2.maxConcurrentStreams=', + '--entrypoints.https.http3', + '--providers.file.', + '--certificatesresolvers.', + '--providers.docker', + '--providers.swarm', + '--log.level=', + '--accesslog.', + ]; + + // Extract commands that don't match default prefixes (these are custom) + foreach ($existing_commands as $command) { + $is_default = false; + foreach ($default_command_prefixes as $prefix) { + if (str_starts_with($command, $prefix)) { + $is_default = true; + break; + } + } + if (! $is_default) { + $custom_commands[] = $command; + } + } + } catch (\Exception $e) { + // If we can't parse the config, return empty array + // Silently fail to avoid breaking the proxy regeneration + } + + return $custom_commands; +} +function generateDefaultProxyConfiguration(Server $server, array $custom_commands = []) { $proxy_path = $server->proxyPath(); $proxy_type = $server->proxyType(); @@ -228,6 +284,13 @@ function generate_default_proxy_configuration(Server $server) $config['services']['traefik']['command'][] = '--providers.docker=true'; $config['services']['traefik']['command'][] = '--providers.docker.exposedbydefault=false'; } + + // Append custom commands (e.g., trustedIPs for Cloudflare) + if (! empty($custom_commands)) { + foreach ($custom_commands as $custom_command) { + $config['services']['traefik']['command'][] = $custom_command; + } + } } elseif ($proxy_type === 'CADDY') { $config = [ 'networks' => $array_of_networks->toArray(), diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 56386a55f..3218bf878 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -84,64 +84,6 @@ function () use ($source, $dest, $server) { ); } -function transfer_file_to_container(string $content, string $container_path, string $deployment_uuid, Server $server, bool $throwError = true): ?string -{ - $temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_'); - - try { - // Write content to temporary file - file_put_contents($temp_file, $content); - - // Generate unique filename for server transfer - $server_temp_file = '/tmp/coolify_env_'.uniqid().'_'.$deployment_uuid; - - // Transfer file to server - instant_scp($temp_file, $server_temp_file, $server, $throwError); - - // Ensure parent directory exists in container, then copy file - $parent_dir = dirname($container_path); - $commands = []; - if ($parent_dir !== '.' && $parent_dir !== '/') { - $commands[] = executeInDocker($deployment_uuid, "mkdir -p \"$parent_dir\""); - } - $commands[] = "docker cp $server_temp_file $deployment_uuid:$container_path"; - $commands[] = "rm -f $server_temp_file"; // Cleanup server temp file - - return instant_remote_process_with_timeout($commands, $server, $throwError); - - } finally { - // Always cleanup local temp file - if (file_exists($temp_file)) { - unlink($temp_file); - } - } -} - -function transfer_file_to_server(string $content, string $server_path, Server $server, bool $throwError = true): ?string -{ - $temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_'); - - try { - // Write content to temporary file - file_put_contents($temp_file, $content); - - // Ensure parent directory exists on server - $parent_dir = dirname($server_path); - if ($parent_dir !== '.' && $parent_dir !== '/') { - instant_remote_process_with_timeout(["mkdir -p \"$parent_dir\""], $server, $throwError); - } - - // Transfer file directly to server destination - return instant_scp($temp_file, $server_path, $server, $throwError); - - } finally { - // Always cleanup local temp file - if (file_exists($temp_file)) { - unlink($temp_file); - } - } -} - function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string { $command = $command instanceof Collection ? $command->toArray() : $command; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index a0ab5a704..656c607bf 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -634,10 +634,14 @@ function getTopLevelNetworks(Service|Application $resource) $definedNetwork = collect([$resource->uuid]); $services = collect($services)->map(function ($service, $_) use ($topLevelNetworks, $definedNetwork) { $serviceNetworks = collect(data_get($service, 'networks', [])); - $hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false; + $networkMode = data_get($service, 'network_mode'); - // Only add 'networks' key if 'network_mode' is not 'host' - if (! $hasHostNetworkMode) { + $hasValidNetworkMode = + $networkMode === 'host' || + (is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:'))); + + // Only add 'networks' key if 'network_mode' is not 'host' or does not start with 'service:' or 'container:' + if (! $hasValidNetworkMode) { // Collect/create/update networks if ($serviceNetworks->count() > 0) { foreach ($serviceNetworks as $networkName => $networkDetails) { @@ -1272,7 +1276,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceNetworks = collect(data_get($service, 'networks', [])); $serviceVariables = collect(data_get($service, 'environment', [])); $serviceLabels = collect(data_get($service, 'labels', [])); - $hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false; + $networkMode = data_get($service, 'network_mode'); + + $hasValidNetworkMode = + $networkMode === 'host' || + (is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:'))); + if ($serviceLabels->count() > 0) { $removedLabels = collect([]); $serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) { @@ -1383,7 +1392,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $savedService->ports = $collectedPorts->implode(','); $savedService->save(); - if (! $hasHostNetworkMode) { + if (! $hasValidNetworkMode) { // Add Coolify specific networks $definedNetworkExists = $topLevelNetworks->contains(function ($value, $_) use ($definedNetwork) { return $value == $definedNetwork; diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php index 961f6809b..fd3fbe74b 100644 --- a/bootstrap/helpers/socialite.php +++ b/bootstrap/helpers/socialite.php @@ -70,8 +70,14 @@ function get_socialite_provider(string $provider) 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class, ]; - return Socialite::buildProvider( + $socialite = Socialite::buildProvider( $provider_class_map[$provider], $config ); + + if ($provider == 'gitlab' && ! empty($oauth_setting->base_url)) { + $socialite->setHost($oauth_setting->base_url); + } + + return $socialite; } diff --git a/config/constants.php b/config/constants.php index 224f2dfb5..01eaa7fa1 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.429', + 'version' => '4.0.0-beta.435', 'helper_version' => '1.0.11', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), @@ -64,7 +64,7 @@ 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes 'connection_timeout' => 10, 'server_interval' => 20, - 'command_timeout' => 7200, + 'command_timeout' => 3600, 'max_retries' => env('SSH_MAX_RETRIES', 3), 'retry_base_delay' => env('SSH_RETRY_BASE_DELAY', 2), // seconds 'retry_max_delay' => env('SSH_RETRY_MAX_DELAY', 30), // seconds diff --git a/database/factories/TeamFactory.php b/database/factories/TeamFactory.php new file mode 100644 index 000000000..26748c54e --- /dev/null +++ b/database/factories/TeamFactory.php @@ -0,0 +1,40 @@ + + */ +class TeamFactory extends Factory +{ + protected $model = Team::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->company().' Team', + 'description' => $this->faker->sentence(), + 'personal_team' => false, + 'show_boarding' => false, + ]; + } + + /** + * Indicate that the team is a personal team. + */ + public function personal(): static + { + return $this->state(fn (array $attributes) => [ + 'personal_team' => true, + 'name' => $this->faker->firstName()."'s Team", + ]); + } +} diff --git a/database/migrations/2025_10_03_154100_update_clickhouse_image.php b/database/migrations/2025_10_03_154100_update_clickhouse_image.php new file mode 100644 index 000000000..e52bbcc16 --- /dev/null +++ b/database/migrations/2025_10_03_154100_update_clickhouse_image.php @@ -0,0 +1,32 @@ +string('image')->default('bitnamilegacy/clickhouse')->change(); + }); + // Optionally, update any existing rows with the old default to the new one + DB::table('standalone_clickhouses') + ->where('image', 'bitnami/clickhouse') + ->update(['image' => 'bitnamilegacy/clickhouse']); + } + + public function down() + { + Schema::table('standalone_clickhouses', function (Blueprint $table) { + $table->string('image')->default('bitnami/clickhouse')->change(); + }); + // Optionally, revert any changed values + DB::table('standalone_clickhouses') + ->where('image', 'bitnamilegacy/clickhouse') + ->update(['image' => 'bitnami/clickhouse']); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table.php b/database/migrations/2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table.php new file mode 100644 index 000000000..d80f2621b --- /dev/null +++ b/database/migrations/2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table.php @@ -0,0 +1,28 @@ +boolean('s3_uploaded')->nullable()->after('filename'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('scheduled_database_backup_executions', function (Blueprint $table) { + $table->dropColumn('s3_uploaded'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e0e7a3ba5..57ccab4ae 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -29,6 +29,7 @@ public function run(): void DisableTwoStepConfirmationSeeder::class, SentinelSeeder::class, CaSslCertSeeder::class, + PersonalAccessTokenSeeder::class, ]); } } diff --git a/database/seeders/PersonalAccessTokenSeeder.php b/database/seeders/PersonalAccessTokenSeeder.php new file mode 100644 index 000000000..38a45219c --- /dev/null +++ b/database/seeders/PersonalAccessTokenSeeder.php @@ -0,0 +1,115 @@ +environment('production')) { + $this->command->warn('Skipping PersonalAccessTokenSeeder in production environment'); + + return; + } + + // Get the first user (usually the admin user created during setup) + $user = User::find(0); + + if (! $user) { + $this->command->warn('No user found. Please run UserSeeder first.'); + + return; + } + + // Get the user's first team + $team = $user->teams()->first(); + + if (! $team) { + $this->command->warn('No team found for user. Cannot create API tokens.'); + + return; + } + + // Define test tokens with different scopes + $testTokens = [ + [ + 'name' => 'Development Root Token', + 'token' => 'root', + 'abilities' => ['root'], + ], + [ + 'name' => 'Development Read Token', + 'token' => 'read', + 'abilities' => ['read'], + ], + [ + 'name' => 'Development Read Sensitive Token', + 'token' => 'read-sensitive', + 'abilities' => ['read', 'read:sensitive'], + ], + [ + 'name' => 'Development Write Token', + 'token' => 'write', + 'abilities' => ['write'], + ], + [ + 'name' => 'Development Write Sensitive Token', + 'token' => 'write-sensitive', + 'abilities' => ['write', 'write:sensitive'], + ], + [ + 'name' => 'Development Deploy Token', + 'token' => 'deploy', + 'abilities' => ['deploy'], + ], + ]; + + // First, remove all existing development tokens for this user + $deletedCount = PersonalAccessToken::where('tokenable_id', $user->id) + ->where('tokenable_type', get_class($user)) + ->whereIn('name', array_column($testTokens, 'name')) + ->delete(); + + if ($deletedCount > 0) { + $this->command->info("Removed {$deletedCount} existing development token(s)."); + } + + // Now create fresh tokens + foreach ($testTokens as $tokenData) { + // Create the token with a simple format: Bearer {scope} + // The token format in the database is the hash of the plain text token + $plainTextToken = $tokenData['token']; + + PersonalAccessToken::create([ + 'tokenable_type' => get_class($user), + 'tokenable_id' => $user->id, + 'name' => $tokenData['name'], + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $tokenData['abilities'], + 'team_id' => $team->id, + ]); + + $this->command->info("Created token '{$tokenData['name']}' with Bearer token: {$plainTextToken}"); + } + + $this->command->info(''); + $this->command->info('Test API tokens created successfully!'); + $this->command->info('You can use these tokens in development as:'); + $this->command->info(' Bearer root - Root access'); + $this->command->info(' Bearer read - Read only access'); + $this->command->info(' Bearer read-sensitive - Read with sensitive data access'); + $this->command->info(' Bearer write - Write access'); + $this->command->info(' Bearer write-sensitive - Write with sensitive data access'); + $this->command->info(' Bearer deploy - Deploy access'); + } +} diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 6c9628a81..628fb5054 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -72,6 +72,7 @@ RUN apk add --no-cache gnupg && \ curl -fSsL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /usr/share/keyrings/postgresql.gpg # Install system dependencies +RUN apk upgrade RUN apk add --no-cache \ postgresql${POSTGRES_VERSION}-client \ openssh-client \ diff --git a/openapi.json b/openapi.json index 2b0a81c6e..901741dd0 100644 --- a/openapi.json +++ b/openapi.json @@ -3309,6 +3309,55 @@ ] } }, + "\/databases\/{uuid}\/backups": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Get", + "description": "Get backups details by database UUID.", + "operationId": "get-database-backups-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get all backups for a database", + "content": { + "application\/json": { + "schema": { + "type": "string" + }, + "example": "Content is very complex. Will be implemented later." + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases\/{uuid}": { "get": { "tags": [ @@ -3658,6 +3707,200 @@ ] } }, + "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete backup configuration", + "description": "Deletes a backup configuration and all its executions.", + "operationId": "delete-backup-configuration-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduled_backup_uuid", + "in": "path", + "description": "UUID of the backup configuration to delete", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "delete_s3", + "in": "query", + "description": "Whether to delete all backup files from S3", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Backup configuration deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "": { + "type": "string", + "example": "Backup configuration and all executions deleted." + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Backup configuration not found.", + "content": { + "application\/json": { + "schema": { + "properties": { + "": { + "type": "string", + "example": "Backup configuration not found." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update", + "description": "Update a specific backup configuration for a given database, identified by its UUID and the backup ID", + "operationId": "update-database-backup", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "scheduled_backup_uuid", + "in": "path", + "description": "UUID of the backup configuration.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "Database backup configuration data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "save_s3": { + "type": "boolean", + "description": "Whether data is saved in s3 or not" + }, + "s3_storage_uuid": { + "type": "string", + "description": "S3 storage UUID" + }, + "backup_now": { + "type": "boolean", + "description": "Whether to take a backup now or not" + }, + "enabled": { + "type": "boolean", + "description": "Whether the backup is enabled or not" + }, + "databases_to_backup": { + "type": "string", + "description": "Comma separated list of databases to backup" + }, + "dump_all": { + "type": "boolean", + "description": "Whether all databases are dumped or not" + }, + "frequency": { + "type": "string", + "description": "Frequency of the backup" + }, + "database_backup_retention_amount_locally": { + "type": "integer", + "description": "Retention amount of the backup locally" + }, + "database_backup_retention_days_locally": { + "type": "integer", + "description": "Retention days of the backup locally" + }, + "database_backup_retention_max_storage_locally": { + "type": "integer", + "description": "Max storage of the backup locally" + }, + "database_backup_retention_amount_s3": { + "type": "integer", + "description": "Retention amount of the backup in s3" + }, + "database_backup_retention_days_s3": { + "type": "integer", + "description": "Retention days of the backup in s3" + }, + "database_backup_retention_max_storage_s3": { + "type": "integer", + "description": "Max storage of the backup in S3" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database backup configuration updated" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases\/postgresql": { "post": { "tags": [ @@ -4694,6 +4937,175 @@ ] } }, + "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions\/{execution_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete backup execution", + "description": "Deletes a specific backup execution.", + "operationId": "delete-backup-execution-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduled_backup_uuid", + "in": "path", + "description": "UUID of the backup configuration", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "execution_uuid", + "in": "path", + "description": "UUID of the backup execution to delete", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "delete_s3", + "in": "query", + "description": "Whether to delete the backup from S3", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Backup execution deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "": { + "type": "string", + "example": "Backup execution deleted." + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Backup execution not found.", + "content": { + "application\/json": { + "schema": { + "properties": { + "": { + "type": "string", + "example": "Backup execution not found." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List backup executions", + "description": "Get all executions for a specific backup configuration.", + "operationId": "list-backup-executions", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduled_backup_uuid", + "in": "path", + "description": "UUID of the backup configuration", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "List of backup executions", + "content": { + "application\/json": { + "schema": { + "properties": { + "": { + "type": "array", + "items": { + "properties": { + "uuid": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Backup configuration not found." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases\/{uuid}\/start": { "get": { "tags": [ @@ -5095,6 +5507,477 @@ ] } }, + "\/github-apps": { + "post": { + "tags": [ + "GitHub Apps" + ], + "summary": "Create GitHub App", + "description": "Create a new GitHub app.", + "operationId": "create-github-app", + "requestBody": { + "description": "GitHub app creation payload.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "api_url", + "html_url", + "app_id", + "installation_id", + "client_id", + "client_secret", + "private_key_uuid" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the GitHub app." + }, + "organization": { + "type": "string", + "nullable": true, + "description": "Organization to associate the app with." + }, + "api_url": { + "type": "string", + "description": "API URL for the GitHub app (e.g., https:\/\/api.github.com)." + }, + "html_url": { + "type": "string", + "description": "HTML URL for the GitHub app (e.g., https:\/\/github.com)." + }, + "custom_user": { + "type": "string", + "description": "Custom user for SSH access (default: git)." + }, + "custom_port": { + "type": "integer", + "description": "Custom port for SSH access (default: 22)." + }, + "app_id": { + "type": "integer", + "description": "GitHub App ID from GitHub." + }, + "installation_id": { + "type": "integer", + "description": "GitHub Installation ID." + }, + "client_id": { + "type": "string", + "description": "GitHub OAuth App Client ID." + }, + "client_secret": { + "type": "string", + "description": "GitHub OAuth App Client Secret." + }, + "webhook_secret": { + "type": "string", + "description": "Webhook secret for GitHub webhooks." + }, + "private_key_uuid": { + "type": "string", + "description": "UUID of an existing private key for GitHub App authentication." + }, + "is_system_wide": { + "type": "boolean", + "description": "Is this app system-wide (cloud only)." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "GitHub app created successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "organization": { + "type": "string", + "nullable": true + }, + "api_url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "custom_user": { + "type": "string" + }, + "custom_port": { + "type": "integer" + }, + "app_id": { + "type": "integer" + }, + "installation_id": { + "type": "integer" + }, + "client_id": { + "type": "string" + }, + "private_key_id": { + "type": "integer" + }, + "is_system_wide": { + "type": "boolean" + }, + "team_id": { + "type": "integer" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/github-apps\/{github_app_id}\/repositories": { + "get": { + "tags": [ + "GitHub Apps" + ], + "summary": "Load Repositories for a GitHub App", + "description": "Fetch repositories from GitHub for a given GitHub app.", + "operationId": "load-repositories", + "parameters": [ + { + "name": "github_app_id", + "in": "path", + "description": "GitHub App ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Repositories loaded successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/github-apps\/{github_app_id}\/repositories\/{owner}\/{repo}\/branches": { + "get": { + "tags": [ + "GitHub Apps" + ], + "summary": "Load Branches for a GitHub Repository", + "description": "Fetch branches from GitHub for a given repository.", + "operationId": "load-branches", + "parameters": [ + { + "name": "github_app_id", + "in": "path", + "description": "GitHub App ID", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "owner", + "in": "path", + "description": "Repository owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo", + "in": "path", + "description": "Repository name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Branches loaded successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/github-apps\/{github_app_id}": { + "delete": { + "tags": [ + "GitHub Apps" + ], + "summary": "Delete GitHub App", + "description": "Delete a GitHub app if it's not being used by any applications.", + "operationId": "deleteGithubApp", + "parameters": [ + { + "name": "github_app_id", + "in": "path", + "description": "GitHub App ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "GitHub app deleted successfully", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "GitHub app deleted successfully" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "GitHub app not found" + }, + "409": { + "description": "Conflict - GitHub app is in use", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "This GitHub app is being used by 5 application(s). Please delete all applications first." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "GitHub Apps" + ], + "summary": "Update GitHub App", + "description": "Update an existing GitHub app.", + "operationId": "updateGithubApp", + "parameters": [ + { + "name": "github_app_id", + "in": "path", + "description": "GitHub App ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "GitHub App name" + }, + "organization": { + "type": "string", + "nullable": true, + "description": "GitHub organization" + }, + "api_url": { + "type": "string", + "description": "GitHub API URL" + }, + "html_url": { + "type": "string", + "description": "GitHub HTML URL" + }, + "custom_user": { + "type": "string", + "description": "Custom user for SSH" + }, + "custom_port": { + "type": "integer", + "description": "Custom port for SSH" + }, + "app_id": { + "type": "integer", + "description": "GitHub App ID" + }, + "installation_id": { + "type": "integer", + "description": "GitHub Installation ID" + }, + "client_id": { + "type": "string", + "description": "GitHub Client ID" + }, + "client_secret": { + "type": "string", + "description": "GitHub Client Secret" + }, + "webhook_secret": { + "type": "string", + "description": "GitHub Webhook Secret" + }, + "private_key_uuid": { + "type": "string", + "description": "Private key UUID" + }, + "is_system_wide": { + "type": "boolean", + "description": "Is system wide (non-cloud instances only)" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "GitHub app updated successfully", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "GitHub app updated successfully" + }, + "data": { + "type": "object", + "description": "Updated GitHub app data" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "GitHub app not found" + }, + "422": { + "description": "Validation error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/version": { "get": { "summary": "Version", @@ -8890,6 +9773,10 @@ "name": "Deployments", "description": "Deployments" }, + { + "name": "GitHub Apps", + "description": "GitHub Apps" + }, { "name": "Projects", "description": "Projects" diff --git a/openapi.yaml b/openapi.yaml index 9529fcf87..3e39c5d36 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2097,6 +2097,39 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/backups': + get: + tags: + - Databases + summary: Get + description: 'Get backups details by database UUID.' + operationId: get-database-backups-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + format: uuid + responses: + '200': + description: 'Get all backups for a database' + content: + application/json: + schema: + type: string + example: 'Content is very complex. Will be implemented later.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] '/databases/{uuid}': get: tags: @@ -2347,6 +2380,139 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/backups/{scheduled_backup_uuid}': + delete: + tags: + - Databases + summary: 'Delete backup configuration' + description: 'Deletes a backup configuration and all its executions.' + operationId: delete-backup-configuration-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database' + required: true + schema: + type: string + - + name: scheduled_backup_uuid + in: path + description: 'UUID of the backup configuration to delete' + required: true + schema: + type: string + format: uuid + - + name: delete_s3 + in: query + description: 'Whether to delete all backup files from S3' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Backup configuration deleted.' + content: + application/json: + schema: + properties: + '': { type: string, example: 'Backup configuration and all executions deleted.' } + type: object + '404': + description: 'Backup configuration not found.' + content: + application/json: + schema: + properties: + '': { type: string, example: 'Backup configuration not found.' } + type: object + security: + - + bearerAuth: [] + patch: + tags: + - Databases + summary: Update + description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID' + operationId: update-database-backup + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + format: uuid + - + name: scheduled_backup_uuid + in: path + description: 'UUID of the backup configuration.' + required: true + schema: + type: string + format: uuid + requestBody: + description: 'Database backup configuration data' + required: true + content: + application/json: + schema: + properties: + save_s3: + type: boolean + description: 'Whether data is saved in s3 or not' + s3_storage_uuid: + type: string + description: 'S3 storage UUID' + backup_now: + type: boolean + description: 'Whether to take a backup now or not' + enabled: + type: boolean + description: 'Whether the backup is enabled or not' + databases_to_backup: + type: string + description: 'Comma separated list of databases to backup' + dump_all: + type: boolean + description: 'Whether all databases are dumped or not' + frequency: + type: string + description: 'Frequency of the backup' + database_backup_retention_amount_locally: + type: integer + description: 'Retention amount of the backup locally' + database_backup_retention_days_locally: + type: integer + description: 'Retention days of the backup locally' + database_backup_retention_max_storage_locally: + type: integer + description: 'Max storage of the backup locally' + database_backup_retention_amount_s3: + type: integer + description: 'Retention amount of the backup in s3' + database_backup_retention_days_s3: + type: integer + description: 'Retention days of the backup in s3' + database_backup_retention_max_storage_s3: + type: integer + description: 'Max storage of the backup in S3' + type: object + responses: + '200': + description: 'Database backup configuration updated' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /databases/postgresql: post: tags: @@ -3094,6 +3260,102 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}': + delete: + tags: + - Databases + summary: 'Delete backup execution' + description: 'Deletes a specific backup execution.' + operationId: delete-backup-execution-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database' + required: true + schema: + type: string + - + name: scheduled_backup_uuid + in: path + description: 'UUID of the backup configuration' + required: true + schema: + type: string + format: uuid + - + name: execution_uuid + in: path + description: 'UUID of the backup execution to delete' + required: true + schema: + type: string + format: uuid + - + name: delete_s3 + in: query + description: 'Whether to delete the backup from S3' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Backup execution deleted.' + content: + application/json: + schema: + properties: + '': { type: string, example: 'Backup execution deleted.' } + type: object + '404': + description: 'Backup execution not found.' + content: + application/json: + schema: + properties: + '': { type: string, example: 'Backup execution not found.' } + type: object + security: + - + bearerAuth: [] + '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions': + get: + tags: + - Databases + summary: 'List backup executions' + description: 'Get all executions for a specific backup configuration.' + operationId: list-backup-executions + parameters: + - + name: uuid + in: path + description: 'UUID of the database' + required: true + schema: + type: string + - + name: scheduled_backup_uuid + in: path + description: 'UUID of the backup configuration' + required: true + schema: + type: string + format: uuid + responses: + '200': + description: 'List of backup executions' + content: + application/json: + schema: + properties: + '': { type: array, items: { properties: { uuid: { type: string }, filename: { type: string }, size: { type: integer }, created_at: { type: string }, message: { type: string }, status: { type: string } }, type: object } } + type: object + '404': + description: 'Backup configuration not found.' + security: + - + bearerAuth: [] '/databases/{uuid}/start': get: tags: @@ -3348,6 +3610,300 @@ paths: security: - bearerAuth: [] + /github-apps: + post: + tags: + - 'GitHub Apps' + summary: 'Create GitHub App' + description: 'Create a new GitHub app.' + operationId: create-github-app + requestBody: + description: 'GitHub app creation payload.' + required: true + content: + application/json: + schema: + required: + - name + - api_url + - html_url + - app_id + - installation_id + - client_id + - client_secret + - private_key_uuid + properties: + name: + type: string + description: 'Name of the GitHub app.' + organization: + type: string + nullable: true + description: 'Organization to associate the app with.' + api_url: + type: string + description: 'API URL for the GitHub app (e.g., https://api.github.com).' + html_url: + type: string + description: 'HTML URL for the GitHub app (e.g., https://github.com).' + custom_user: + type: string + description: 'Custom user for SSH access (default: git).' + custom_port: + type: integer + description: 'Custom port for SSH access (default: 22).' + app_id: + type: integer + description: 'GitHub App ID from GitHub.' + installation_id: + type: integer + description: 'GitHub Installation ID.' + client_id: + type: string + description: 'GitHub OAuth App Client ID.' + client_secret: + type: string + description: 'GitHub OAuth App Client Secret.' + webhook_secret: + type: string + description: 'Webhook secret for GitHub webhooks.' + private_key_uuid: + type: string + description: 'UUID of an existing private key for GitHub App authentication.' + is_system_wide: + type: boolean + description: 'Is this app system-wide (cloud only).' + type: object + responses: + '201': + description: 'GitHub app created successfully.' + content: + application/json: + schema: + properties: + id: { type: integer } + uuid: { type: string } + name: { type: string } + organization: { type: string, nullable: true } + api_url: { type: string } + html_url: { type: string } + custom_user: { type: string } + custom_port: { type: integer } + app_id: { type: integer } + installation_id: { type: integer } + client_id: { type: string } + private_key_id: { type: integer } + is_system_wide: { type: boolean } + team_id: { type: integer } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/github-apps/{github_app_id}/repositories': + get: + tags: + - 'GitHub Apps' + summary: 'Load Repositories for a GitHub App' + description: 'Fetch repositories from GitHub for a given GitHub app.' + operationId: load-repositories + parameters: + - + name: github_app_id + in: path + description: 'GitHub App ID' + required: true + schema: + type: integer + responses: + '200': + description: 'Repositories loaded successfully.' + content: + application/json: + schema: + properties: + '': { type: array, items: { type: object } } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches': + get: + tags: + - 'GitHub Apps' + summary: 'Load Branches for a GitHub Repository' + description: 'Fetch branches from GitHub for a given repository.' + operationId: load-branches + parameters: + - + name: github_app_id + in: path + description: 'GitHub App ID' + required: true + schema: + type: integer + - + name: owner + in: path + description: 'Repository owner' + required: true + schema: + type: string + - + name: repo + in: path + description: 'Repository name' + required: true + schema: + type: string + responses: + '200': + description: 'Branches loaded successfully.' + content: + application/json: + schema: + properties: + '': { type: array, items: { type: object } } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/github-apps/{github_app_id}': + delete: + tags: + - 'GitHub Apps' + summary: 'Delete GitHub App' + description: "Delete a GitHub app if it's not being used by any applications." + operationId: deleteGithubApp + parameters: + - + name: github_app_id + in: path + description: 'GitHub App ID' + required: true + schema: + type: integer + responses: + '200': + description: 'GitHub app deleted successfully' + content: + application/json: + schema: + properties: + message: { type: string, example: 'GitHub app deleted successfully' } + type: object + '401': + description: Unauthorized + '404': + description: 'GitHub app not found' + '409': + description: 'Conflict - GitHub app is in use' + content: + application/json: + schema: + properties: + message: { type: string, example: 'This GitHub app is being used by 5 application(s). Please delete all applications first.' } + type: object + security: + - + bearerAuth: [] + patch: + tags: + - 'GitHub Apps' + summary: 'Update GitHub App' + description: 'Update an existing GitHub app.' + operationId: updateGithubApp + parameters: + - + name: github_app_id + in: path + description: 'GitHub App ID' + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'GitHub App name' + organization: + type: string + nullable: true + description: 'GitHub organization' + api_url: + type: string + description: 'GitHub API URL' + html_url: + type: string + description: 'GitHub HTML URL' + custom_user: + type: string + description: 'Custom user for SSH' + custom_port: + type: integer + description: 'Custom port for SSH' + app_id: + type: integer + description: 'GitHub App ID' + installation_id: + type: integer + description: 'GitHub Installation ID' + client_id: + type: string + description: 'GitHub Client ID' + client_secret: + type: string + description: 'GitHub Client Secret' + webhook_secret: + type: string + description: 'GitHub Webhook Secret' + private_key_uuid: + type: string + description: 'Private key UUID' + is_system_wide: + type: boolean + description: 'Is system wide (non-cloud instances only)' + type: object + responses: + '200': + description: 'GitHub app updated successfully' + content: + application/json: + schema: + properties: + message: { type: string, example: 'GitHub app updated successfully' } + data: { type: object, description: 'Updated GitHub app data' } + type: object + '401': + description: Unauthorized + '404': + description: 'GitHub app not found' + '422': + description: 'Validation error' + security: + - + bearerAuth: [] /version: get: summary: Version @@ -5781,6 +6337,9 @@ tags: - name: Deployments description: Deployments + - + name: 'GitHub Apps' + description: 'GitHub Apps' - name: Projects description: Projects diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 57f062202..b90f126a2 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -61,7 +61,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.9' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index e19ec961f..09ce3ead3 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -103,7 +103,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.0' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.10' pull_policy: always container_name: coolify-realtime restart: always diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 92ad12302..bcd37e71f 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -20,7 +20,6 @@ DATE=$(date +"%Y%m%d-%H%M%S") OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') ENV_FILE="/data/coolify/source/.env" -VERSION="21" DOCKER_VERSION="27.0" # TODO: Ask for a user CURRENT_USER=$USER @@ -32,7 +31,7 @@ fi echo -e "Welcome to Coolify Installer!" echo -e "This script will install everything for you. Sit back and relax." -echo -e "Source code: https://github.com/coollabsio/coolify/blob/main/scripts/install.sh\n" +echo -e "Source code: https://github.com/coollabsio/coolify/blob/v4.x/scripts/install.sh" # Predefined root user ROOT_USERNAME=${ROOT_USERNAME:-} @@ -711,84 +710,80 @@ curl -fsSL $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.p curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production curl -fsSL $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh -echo -e "6. Make backup of .env to .env-$DATE" +echo -e "6. Setting up environment variable file" -# Copy .env.example if .env does not exist -if [ -f $ENV_FILE ]; then - cp $ENV_FILE $ENV_FILE-$DATE +if [ -f "$ENV_FILE" ]; then + # If .env exists, create backup + echo " - Creating backup of existing .env file to .env-$DATE" + cp "$ENV_FILE" "$ENV_FILE-$DATE" + # Merge .env.production values into .env + echo " - Merging .env.production values into .env" + awk -F '=' '!seen[$1]++' "$ENV_FILE" "/data/coolify/source/.env.production" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" + echo " - .env file merged successfully" else - echo " - File does not exist: $ENV_FILE" - echo " - Copying .env.production to .env-$DATE" - cp /data/coolify/source/.env.production $ENV_FILE-$DATE - # Generate a secure APP_ID and APP_KEY - sed -i "s|^APP_ID=.*|APP_ID=$(openssl rand -hex 16)|" "$ENV_FILE-$DATE" - sed -i "s|^APP_KEY=.*|APP_KEY=base64:$(openssl rand -base64 32)|" "$ENV_FILE-$DATE" - - # Generate a secure Postgres DB username and password - # Causes issues: database "random-user" does not exist - # sed -i "s|^DB_USERNAME=.*|DB_USERNAME=$(openssl rand -hex 16)|" "$ENV_FILE-$DATE" - sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=$(openssl rand -base64 32)|" "$ENV_FILE-$DATE" - - # Generate a secure Redis password - sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=$(openssl rand -base64 32)|" "$ENV_FILE-$DATE" - - # Generate secure Pusher credentials - sed -i "s|^PUSHER_APP_ID=.*|PUSHER_APP_ID=$(openssl rand -hex 32)|" "$ENV_FILE-$DATE" - sed -i "s|^PUSHER_APP_KEY=.*|PUSHER_APP_KEY=$(openssl rand -hex 32)|" "$ENV_FILE-$DATE" - sed -i "s|^PUSHER_APP_SECRET=.*|PUSHER_APP_SECRET=$(openssl rand -hex 32)|" "$ENV_FILE-$DATE" + # If no .env exists, copy .env.production to .env + echo " - No .env file found, copying .env.production to .env" + cp "/data/coolify/source/.env.production" "$ENV_FILE" fi +echo -e "7. Checking and updating environment variables if necessary..." + +update_env_var() { + local key="$1" + local value="$2" + + # If variable "key=" exists but has no value, update the value of the existing line + if grep -q "^${key}=$" "$ENV_FILE"; then + sed -i "s|^${key}=$|${key}=${value}|" "$ENV_FILE" + echo " - Updated value of ${key} as the current value was empty" + # If variable "key=" doesn't exist, append it to the file with value + elif ! grep -q "^${key}=" "$ENV_FILE"; then + printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE" + echo " - Added ${key} and it's value as the variable was missing" + fi +} + +update_env_var "APP_ID" "$(openssl rand -hex 16)" +update_env_var "APP_KEY" "base64:$(openssl rand -base64 32)" +# update_env_var "DB_USERNAME" "$(openssl rand -hex 16)" # Causes issues: database "random-user" does not exist +update_env_var "DB_PASSWORD" "$(openssl rand -base64 32)" +update_env_var "REDIS_PASSWORD" "$(openssl rand -base64 32)" +update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)" +update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" +update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)" + # Add default root user credentials from environment variables if [ -n "$ROOT_USERNAME" ] && [ -n "$ROOT_USER_EMAIL" ] && [ -n "$ROOT_USER_PASSWORD" ]; then - if grep -q "^ROOT_USERNAME=" "$ENV_FILE-$DATE"; then - sed -i "s|^ROOT_USERNAME=.*|ROOT_USERNAME=$ROOT_USERNAME|" "$ENV_FILE-$DATE" - fi - if grep -q "^ROOT_USER_EMAIL=" "$ENV_FILE-$DATE"; then - sed -i "s|^ROOT_USER_EMAIL=.*|ROOT_USER_EMAIL=$ROOT_USER_EMAIL|" "$ENV_FILE-$DATE" - fi - if grep -q "^ROOT_USER_PASSWORD=" "$ENV_FILE-$DATE"; then - sed -i "s|^ROOT_USER_PASSWORD=.*|ROOT_USER_PASSWORD=$ROOT_USER_PASSWORD|" "$ENV_FILE-$DATE" - fi + echo " - Setting predefined root user credentials from environment" + update_env_var "ROOT_USERNAME" "$ROOT_USERNAME" + update_env_var "ROOT_USER_EMAIL" "$ROOT_USER_EMAIL" + update_env_var "ROOT_USER_PASSWORD" "$ROOT_USER_PASSWORD" fi -# Add registry URL to .env file if [ -n "${REGISTRY_URL+x}" ]; then # Only update if REGISTRY_URL was explicitly provided - if grep -q "^REGISTRY_URL=" "$ENV_FILE-$DATE"; then - sed -i "s|^REGISTRY_URL=.*|REGISTRY_URL=$REGISTRY_URL|" "$ENV_FILE-$DATE" - else - echo "REGISTRY_URL=$REGISTRY_URL" >>"$ENV_FILE-$DATE" - fi + update_env_var "REGISTRY_URL" "$REGISTRY_URL" fi -# Merge .env and .env.production. New values will be added to .env -echo -e "7. Propagating .env with new values - if necessary." -awk -F '=' '!seen[$1]++' "$ENV_FILE-$DATE" /data/coolify/source/.env.production >$ENV_FILE - if [ "$AUTOUPDATE" = "false" ]; then - if ! grep -q "AUTOUPDATE=" /data/coolify/source/.env; then - echo "AUTOUPDATE=false" >>/data/coolify/source/.env - else - sed -i "s|AUTOUPDATE=.*|AUTOUPDATE=false|g" /data/coolify/source/.env + update_env_var "AUTOUPDATE" "false" +fi + +if [ "$DOCKER_POOL_BASE_PROVIDED" = true ]; then + update_env_var "DOCKER_ADDRESS_POOL_BASE" "$DOCKER_ADDRESS_POOL_BASE" +else + # Add with default value if missing + if ! grep -q "^DOCKER_ADDRESS_POOL_BASE=" "$ENV_FILE"; then + update_env_var "DOCKER_ADDRESS_POOL_BASE" "$DOCKER_ADDRESS_POOL_BASE" fi fi -# Save Docker address pool configuration to .env file -if ! grep -q "DOCKER_ADDRESS_POOL_BASE=" /data/coolify/source/.env; then - echo "DOCKER_ADDRESS_POOL_BASE=$DOCKER_ADDRESS_POOL_BASE" >>/data/coolify/source/.env +if [ "$DOCKER_POOL_SIZE_PROVIDED" = true ]; then + update_env_var "DOCKER_ADDRESS_POOL_SIZE" "$DOCKER_ADDRESS_POOL_SIZE" else - # Only update if explicitly provided - if [ "$DOCKER_POOL_BASE_PROVIDED" = true ]; then - sed -i "s|DOCKER_ADDRESS_POOL_BASE=.*|DOCKER_ADDRESS_POOL_BASE=$DOCKER_ADDRESS_POOL_BASE|g" /data/coolify/source/.env - fi -fi - -if ! grep -q "DOCKER_ADDRESS_POOL_SIZE=" /data/coolify/source/.env; then - echo "DOCKER_ADDRESS_POOL_SIZE=$DOCKER_ADDRESS_POOL_SIZE" >>/data/coolify/source/.env -else - # Only update if explicitly provided - if [ "$DOCKER_POOL_SIZE_PROVIDED" = true ]; then - sed -i "s|DOCKER_ADDRESS_POOL_SIZE=.*|DOCKER_ADDRESS_POOL_SIZE=$DOCKER_ADDRESS_POOL_SIZE|g" /data/coolify/source/.env + # Add with default value if missing + if ! grep -q "^DOCKER_ADDRESS_POOL_SIZE=" "$ENV_FILE"; then + update_env_var "DOCKER_ADDRESS_POOL_SIZE" "$DOCKER_ADDRESS_POOL_SIZE" fi fi @@ -824,14 +819,13 @@ echo -e " - Please wait." getAJoke if [[ $- == *x* ]]; then - bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" + bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" else - bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" + bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" fi echo " - Coolify installed successfully." -rm -f $ENV_FILE-$DATE -echo " - Waiting for 20 seconds for Coolify (database migrations) to be ready." +echo " - Waiting 20 seconds for Coolify database migrations to complete." getAJoke sleep 20 @@ -868,5 +862,5 @@ if [ -n "$PRIVATE_IPS" ]; then fi done fi + echo -e "\nWARNING: It is highly recommended to backup your Environment variables file (/data/coolify/source/.env) to a safe location, outside of this server (e.g. into a Password Manager).\n" -cp /data/coolify/source/.env /data/coolify/source/.env.backup diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 0b031ca75..14eede4ee 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -1,11 +1,12 @@ #!/bin/bash ## Do not modify this file. You will lose the ability to autoupdate! -VERSION="15" CDN="https://cdn.coollabs.io/coolify-nightly" LATEST_IMAGE=${1:-latest} LATEST_HELPER_VERSION=${2:-latest} REGISTRY_URL=${3:-ghcr.io} +SKIP_BACKUP=${4:-false} +ENV_FILE="/data/coolify/source/.env" DATE=$(date +%Y-%m-%d-%H-%M-%S) LOGFILE="/data/coolify/source/upgrade-${DATE}.log" @@ -14,20 +15,39 @@ 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 -# Merge .env and .env.production. New values will be added to .env -awk -F '=' '!seen[$1]++' /data/coolify/source/.env /data/coolify/source/.env.production >/data/coolify/source/.env.tmp && mv /data/coolify/source/.env.tmp /data/coolify/source/.env -# Check if PUSHER_APP_ID or PUSHER_APP_KEY or PUSHER_APP_SECRET is empty in /data/coolify/source/.env -if grep -q "PUSHER_APP_ID=$" /data/coolify/source/.env; then - sed -i "s|PUSHER_APP_ID=.*|PUSHER_APP_ID=$(openssl rand -hex 32)|g" /data/coolify/source/.env +# Backup existing .env file before making any changes +if [ "$SKIP_BACKUP" != "true" ]; then + if [ -f "$ENV_FILE" ]; then + echo "Creating backup of existing .env file to .env-$DATE" >>"$LOGFILE" + cp "$ENV_FILE" "$ENV_FILE-$DATE" + else + echo "No existing .env file found to backup" >>"$LOGFILE" + fi fi -if grep -q "PUSHER_APP_KEY=$" /data/coolify/source/.env; then - sed -i "s|PUSHER_APP_KEY=.*|PUSHER_APP_KEY=$(openssl rand -hex 32)|g" /data/coolify/source/.env -fi +echo "Merging .env.production values into .env" >>"$LOGFILE" +awk -F '=' '!seen[$1]++' "$ENV_FILE" /data/coolify/source/.env.production > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" +echo ".env file merged successfully" >>"$LOGFILE" -if grep -q "PUSHER_APP_SECRET=$" /data/coolify/source/.env; then - sed -i "s|PUSHER_APP_SECRET=.*|PUSHER_APP_SECRET=$(openssl rand -hex 32)|g" /data/coolify/source/.env -fi +update_env_var() { + local key="$1" + local value="$2" + + # If variable "key=" exists but has no value, update the value of the existing line + if grep -q "^${key}=$" "$ENV_FILE"; then + sed -i "s|^${key}=$|${key}=${value}|" "$ENV_FILE" + echo " - Updated value of ${key} as the current value was empty" >>"$LOGFILE" + # If variable "key=" doesn't exist, append it to the file with value + elif ! grep -q "^${key}=" "$ENV_FILE"; then + printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE" + echo " - Added ${key} with default value as the variable was missing" >>"$LOGFILE" + fi +} + +echo "Checking and updating environment variables if necessary..." >>"$LOGFILE" +update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)" +update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" +update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)" # Make sure coolify network exists # It is created when starting Coolify with docker compose @@ -37,11 +57,16 @@ if ! docker network inspect coolify >/dev/null 2>&1; then docker network create --attachable coolify 2>/dev/null fi fi -# docker network create --attachable --driver=overlay coolify-overlay 2>/dev/null + +# Check if Docker config file exists +DOCKER_CONFIG_MOUNT="" +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 --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 + 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 --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 + 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 diff --git a/other/nightly/versions.json b/other/nightly/versions.json index fd5dccaf0..2e5cc5e84 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.428" + "version": "4.0.0-beta.435" }, "nightly": { - "version": "4.0.0-beta.429" + "version": "4.0.0-beta.436" }, "helper": { "version": "1.0.11" diff --git a/public/coolify-logo-dev-transparent.png b/public/coolify-logo-dev-transparent.png index 9beeb9ba3..4e65e8b72 100644 Binary files a/public/coolify-logo-dev-transparent.png and b/public/coolify-logo-dev-transparent.png differ diff --git a/public/coolify-logo-dev-transparent.svg b/public/coolify-logo-dev-transparent.svg new file mode 100644 index 000000000..a4159154f --- /dev/null +++ b/public/coolify-logo-dev-transparent.svg @@ -0,0 +1 @@ +Coolify \ No newline at end of file diff --git a/public/coolify-logo-monochrome.png b/public/coolify-logo-monochrome.png new file mode 100644 index 000000000..48605e8fd Binary files /dev/null and b/public/coolify-logo-monochrome.png differ diff --git a/public/coolify-logo-monochrome.svg b/public/coolify-logo-monochrome.svg new file mode 100644 index 000000000..f60f33f97 --- /dev/null +++ b/public/coolify-logo-monochrome.svg @@ -0,0 +1 @@ +Coolify \ No newline at end of file diff --git a/public/coolify-logo-red.png b/public/coolify-logo-red.png new file mode 100644 index 000000000..b3f7d2b6c Binary files /dev/null and b/public/coolify-logo-red.png differ diff --git a/public/coolify-logo-red.svg b/public/coolify-logo-red.svg new file mode 100644 index 000000000..4cbfef43f --- /dev/null +++ b/public/coolify-logo-red.svg @@ -0,0 +1 @@ +Coolify \ No newline at end of file diff --git a/public/coolify-logo.svg b/public/coolify-logo.svg index 6f4f641f5..bff8f6b40 100644 --- a/public/coolify-logo.svg +++ b/public/coolify-logo.svg @@ -1,9 +1 @@ - - - - - - - - - +Coolify \ No newline at end of file diff --git a/public/coolify-transparent.png b/public/coolify-transparent.png index 96fc0db36..99a56acbe 100644 Binary files a/public/coolify-transparent.png and b/public/coolify-transparent.png differ diff --git a/public/ente-photos-icon-green.png b/public/ente-photos-icon-green.png new file mode 100644 index 000000000..b74aa472d Binary files /dev/null and b/public/ente-photos-icon-green.png differ diff --git a/public/svgs/ente-photos.svg b/public/svgs/ente-photos.svg new file mode 100644 index 000000000..e6a469e91 --- /dev/null +++ b/public/svgs/ente-photos.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/svgs/ente.png b/public/svgs/ente.png new file mode 100644 index 000000000..f510a7bf7 Binary files /dev/null and b/public/svgs/ente.png differ diff --git a/resources/css/app.css b/resources/css/app.css index 77fa2d66b..c1dc7e56d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -20,8 +20,11 @@ @theme { --color-warning: #fcd452; --color-success: #16a34a; --color-error: #dc2626; + --color-coollabs-50: #f5f0ff; --color-coollabs: #6b16ed; --color-coollabs-100: #7317ff; + --color-coollabs-200: #5a12c7; + --color-coollabs-300: #4a0fa3; --color-coolgray-100: #181818; --color-coolgray-200: #202020; --color-coolgray-300: #242424; @@ -91,11 +94,11 @@ option { } button[isError]:not(:disabled) { - @apply text-white bg-red-600 hover:bg-red-700; + @apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white dark:hover:bg-red-800 dark:hover:text-white; } button[isHighlighted]:not(:disabled) { - @apply text-white bg-coollabs hover:bg-coollabs-100; + @apply text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white dark:hover:bg-coollabs-100 dark:hover:text-white; } h1 { @@ -118,6 +121,11 @@ a { @apply hover:text-black dark:hover:text-white; } +button:focus-visible, +a:focus-visible { + @apply outline-none ring-2 ring-coollabs dark:ring-warning ring-offset-2 dark:ring-offset-coolgray-100; +} + label { @apply dark:text-neutral-400; } diff --git a/resources/css/utilities.css b/resources/css/utilities.css index d09d7f49c..bedfb51bc 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -6,10 +6,31 @@ @utility apexcharts-tooltip-title { @apply hidden!; } +@utility apexcharts-grid-borders { + @apply dark:hidden!; +} + @utility apexcharts-xaxistooltip { @apply hidden!; } +@utility apexcharts-tooltip-custom { + @apply bg-white dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 rounded-lg shadow-lg p-3 text-sm; + min-width: 160px; +} + +@utility apexcharts-tooltip-custom-value { + @apply text-neutral-700 dark:text-neutral-300 mb-1; +} + +@utility apexcharts-tooltip-value-bold { + @apply font-bold text-black dark:text-white; +} + +@utility apexcharts-tooltip-custom-title { + @apply text-xs text-neutral-500 dark:text-neutral-400 font-medium; +} + @utility input-sticky { @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300; } @@ -42,7 +63,7 @@ @utility select { } @utility button { - @apply flex gap-2 justify-center items-center px-2 py-1 text-sm text-black normal-case rounded-sm border outline-0 cursor-pointer bg-neutral-200/50 border-neutral-300 hover:bg-neutral-300 dark:bg-coolgray-200 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-500 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300; + @apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100; } @utility alert-success { @@ -62,11 +83,11 @@ @utility add-tag { } @utility dropdown-item { - @apply flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50; + @apply flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs; } @utility dropdown-item-no-padding { - @apply flex relative gap-2 justify-start items-center py-1 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50; + @apply flex relative gap-2 justify-start items-center py-1 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs; } @utility badge { @@ -134,15 +155,15 @@ @utility kbd-custom { } @utility box { - @apply relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-black hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline; + @apply relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm; } @utility box-boarding { - @apply flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 dark:text-white bg-neutral-50 border border-neutral-200 dark:border-black hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:text-black hover:no-underline text-black; + @apply flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 dark:text-white bg-neutral-50 border border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:text-black hover:no-underline text-black rounded-sm; } @utility box-without-bg { - @apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem] border border-neutral-200 dark:border-black; + @apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem] border border-neutral-200 dark:border-coolgray-300 rounded-sm; } @utility box-without-bg-without-border { @@ -178,7 +199,7 @@ @utility info-helper { } @utility info-helper-popup { - @apply hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300; + @apply hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-xs whitespace-normal break-words; } @utility buyme { diff --git a/resources/views/components/applications/advanced.blade.php b/resources/views/components/applications/advanced.blade.php index 46ea54e99..e36583741 100644 --- a/resources/views/components/applications/advanced.blade.php +++ b/resources/views/components/applications/advanced.blade.php @@ -19,7 +19,7 @@ @else
- + + The following domain(s) are already in use by other resources. Using the same domain for + multiple resources can cause routing conflicts and unpredictable behavior. +
-

Conflicting Resources:

    @foreach ($conflicts as $conflict)
  • @@ -58,9 +56,7 @@ class="underline hover:text-red-400">
- +
-
+
{{ $slot }}
diff --git a/resources/views/components/environment-variable-warning.blade.php b/resources/views/components/environment-variable-warning.blade.php new file mode 100644 index 000000000..1ad05ed46 --- /dev/null +++ b/resources/views/components/environment-variable-warning.blade.php @@ -0,0 +1,37 @@ +@props(['problematicVariables' => []]) + + diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 0d185782f..1a3c88f80 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -11,6 +11,7 @@ 'content' => null, 'checkboxes' => [], 'actions' => [], + 'warningMessage' => null, 'confirmWithText' => true, 'confirmationText' => 'Confirm Deletion', 'confirmationLabel' => 'Please confirm the execution of the actions by entering the Name below', @@ -200,9 +201,6 @@ class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-f @if (!empty($checkboxes))
-
-

Actions

-
@foreach ($checkboxes as $index => $checkbox)
- + + {!! $warningMessage ?: 'This operation is permanent and cannot be undone. Please think again before proceeding!' !!} +
The following actions will be performed: