diff --git a/.cursor/mcp.json b/.cursor/mcp.json
new file mode 100644
index 000000000..8c6715a15
--- /dev/null
+++ b/.cursor/mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "laravel-boost": {
+ "command": "php",
+ "args": [
+ "artisan",
+ "boost:mcp"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc
new file mode 100644
index 000000000..005ede849
--- /dev/null
+++ b/.cursor/rules/laravel-boost.mdc
@@ -0,0 +1,405 @@
+---
+alwaysApply: true
+---
+
+=== foundation rules ===
+
+# Laravel Boost Guidelines
+
+The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
+
+## Foundational Context
+This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
+
+- php - 8.4.7
+- laravel/fortify (FORTIFY) - v1
+- laravel/framework (LARAVEL) - v12
+- laravel/horizon (HORIZON) - v5
+- laravel/prompts (PROMPTS) - v0
+- laravel/sanctum (SANCTUM) - v4
+- laravel/socialite (SOCIALITE) - v5
+- livewire/livewire (LIVEWIRE) - v3
+- laravel/dusk (DUSK) - v8
+- laravel/pint (PINT) - v1
+- laravel/telescope (TELESCOPE) - v5
+- pestphp/pest (PEST) - v3
+- phpunit/phpunit (PHPUNIT) - v11
+- rector/rector (RECTOR) - v2
+- laravel-echo (ECHO) - v2
+- tailwindcss (TAILWINDCSS) - v4
+- vue (VUE) - v3
+
+
+## Conventions
+- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
+- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
+- Check for existing components to reuse before writing a new one.
+
+## Verification Scripts
+- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
+
+## Application Structure & Architecture
+- Stick to existing directory structure - don't create new base folders without approval.
+- Do not change the application's dependencies without approval.
+
+## Frontend Bundling
+- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
+
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+
+=== laravel/core rules ===
+
+## Do Things the Laravel Way
+
+- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
+- If you're creating a generic PHP class, use `artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Database
+- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
+- Use Eloquent models and relationships before suggesting raw database queries
+- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
+- Generate code that prevents N+1 query problems by using eager loading.
+- Use Laravel's query builder for very complex database operations.
+
+### Model Creation
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
+
+### APIs & Eloquent Resources
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+### Controllers & Validation
+- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
+- Check sibling Form Requests to see if the application uses array or string based validation rules.
+
+### Queues
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+### Authentication & Authorization
+- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+
+### URL Generation
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+### Configuration
+- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+
+### Testing
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+### Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
+
+
+=== laravel/v12 rules ===
+
+## Laravel 12
+
+- Use the `search-docs` tool to get version specific documentation.
+- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
+- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that.
+
+### Laravel 10 Structure
+- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
+- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
+ - Middleware registration happens in `app/Http/Kernel.php`
+ - Exception handling is in `app/Exceptions/Handler.php`
+ - Console commands and schedule register in `app/Console/Kernel.php`
+ - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
+
+### Database
+- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
+- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
+
+### Models
+- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
+
+
+=== livewire/core rules ===
+
+## Livewire Core
+- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
+- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components
+- State should live on the server, with the UI reflecting it.
+- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
+
+## Livewire Best Practices
+- Livewire components require a single root element.
+- Use `wire:loading` and `wire:dirty` for delightful loading states.
+- Add `wire:key` in loops:
+
+ ```blade
+ @foreach ($items as $item)
+
+ {{ $item->name }}
+
+ @endforeach
+ ```
+
+- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
+
+
+ public function mount(User $user) { $this->user = $user; }
+ public function updatedSearch() { $this->resetPage(); }
+
+
+
+## Testing Livewire
+
+
+ Livewire::test(Counter::class)
+ ->assertSet('count', 0)
+ ->call('increment')
+ ->assertSet('count', 1)
+ ->assertSee(1)
+ ->assertStatus(200);
+
+
+
+
+ $this->get('/posts/create')
+ ->assertSeeLivewire(CreatePost::class);
+
+
+
+=== livewire/v3 rules ===
+
+## Livewire 3
+
+### Key Changes From Livewire 2
+- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
+ - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
+ - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
+ - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
+ - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
+
+### New Directives
+- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
+
+### Alpine
+- Alpine is now included with Livewire, don't manually include Alpine.js.
+- Plugins included with Alpine: persist, intersect, collapse, and focus.
+
+### Lifecycle Hooks
+- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
+
+
+document.addEventListener('livewire:init', function () {
+ Livewire.hook('request', ({ fail }) => {
+ if (fail && fail.status === 419) {
+ alert('Your session expired');
+ }
+ });
+
+ Livewire.hook('message.failed', (message, component) => {
+ console.error(message);
+ });
+});
+
+
+
+=== pint/core rules ===
+
+## Laravel Pint Code Formatter
+
+- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
+- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
+
+
+=== pest/core rules ===
+
+## Pest
+
+### Testing
+- If you need to verify a feature is working, write or update a Unit / Feature test.
+
+### Pest Tests
+- All tests must be written using Pest. Use `php artisan make:test --pest `.
+- 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.
+- Pest tests look and behave like this:
+
+it('is true', function () {
+ expect(true)->toBeTrue();
+});
+
+
+### 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.
+
+### 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.:
+
+it('returns all', function () {
+ $response = $this->postJson('/api/docs', []);
+
+ $response->assertSuccessful();
+});
+
+
+### Mocking
+- Mocking can be very helpful when appropriate.
+- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
+- You can also create partial mocks using the same import or self method.
+
+### Datasets
+- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
+
+
+it('has emails', function (string $email) {
+ expect($email)->not->toBeEmpty();
+})->with([
+ 'james' => 'james@laravel.com',
+ 'taylor' => 'taylor@laravel.com',
+]);
+
+
+
+=== tailwindcss/core rules ===
+
+## Tailwind Core
+
+- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
+- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
+- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
+- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
+
+### Spacing
+- When listing items, use gap utilities for spacing, don't use margins.
+
+
+
+
Superior
+
Michigan
+
Erie
+
+
+
+
+### Dark Mode
+- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
+
+
+=== tailwindcss/v4 rules ===
+
+## Tailwind 4
+
+- Always use Tailwind CSS v4 - do not use the deprecated utilities.
+- `corePlugins` is not supported in Tailwind v4.
+- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
+
+
+
+
+### Replaced Utilities
+- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
+- Opacity values are still numeric.
+
+| Deprecated | Replacement |
+|------------+--------------|
+| bg-opacity-* | bg-black/* |
+| text-opacity-* | text-black/* |
+| border-opacity-* | border-black/* |
+| divide-opacity-* | divide-black/* |
+| ring-opacity-* | ring-black/* |
+| placeholder-opacity-* | placeholder-black/* |
+| flex-shrink-* | shrink-* |
+| flex-grow-* | grow-* |
+| overflow-ellipsis | text-ellipsis |
+| decoration-slice | box-decoration-slice |
+| decoration-clone | box-decoration-clone |
+
+
+=== tests rules ===
+
+## Test Enforcement
+
+- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
+- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
+
\ No newline at end of file
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/.mcp.json b/.mcp.json
new file mode 100644
index 000000000..8c6715a15
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "laravel-boost": {
+ "command": "php",
+ "args": [
+ "artisan",
+ "boost:mcp"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/.phpactor.json b/.phpactor.json
new file mode 100644
index 000000000..4d42bbbc5
--- /dev/null
+++ b/.phpactor.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "/phpactor.schema.json",
+ "language_server_phpstan.enabled": true
+}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 661029f98..04b99c646 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,252 @@ # Changelog
## [unreleased]
+### 🐛 Bug Fixes
+
+- *(docker)* Adjust openssh-client installation in Dockerfile to avoid version bug
+- *(docker)* Streamline openssh-client installation in Dockerfile
+
+### 📚 Documentation
+
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Increment coolify version numbers to 4.0.0-beta.431 and 4.0.0-beta.432 in configuration files
+
+## [4.0.0-beta.430] - 2025-09-24
+
+### 🐛 Bug Fixes
+
+- *(PreviewCompose)* Adds port to preview urls
+- *(deployment-job)* Enhance build time variable analysis
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+## [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
+- *(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
+
+- Improve detection of special network modes
+- *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic
+- *(ui)* Display current version in settings dropdown and update UI accordingly
+- *(settings)* Add option to restrict PR deployments to repository members and contributors
+- *(command)* Implement SSH command retry logic with exponential backoff and logging for better error handling
+- *(ssh)* Add Sentry tracking for SSH retry events to enhance error monitoring
+- *(exceptions)* Introduce NonReportableException to handle known errors and update Handler for selective reporting
+- *(sudo-helper)* Add helper functions for command parsing and ownership management with sudo
+- *(dev-command)* Dispatch CheckHelperImageJob during instance initialization to enhance setup process
+- *(ssh-multiplexing)* Enhance multiplexed connection management with health checks and metadata caching
+- *(ssh-multiplexing)* Add connection age metadata handling to improve multiplexed connection management
+- *(database-backup)* Enhance error handling and output management in DatabaseBackupJob
+- *(application)* Display parsing version in development mode and clean up domain conflict modal markup
+- *(deployment)* Add SERVICE_NAME variables for service discovery
+- *(storages)* Add method to retrieve the first storage ID for improved stability in storage display
+- *(environment)* Add 'is_literal' attribute to environment variable for enhanced configuration options
+- *(pre-commit)* Automate generation of service templates and OpenAPI documentation during pre-commit hook
+- *(execute-container)* Enhance container command form with auto-connect feature for single container scenarios
+- *(environment)* Introduce 'is_buildtime_only' attribute to environment variables for improved build-time configuration
+- *(templates)* Add n8n service with PostgreSQL and worker support for enhanced workflow automation
+- *(user-management)* Implement user deletion command with phased resource and subscription cancellation, including dry run option
+- *(sentinel)* Add support for custom Docker images in StartSentinel and related methods
+- *(sentinel)* Add slide-over for viewing Sentinel logs and custom Docker image input for development
+- *(executions)* Add 'Load All' button to view all logs and implement loadAllLogs method for complete log retrieval
+- *(auth)* Enhance user login flow to handle team invitations, attaching users to invited teams upon first login and maintaining personal team logic for regular logins
+- *(laravel-boost)* Add Laravel Boost guidelines and MCP server configuration to enhance development experience
+- *(deployment)* Enhance deployment status reporting with detailed information on active deployments and team members
+- *(deployment)* Implement cancellation checks during deployment process to enhance user control and prevent unnecessary execution
+- *(deployment)* Introduce 'use_build_secrets' setting for enhanced security during Docker builds and update related logic in deployment process
+
+### 🐛 Bug Fixes
+
+- *(ui)* Transactional email settings link on members page (#6491)
+- *(api)* Add custom labels generation for applications with readonly container label setting enabled
+- *(ui)* Add cursor pointer to upgrade button for better user interaction
+- *(templates)* Update SECRET_KEY environment variable in getoutline.yaml to use SERVICE_HEX_32_OUTLINE
+- *(command)* Enhance database deletion command to support multiple database types
+- *(command)* Enhance cleanup process for stuck application previews by adding force delete for trashed records
+- *(user)* Ensure email attributes are stored in lowercase for consistency and prevent case-related issues
+- *(webhook)* Replace delete with forceDelete for application previews to ensure immediate removal
+- *(ssh)* Introduce SshRetryHandler and SshRetryable trait for enhanced SSH command retry logic with exponential backoff and error handling
+- Appwrite template - 500 errors, missing env vars etc.
+- *(LocalFileVolume)* Add missing directory creation command for workdir in saveStorageOnServer method
+- *(ScheduledTaskJob)* Replace generic Exception with NonReportableException for better error handling
+- *(web-routes)* Enhance backup response messages to clarify local and S3 availability
+- *(proxy)* Replace CheckConfiguration with GetProxyConfiguration and SaveConfiguration with SaveProxyConfiguration for improved clarity and consistency in proxy management
+- *(private-key)* Implement transaction handling and error verification for private key storage operations
+- *(deployment)* Add COOLIFY_* environment variables to Nixpacks build context for enhanced deployment configuration
+- *(application)* Add functionality to stop and remove Docker containers on server
+- *(templates)* Update 'compose' configuration for Appwrite service to enhance compatibility and streamline deployment
+- *(security)* Update contact email for reporting vulnerabilities to enhance privacy
+- *(feedback)* Update feedback email address to improve communication with users
+- *(security)* Update contact email for vulnerability reports to improve security communication
+- *(navbar)* Restrict subscription link visibility to admin users in cloud environment
+- *(docker)* Enhance container status aggregation for multi-container applications, including exclusion handling based on docker-compose configuration
+- *(application)* Improve watch paths handling by trimming and filtering empty paths to prevent unnecessary triggers
+- *(server)* Update server usability check to reflect actual Docker availability status
+- *(server)* Add build server check to disable Sentinel and update related logic
+- *(server)* Implement refreshServer method and update navbar event listener for improved server state management
+- *(deployment)* Prevent removal of running containers for pull request deployments in case of failure
+- *(docker)* Redirect stderr to stdout for container log retrieval to capture error messages
+- *(clone)* Update destinations method call to ensure correct retrieval of selected destination
+
+### 🚜 Refactor
+
+- *(jobs)* Pull github changelogs from cdn instead of github
+- *(command)* Streamline database deletion process to handle multiple database types and improve user experience
+- *(command)* Improve database collection logic for deletion command by using unique identifiers and enhancing user experience
+- *(command)* Remove InitChangelog command as it is no longer needed
+- *(command)* Streamline Init command by removing unnecessary options and enhancing error handling for various operations
+- *(webhook)* Replace direct forceDelete calls with DeleteResourceJob dispatch for application previews
+- *(command)* Replace forceDelete calls with DeleteResourceJob dispatch for all stuck resources in cleanup process
+- *(command)* Simplify SSH command retry logic by removing unnecessary logging and improving delay calculation
+- *(ssh)* Enhance error handling in SSH command execution and improve connection validation logging
+- *(backlog)* Remove outdated guidelines and project manager agent files to streamline task management documentation
+- *(error-handling)* Remove ray debugging statements from CheckUpdates and shared helper functions to clean up error reporting
+- *(file-transfer)* Replace base64 encoding with direct file transfer method across multiple database actions for improved clarity and efficiency
+- *(remoteProcess)* Remove debugging statement from transfer_file_to_server function to clean up code
+- *(dns-validation)* Rename DNS validation functions for consistency and clarity, and remove unused code
+- *(file-transfer)* Replace base64 encoding with direct file transfer method in various components for improved clarity and efficiency
+- *(private-key)* Remove debugging statement from storeInFileSystem method for cleaner code
+- *(github-webhook)* Restructure application processing by grouping applications by server for improved deployment handling
+- *(deployment)* Enhance queuing logic to support concurrent deployments by including pull request ID in checks
+- *(remoteProcess)* Remove debugging statement from transfer_file_to_container function for cleaner code
+- *(deployment)* Streamline next deployment queuing logic by repositioning queue_next_deployment call
+- *(deployment)* Add validation for pull request existence in deployment process to enhance error handling
+- *(database)* Remove volume_configuration_dir and streamline configuration directory usage in MongoDB and PostgreSQL handlers
+- *(application-source)* Improve layout and accessibility of Git repository links in the application source view
+- *(models)* Remove 'is_readonly' attribute from multiple database models for consistency
+- *(webhook)* Remove Webhook model and related logic; add migrations to drop webhooks and kubernetes tables
+- *(clone)* Consolidate application cloning logic into a dedicated function for improved maintainability and readability
+- *(clone)* Integrate preview cloning logic directly into application cloning function for improved clarity and maintainability
+- *(application)* Enhance environment variable retrieval in configuration change check for improved accuracy
+- *(clone)* Enhance application cloning by separating production and preview environment variable handling
+- *(deployment)* Add environment variable copying logic to Docker build commands for pull requests
+- *(environment)* Standardize service name formatting by replacing '-' and '.' with '_' in environment variable keys
+- *(deployment)* Update environment file handling in Docker commands to use '/artifacts/' path and streamline variable management
+- *(openapi)* Remove 'is_build_time' attribute from environment variable definitions to streamline configuration
+- *(environment)* Remove 'is_build_time' attribute from environment variable handling across the application to simplify configuration
+- *(environment)* Streamline environment variable handling by replacing sorting methods with direct property access and enhancing query ordering for improved performance
+- *(stripe-jobs)* Comment out internal notification calls and add subscription status verification before sending failure notifications
+- *(deployment)* Streamline environment variable handling for dockercompose and improve sorting of runtime variables
+- *(remoteProcess)* Remove command log comments for file transfers to simplify code
+- *(remoteProcess)* Remove file transfer handling from remote_process and instant_remote_process functions to simplify code
+- *(deployment)* Update environment file paths in docker compose commands to use working directory for improved consistency
+- *(server)* Remove debugging ray call from validateConnection method for cleaner code
+- *(deployment)* Conditionally cleanup build secrets based on Docker BuildKit support and remove redundant calls for improved efficiency
+- *(deployment)* Remove redundant environment variable documentation from Dockerfile comments to streamline the deployment process
+- *(deployment)* Streamline Docker BuildKit detection and environment variable handling for enhanced security during application deployment
+- *(deployment)* Optimize BuildKit capabilities detection and remove unnecessary comments for cleaner deployment logic
+- *(deployment)* Rename method for modifying Dockerfile to improve clarity and streamline build secrets integration
+
+### 📚 Documentation
+
+- Update changelog
+- *(testing-patterns)* Add important note to always run tests inside the `coolify` container for clarity
+
+### ⚙️ Miscellaneous Tasks
+
+- Update coolify version to 4.0.0-beta.427 and nightly version to 4.0.0-beta.428
+- Use main value then fallback to service_ values
+- Remove webhooks table cleanup
+- *(cleanup)* Remove deprecated ServerCheck and related job classes to streamline codebase
+- *(versions)* Update sentinel version from 0.0.15 to 0.0.16 in versions.json files
+- *(constants)* Update realtime_version from 1.0.10 to 1.0.11
+- *(versions)* Increment coolify version to 4.0.0-beta.428 and update realtime_version to 1.0.10
+- *(docker)* Add a blank line for improved readability in Dockerfile
+- *(versions)* Bump coolify version to 4.0.0-beta.429 and nightly version to 4.0.0-beta.430
+
+## [4.0.0-beta.426] - 2025-08-28
+
### 🚜 Refactor
- *(policy)* Simplify ServiceDatabasePolicy methods to always return true and add manageBackups method
diff --git a/CLAUDE.md b/CLAUDE.md
index 96f8eec78..22e762182 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -247,3 +247,412 @@ ### Project Information
- [Project Overview](.cursor/rules/project-overview.mdc) - High-level project structure
- [Technology Stack](.cursor/rules/technology-stack.mdc) - Detailed tech stack information
- [Cursor Rules Guide](.cursor/rules/cursor_rules.mdc) - How to maintain cursor rules
+
+===
+
+
+=== foundation rules ===
+
+# Laravel Boost Guidelines
+
+The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
+
+## Foundational Context
+This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
+
+- php - 8.4.7
+- laravel/fortify (FORTIFY) - v1
+- laravel/framework (LARAVEL) - v12
+- laravel/horizon (HORIZON) - v5
+- laravel/prompts (PROMPTS) - v0
+- laravel/sanctum (SANCTUM) - v4
+- laravel/socialite (SOCIALITE) - v5
+- livewire/livewire (LIVEWIRE) - v3
+- laravel/dusk (DUSK) - v8
+- laravel/pint (PINT) - v1
+- laravel/telescope (TELESCOPE) - v5
+- pestphp/pest (PEST) - v3
+- phpunit/phpunit (PHPUNIT) - v11
+- rector/rector (RECTOR) - v2
+- laravel-echo (ECHO) - v2
+- tailwindcss (TAILWINDCSS) - v4
+- vue (VUE) - v3
+
+
+## Conventions
+- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
+- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
+- Check for existing components to reuse before writing a new one.
+
+## Verification Scripts
+- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
+
+## Application Structure & Architecture
+- Stick to existing directory structure - don't create new base folders without approval.
+- Do not change the application's dependencies without approval.
+
+## Frontend Bundling
+- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
+
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+
+=== laravel/core rules ===
+
+## Do Things the Laravel Way
+
+- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
+- If you're creating a generic PHP class, use `artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Database
+- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
+- Use Eloquent models and relationships before suggesting raw database queries
+- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
+- Generate code that prevents N+1 query problems by using eager loading.
+- Use Laravel's query builder for very complex database operations.
+
+### Model Creation
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
+
+### APIs & Eloquent Resources
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+### Controllers & Validation
+- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
+- Check sibling Form Requests to see if the application uses array or string based validation rules.
+
+### Queues
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+### Authentication & Authorization
+- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+
+### URL Generation
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+### Configuration
+- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+
+### Testing
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+### Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
+
+
+=== laravel/v12 rules ===
+
+## Laravel 12
+
+- Use the `search-docs` tool to get version specific documentation.
+- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
+- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that.
+
+### Laravel 10 Structure
+- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
+- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
+ - Middleware registration happens in `app/Http/Kernel.php`
+ - Exception handling is in `app/Exceptions/Handler.php`
+ - Console commands and schedule register in `app/Console/Kernel.php`
+ - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
+
+### Database
+- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
+- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
+
+### Models
+- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
+
+
+=== livewire/core rules ===
+
+## Livewire Core
+- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
+- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components
+- State should live on the server, with the UI reflecting it.
+- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
+
+## Livewire Best Practices
+- Livewire components require a single root element.
+- Use `wire:loading` and `wire:dirty` for delightful loading states.
+- Add `wire:key` in loops:
+
+ ```blade
+ @foreach ($items as $item)
+
+ {{ $item->name }}
+
+ @endforeach
+ ```
+
+- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
+
+
+ public function mount(User $user) { $this->user = $user; }
+ public function updatedSearch() { $this->resetPage(); }
+
+
+
+## Testing Livewire
+
+
+ Livewire::test(Counter::class)
+ ->assertSet('count', 0)
+ ->call('increment')
+ ->assertSet('count', 1)
+ ->assertSee(1)
+ ->assertStatus(200);
+
+
+
+
+ $this->get('/posts/create')
+ ->assertSeeLivewire(CreatePost::class);
+
+
+
+=== livewire/v3 rules ===
+
+## Livewire 3
+
+### Key Changes From Livewire 2
+- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
+ - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
+ - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
+ - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
+ - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
+
+### New Directives
+- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
+
+### Alpine
+- Alpine is now included with Livewire, don't manually include Alpine.js.
+- Plugins included with Alpine: persist, intersect, collapse, and focus.
+
+### Lifecycle Hooks
+- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
+
+
+document.addEventListener('livewire:init', function () {
+ Livewire.hook('request', ({ fail }) => {
+ if (fail && fail.status === 419) {
+ alert('Your session expired');
+ }
+ });
+
+ Livewire.hook('message.failed', (message, component) => {
+ console.error(message);
+ });
+});
+
+
+
+=== pint/core rules ===
+
+## Laravel Pint Code Formatter
+
+- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
+- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
+
+
+=== pest/core rules ===
+
+## Pest
+
+### Testing
+- If you need to verify a feature is working, write or update a Unit / Feature test.
+
+### Pest Tests
+- All tests must be written using Pest. Use `php artisan make:test --pest `.
+- 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.
+- Pest tests look and behave like this:
+
+it('is true', function () {
+ expect(true)->toBeTrue();
+});
+
+
+### 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.
+
+### 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.:
+
+it('returns all', function () {
+ $response = $this->postJson('/api/docs', []);
+
+ $response->assertSuccessful();
+});
+
+
+### Mocking
+- Mocking can be very helpful when appropriate.
+- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
+- You can also create partial mocks using the same import or self method.
+
+### Datasets
+- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
+
+
+it('has emails', function (string $email) {
+ expect($email)->not->toBeEmpty();
+})->with([
+ 'james' => 'james@laravel.com',
+ 'taylor' => 'taylor@laravel.com',
+]);
+
+
+
+=== tailwindcss/core rules ===
+
+## Tailwind Core
+
+- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
+- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
+- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
+- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
+
+### Spacing
+- When listing items, use gap utilities for spacing, don't use margins.
+
+
+
+
Superior
+
Michigan
+
Erie
+
+
+
+
+### Dark Mode
+- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
+
+
+=== tailwindcss/v4 rules ===
+
+## Tailwind 4
+
+- Always use Tailwind CSS v4 - do not use the deprecated utilities.
+- `corePlugins` is not supported in Tailwind v4.
+- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
+
+
+
+
+### Replaced Utilities
+- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
+- Opacity values are still numeric.
+
+| Deprecated | Replacement |
+|------------+--------------|
+| bg-opacity-* | bg-black/* |
+| text-opacity-* | text-black/* |
+| border-opacity-* | border-black/* |
+| divide-opacity-* | divide-black/* |
+| ring-opacity-* | ring-black/* |
+| placeholder-opacity-* | placeholder-black/* |
+| flex-shrink-* | shrink-* |
+| flex-grow-* | grow-* |
+| overflow-ellipsis | text-ellipsis |
+| decoration-slice | box-decoration-slice |
+| decoration-clone | box-decoration-clone |
+
+
+=== tests rules ===
+
+## Test Enforcement
+
+- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
+- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
+
+
+
+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/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php
index 7be727f55..f218fcabb 100644
--- a/app/Actions/Database/StartClickhouse.php
+++ b/app/Actions/Database/StartClickhouse.php
@@ -99,12 +99,8 @@ public function handle(StandaloneClickhouse $database)
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php
index d90eebc17..12fd92792 100644
--- a/app/Actions/Database/StartDatabaseProxy.php
+++ b/app/Actions/Database/StartDatabaseProxy.php
@@ -52,9 +52,8 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
}
$configuration_dir = database_proxy_dir($database->uuid);
- $volume_configuration_dir = $configuration_dir;
if (isDev()) {
- $volume_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy';
+ $configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy';
}
$nginxconf = << [
[
'type' => 'bind',
- 'source' => "$volume_configuration_dir/nginx.conf",
+ 'source' => "$configuration_dir/nginx.conf",
'target' => '/etc/nginx/nginx.conf',
],
],
@@ -116,18 +115,8 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
instant_remote_process([
"mkdir -p $configuration_dir",
- [
- 'transfer_file' => [
- 'content' => base64_decode($nginxconf_base64),
- 'destination' => "$configuration_dir/nginx.conf",
- ],
- ],
- [
- 'transfer_file' => [
- 'content' => base64_decode($dockercompose_base64),
- 'destination' => "$configuration_dir/docker-compose.yaml",
- ],
- ],
+ "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
+ "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
"docker compose --project-directory {$configuration_dir} pull",
"docker compose --project-directory {$configuration_dir} up -d",
], $server);
diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php
index 579c6841d..38ad99d2e 100644
--- a/app/Actions/Database/StartDragonfly.php
+++ b/app/Actions/Database/StartDragonfly.php
@@ -183,12 +183,8 @@ public function handle(StandaloneDragonfly $database)
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php
index e1d4e43c1..59bcd4123 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -199,12 +199,8 @@ public function handle(StandaloneKeydb $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php
index 3f7d22245..13dba4b43 100644
--- a/app/Actions/Database/StartMariadb.php
+++ b/app/Actions/Database/StartMariadb.php
@@ -203,12 +203,8 @@ public function handle(StandaloneMariadb $database)
}
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
@@ -288,11 +284,7 @@ private function add_custom_mysql()
}
$filename = 'custom-config.cnf';
$content = $this->database->mariadb_conf;
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $content,
- 'destination' => "$this->configuration_dir/{$filename}",
- ],
- ];
+ $content_base64 = base64_encode($content);
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
}
diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php
index 7135f1c70..870b5b7e5 100644
--- a/app/Actions/Database/StartMongodb.php
+++ b/app/Actions/Database/StartMongodb.php
@@ -28,6 +28,9 @@ public function handle(StandaloneMongodb $database)
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
+ if (isDev()) {
+ $this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name;
+ }
$this->commands = [
"echo 'Starting database.'",
@@ -251,12 +254,8 @@ public function handle(StandaloneMongodb $database)
}
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
@@ -333,22 +332,15 @@ private function add_custom_mongo_conf()
}
$filename = 'mongod.conf';
$content = $this->database->mongo_conf;
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $content,
- 'destination' => "$this->configuration_dir/{$filename}",
- ],
- ];
+ $content_base64 = base64_encode($content);
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
private function add_default_database()
{
$content = "db = db.getSiblingDB(\"{$this->database->mongo_initdb_database}\");db.createCollection('init_collection');db.createUser({user: \"{$this->database->mongo_initdb_root_username}\", pwd: \"{$this->database->mongo_initdb_root_password}\",roles: [{role:\"readWrite\",db:\"{$this->database->mongo_initdb_database}\"}]});";
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $content,
- 'destination' => "$this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js",
- ],
- ];
+ $content_base64 = base64_encode($content);
+ $this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null";
}
}
diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php
index 5f453f80a..5d5611e07 100644
--- a/app/Actions/Database/StartMysql.php
+++ b/app/Actions/Database/StartMysql.php
@@ -204,12 +204,8 @@ public function handle(StandaloneMysql $database)
}
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
@@ -291,11 +287,7 @@ private function add_custom_mysql()
}
$filename = 'custom-config.cnf';
$content = $this->database->mysql_conf;
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $content,
- 'destination' => "$this->configuration_dir/{$filename}",
- ],
- ];
+ $content_base64 = base64_encode($content);
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
}
diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php
index 75ca8ef10..38d46b3c1 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -27,6 +27,9 @@ public function handle(StandalonePostgresql $database)
$this->database = $database;
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
+ if (isDev()) {
+ $this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name;
+ }
$this->commands = [
"echo 'Starting database.'",
@@ -214,12 +217,8 @@ public function handle(StandalonePostgresql $database)
}
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
@@ -305,12 +304,8 @@ private function generate_init_scripts()
foreach ($this->database->init_scripts as $init_script) {
$filename = data_get($init_script, 'filename');
$content = data_get($init_script, 'content');
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $content,
- 'destination' => "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}",
- ],
- ];
+ $content_base64 = base64_encode($content);
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/{$filename} > /dev/null";
$this->init_scripts[] = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
}
}
@@ -332,11 +327,7 @@ private function add_custom_conf()
$this->database->postgres_conf = $content;
$this->database->save();
}
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $content,
- 'destination' => $config_file_path,
- ],
- ];
+ $content_base64 = base64_encode($content);
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $config_file_path > /dev/null";
}
}
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index b5962b165..68a1f3fe3 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -196,12 +196,8 @@ public function handle(StandaloneRedis $database)
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10);
- $this->commands[] = [
- 'transfer_file' => [
- 'content' => $docker_compose,
- 'destination' => "$this->configuration_dir/docker-compose.yml",
- ],
- ];
+ $docker_compose_base64 = base64_encode($docker_compose);
+ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php
index ad7c4a606..f5d5f82b6 100644
--- a/app/Actions/Docker/GetContainersStatus.php
+++ b/app/Actions/Docker/GetContainersStatus.php
@@ -96,7 +96,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
}
$containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
- $containerStatus = "$containerStatus ($containerHealth)";
+ if ($containerStatus === 'restarting') {
+ $containerStatus = "restarting ($containerHealth)";
+ } else {
+ $containerStatus = "$containerStatus ($containerHealth)";
+ }
$labels = Arr::undot(format_docker_labels_to_json($labels));
$applicationId = data_get($labels, 'coolify.applicationId');
if ($applicationId) {
@@ -386,19 +390,33 @@ private function aggregateApplicationStatus($application, Collection $containerS
return null;
}
- // Aggregate status: if any container is running, app is running
$hasRunning = false;
+ $hasRestarting = false;
$hasUnhealthy = false;
+ $hasExited = false;
foreach ($relevantStatuses as $status) {
- if (str($status)->contains('running')) {
+ if (str($status)->contains('restarting')) {
+ $hasRestarting = true;
+ } elseif (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
}
+ } elseif (str($status)->contains('exited')) {
+ $hasExited = true;
+ $hasUnhealthy = true;
}
}
+ if ($hasRestarting) {
+ return 'degraded (unhealthy)';
+ }
+
+ if ($hasRunning && $hasExited) {
+ return 'degraded (unhealthy)';
+ }
+
if ($hasRunning) {
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
}
diff --git a/app/Actions/Proxy/SaveProxyConfiguration.php b/app/Actions/Proxy/SaveProxyConfiguration.php
index 38c9c8def..53fbecce2 100644
--- a/app/Actions/Proxy/SaveProxyConfiguration.php
+++ b/app/Actions/Proxy/SaveProxyConfiguration.php
@@ -21,12 +21,7 @@ public function handle(Server $server, string $configuration): void
// Transfer the configuration file to the server
instant_remote_process([
"mkdir -p $proxy_path",
- [
- 'transfer_file' => [
- 'content' => base64_decode($docker_compose_yml_base64),
- 'destination' => "$proxy_path/docker-compose.yml",
- ],
- ],
+ "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
], $server);
}
}
diff --git a/app/Actions/Server/ConfigureCloudflared.php b/app/Actions/Server/ConfigureCloudflared.php
index e66e7eecb..d21622bc5 100644
--- a/app/Actions/Server/ConfigureCloudflared.php
+++ b/app/Actions/Server/ConfigureCloudflared.php
@@ -40,12 +40,7 @@ public function handle(Server $server, string $cloudflare_token, string $ssh_dom
$commands = collect([
'mkdir -p /tmp/cloudflared',
'cd /tmp/cloudflared',
- [
- 'transfer_file' => [
- 'content' => base64_decode($docker_compose_yml_base64),
- 'destination' => '/tmp/cloudflared/docker-compose.yml',
- ],
- ],
+ "echo '$docker_compose_yml_base64' | base64 -d | tee docker-compose.yml > /dev/null",
'echo Pulling latest Cloudflare Tunnel image.',
'docker compose pull',
'echo Stopping existing Cloudflare Tunnel container.',
diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php
index 33c22b484..5410b1cbd 100644
--- a/app/Actions/Server/InstallDocker.php
+++ b/app/Actions/Server/InstallDocker.php
@@ -14,7 +14,6 @@ class InstallDocker
public function handle(Server $server)
{
- ray('install docker');
$dockerVersion = config('constants.docker.minimum_required_version');
$supported_os_type = $server->validateOS();
if (! $supported_os_type) {
@@ -104,15 +103,8 @@ public function handle(Server $server)
"curl https://releases.rancher.com/install-docker/{$dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$dockerVersion}",
"echo 'Configuring Docker Engine (merging existing configuration with the required)...'",
'test -s /etc/docker/daemon.json && cp /etc/docker/daemon.json "/etc/docker/daemon.json.original-$(date +"%Y%m%d-%H%M%S")"',
- [
- 'transfer_file' => [
- 'content' => base64_decode($config),
- 'destination' => '/tmp/daemon.json.new',
- ],
- ],
- 'test ! -s /etc/docker/daemon.json && cp /tmp/daemon.json.new /etc/docker/daemon.json',
- 'cp /tmp/daemon.json.new /etc/docker/daemon.json.coolify',
- 'rm -f /tmp/daemon.json.new',
+ "test ! -s /etc/docker/daemon.json && echo '{$config}' | base64 -d | tee /etc/docker/daemon.json > /dev/null",
+ "echo '{$config}' | base64 -d | tee /etc/docker/daemon.json.coolify > /dev/null",
'jq . /etc/docker/daemon.json.coolify | tee /etc/docker/daemon.json.coolify.pretty > /dev/null',
'mv /etc/docker/daemon.json.coolify.pretty /etc/docker/daemon.json.coolify',
"jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null",
diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php
index 3e1dad1c2..f72f23696 100644
--- a/app/Actions/Server/StartLogDrain.php
+++ b/app/Actions/Server/StartLogDrain.php
@@ -180,30 +180,10 @@ public function handle(Server $server)
$command = [
"echo 'Saving configuration'",
"mkdir -p $config_path",
- [
- 'transfer_file' => [
- 'content' => base64_decode($parsers),
- 'destination' => $parsers_config,
- ],
- ],
- [
- 'transfer_file' => [
- 'content' => base64_decode($config),
- 'destination' => $fluent_bit_config,
- ],
- ],
- [
- 'transfer_file' => [
- 'content' => base64_decode($compose),
- 'destination' => $compose_path,
- ],
- ],
- [
- 'transfer_file' => [
- 'content' => base64_decode($readme),
- 'destination' => $readme_path,
- ],
- ],
+ "echo '{$parsers}' | base64 -d | tee $parsers_config > /dev/null",
+ "echo '{$config}' | base64 -d | tee $fluent_bit_config > /dev/null",
+ "echo '{$compose}' | base64 -d | tee $compose_path > /dev/null",
+ "echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
"test -f $config_path/.env && rm $config_path/.env",
];
if ($type === 'newrelic') {
diff --git a/app/Actions/Shared/ComplexStatusCheck.php b/app/Actions/Shared/ComplexStatusCheck.php
index 5a7ba6637..e06136e3c 100644
--- a/app/Actions/Shared/ComplexStatusCheck.php
+++ b/app/Actions/Shared/ComplexStatusCheck.php
@@ -26,22 +26,22 @@ public function handle(Application $application)
continue;
}
}
- $container = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
- $container = format_docker_command_output_to_json($container);
- if ($container->count() === 1) {
- $container = $container->first();
- $containerStatus = data_get($container, 'State.Status');
- $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
+ $containers = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
+ $containers = format_docker_command_output_to_json($containers);
+
+ if ($containers->count() > 0) {
+ $statusToSet = $this->aggregateContainerStatuses($application, $containers);
+
if ($is_main_server) {
$statusFromDb = $application->status;
- if ($statusFromDb !== $containerStatus) {
- $application->update(['status' => "$containerStatus:$containerHealth"]);
+ if ($statusFromDb !== $statusToSet) {
+ $application->update(['status' => $statusToSet]);
}
} else {
$additional_server = $application->additional_servers()->wherePivot('server_id', $server->id);
$statusFromDb = $additional_server->first()->pivot->status;
- if ($statusFromDb !== $containerStatus) {
- $additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]);
+ if ($statusFromDb !== $statusToSet) {
+ $additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]);
}
}
} else {
@@ -57,4 +57,78 @@ public function handle(Application $application)
}
}
}
+
+ private function aggregateContainerStatuses($application, $containers)
+ {
+ $dockerComposeRaw = data_get($application, 'docker_compose_raw');
+ $excludedContainers = collect();
+
+ if ($dockerComposeRaw) {
+ try {
+ $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
+ $services = data_get($dockerCompose, 'services', []);
+
+ foreach ($services as $serviceName => $serviceConfig) {
+ $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
+ $restartPolicy = data_get($serviceConfig, 'restart', 'always');
+
+ if ($excludeFromHc || $restartPolicy === 'no') {
+ $excludedContainers->push($serviceName);
+ }
+ }
+ } catch (\Exception $e) {
+ // If we can't parse, treat all containers as included
+ }
+ }
+
+ $hasRunning = false;
+ $hasRestarting = false;
+ $hasUnhealthy = false;
+ $hasExited = false;
+ $relevantContainerCount = 0;
+
+ foreach ($containers as $container) {
+ $labels = data_get($container, 'Config.Labels', []);
+ $serviceName = data_get($labels, 'com.docker.compose.service');
+
+ if ($serviceName && $excludedContainers->contains($serviceName)) {
+ continue;
+ }
+
+ $relevantContainerCount++;
+ $containerStatus = data_get($container, 'State.Status');
+ $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
+
+ if ($containerStatus === 'restarting') {
+ $hasRestarting = true;
+ $hasUnhealthy = true;
+ } elseif ($containerStatus === 'running') {
+ $hasRunning = true;
+ if ($containerHealth === 'unhealthy') {
+ $hasUnhealthy = true;
+ }
+ } elseif ($containerStatus === 'exited') {
+ $hasExited = true;
+ $hasUnhealthy = true;
+ }
+ }
+
+ if ($relevantContainerCount === 0) {
+ return 'running:healthy';
+ }
+
+ if ($hasRestarting) {
+ return 'degraded:unhealthy';
+ }
+
+ if ($hasRunning && $hasExited) {
+ return 'degraded:unhealthy';
+ }
+
+ if ($hasRunning) {
+ return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy';
+ }
+
+ return 'exited:unhealthy';
+ }
}
diff --git a/app/Console/Commands/CloudDeleteUser.php b/app/Console/Commands/Cloud/CloudDeleteUser.php
similarity index 99%
rename from app/Console/Commands/CloudDeleteUser.php
rename to app/Console/Commands/Cloud/CloudDeleteUser.php
index 6928eb97b..29580a95e 100644
--- a/app/Console/Commands/CloudDeleteUser.php
+++ b/app/Console/Commands/Cloud/CloudDeleteUser.php
@@ -1,6 +1,6 @@
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 b9c854ea1..ce9e723d4 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -2532,8 +2532,11 @@ public function update_env_by_uuid(Request $request)
if ($env->is_shown_once != $request->is_shown_once) {
$env->is_shown_once = $request->is_shown_once;
}
- if ($request->has('is_buildtime_only') && $env->is_buildtime_only != $request->is_buildtime_only) {
- $env->is_buildtime_only = $request->is_buildtime_only;
+ if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) {
+ $env->is_runtime = $request->is_runtime;
+ }
+ if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
+ $env->is_buildtime = $request->is_buildtime;
}
$env->save();
@@ -2559,8 +2562,11 @@ public function update_env_by_uuid(Request $request)
if ($env->is_shown_once != $request->is_shown_once) {
$env->is_shown_once = $request->is_shown_once;
}
- if ($request->has('is_buildtime_only') && $env->is_buildtime_only != $request->is_buildtime_only) {
- $env->is_buildtime_only = $request->is_buildtime_only;
+ if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) {
+ $env->is_runtime = $request->is_runtime;
+ }
+ if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
+ $env->is_buildtime = $request->is_buildtime;
}
$env->save();
@@ -2723,8 +2729,11 @@ public function create_bulk_envs(Request $request)
if ($env->is_shown_once != $item->get('is_shown_once')) {
$env->is_shown_once = $item->get('is_shown_once');
}
- if ($item->has('is_buildtime_only') && $env->is_buildtime_only != $item->get('is_buildtime_only')) {
- $env->is_buildtime_only = $item->get('is_buildtime_only');
+ if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) {
+ $env->is_runtime = $item->get('is_runtime');
+ }
+ if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
+ $env->is_buildtime = $item->get('is_buildtime');
}
$env->save();
} else {
@@ -2735,7 +2744,8 @@ public function create_bulk_envs(Request $request)
'is_literal' => $is_literal,
'is_multiline' => $is_multi_line,
'is_shown_once' => $is_shown_once,
- 'is_buildtime_only' => $item->get('is_buildtime_only', false),
+ 'is_runtime' => $item->get('is_runtime', true),
+ 'is_buildtime' => $item->get('is_buildtime', true),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -2753,8 +2763,11 @@ public function create_bulk_envs(Request $request)
if ($env->is_shown_once != $item->get('is_shown_once')) {
$env->is_shown_once = $item->get('is_shown_once');
}
- if ($item->has('is_buildtime_only') && $env->is_buildtime_only != $item->get('is_buildtime_only')) {
- $env->is_buildtime_only = $item->get('is_buildtime_only');
+ if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) {
+ $env->is_runtime = $item->get('is_runtime');
+ }
+ if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
+ $env->is_buildtime = $item->get('is_buildtime');
}
$env->save();
} else {
@@ -2765,7 +2778,8 @@ public function create_bulk_envs(Request $request)
'is_literal' => $is_literal,
'is_multiline' => $is_multi_line,
'is_shown_once' => $is_shown_once,
- 'is_buildtime_only' => $item->get('is_buildtime_only', false),
+ 'is_runtime' => $item->get('is_runtime', true),
+ 'is_buildtime' => $item->get('is_buildtime', true),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -2904,7 +2918,8 @@ public function create_env(Request $request)
'is_literal' => $request->is_literal ?? false,
'is_multiline' => $request->is_multiline ?? false,
'is_shown_once' => $request->is_shown_once ?? false,
- 'is_buildtime_only' => $request->is_buildtime_only ?? false,
+ 'is_runtime' => $request->is_runtime ?? true,
+ 'is_buildtime' => $request->is_buildtime ?? true,
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -2927,7 +2942,8 @@ public function create_env(Request $request)
'is_literal' => $request->is_literal ?? false,
'is_multiline' => $request->is_multiline ?? false,
'is_shown_once' => $request->is_shown_once ?? false,
- 'is_buildtime_only' => $request->is_buildtime_only ?? false,
+ 'is_runtime' => $request->is_runtime ?? true,
+ 'is_buildtime' => $request->is_buildtime ?? true,
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -3364,11 +3380,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..0e282fccd 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\Schema(
+ 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..8c95a585f
--- /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\Items(
+ type: 'array',
+ items: new OA\Schema(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\Items(
+ type: 'array',
+ items: new OA\Schema(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 81628a629..e10422848 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;
@@ -38,7 +40,7 @@
class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{
- use Dispatchable, ExecuteRemoteCommand, InteractsWithQueue, Queueable, SerializesModels;
+ use Dispatchable, EnvironmentVariableAnalyzer, ExecuteRemoteCommand, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
@@ -147,6 +149,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';
@@ -167,6 +171,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $preserveRepository = false;
+ private bool $dockerBuildkitSupported = false;
+
+ private bool $skip_build = false;
+
+ private Collection|string $build_secrets;
+
public function tags()
{
// Do not remove this one, it needs to properly identify which worker is running the job
@@ -183,6 +193,7 @@ public function __construct(public int $application_deployment_queue_id)
$this->application = Application::find($this->application_deployment_queue->application_id);
$this->build_pack = data_get($this->application, 'build_pack');
$this->build_args = collect([]);
+ $this->build_secrets = '';
$this->deployment_uuid = $this->application_deployment_queue->deployment_uuid;
$this->pull_request_id = $this->application_deployment_queue->pull_request_id;
@@ -250,6 +261,14 @@ public function __construct(public int $application_deployment_queue_id)
public function handle(): void
{
+ // Check if deployment was cancelled before we even started
+ $this->application_deployment_queue->refresh();
+ if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
+ $this->application_deployment_queue->addLogEntry('Deployment was cancelled before starting.');
+
+ return;
+ }
+
$this->application_deployment_queue->update([
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'horizon_job_worker' => gethostname(),
@@ -263,7 +282,6 @@ public function handle(): void
try {
// Make sure the private key is stored in the filesystem
$this->server->privateKey->storeInFileSystem();
-
// Generate custom host<->ip mapping
$allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server);
@@ -319,6 +337,7 @@ public function handle(): void
$this->build_server = $this->server;
$this->original_server = $this->server;
}
+ $this->detectBuildKitCapabilities();
$this->decide_what_to_do();
} catch (Exception $e) {
if ($this->pull_request_id !== 0 && $this->application->is_github_based()) {
@@ -336,6 +355,7 @@ public function handle(): void
} else {
$this->write_deployment_configurations();
}
+
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
$this->graceful_shutdown_container($this->deployment_uuid);
@@ -343,6 +363,80 @@ public function handle(): void
}
}
+ private function detectBuildKitCapabilities(): void
+ {
+ // If build secrets are not enabled, skip detection and use traditional args
+ if (! $this->application->settings->use_build_secrets) {
+ $this->dockerBuildkitSupported = false;
+
+ return;
+ }
+
+ $serverToCheck = $this->use_build_server ? $this->build_server : $this->server;
+ $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})";
+
+ try {
+ $dockerVersion = instant_remote_process(
+ ["docker version --format '{{.Server.Version}}'"],
+ $serverToCheck
+ );
+
+ $versionParts = explode('.', $dockerVersion);
+ $majorVersion = (int) $versionParts[0];
+ $minorVersion = (int) ($versionParts[1] ?? 0);
+
+ if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
+ $this->dockerBuildkitSupported = false;
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Build secrets feature disabled.");
+
+ return;
+ }
+
+ $buildkitEnabled = instant_remote_process(
+ ["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"],
+ $serverToCheck
+ );
+
+ if (trim($buildkitEnabled) !== 'available') {
+ $buildkitTest = instant_remote_process(
+ ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
+ $serverToCheck
+ );
+
+ if (trim($buildkitTest) === 'supported') {
+ $this->dockerBuildkitSupported = true;
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}.");
+ $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
+ } else {
+ $this->dockerBuildkitSupported = false;
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support.");
+ $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
+ }
+ } else {
+ // Buildx is available, which means BuildKit is available
+ // Now specifically test for secrets support
+ $secretsTest = instant_remote_process(
+ ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
+ $serverToCheck
+ );
+
+ if (trim($secretsTest) === 'supported') {
+ $this->dockerBuildkitSupported = true;
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
+ $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
+ } else {
+ $this->dockerBuildkitSupported = false;
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported.");
+ $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
+ }
+ }
+ } catch (\Exception $e) {
+ $this->dockerBuildkitSupported = false;
+ $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
+ $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but detection failed. Using traditional build arguments.');
+ }
+ }
+
private function decide_what_to_do()
{
if ($this->restart_only) {
@@ -388,8 +482,11 @@ private function deploy_simple_dockerfile()
$dockerfile_base64 = base64_encode($this->application->dockerfile);
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->application->name} to {$this->server->name}.");
$this->prepare_builder_image();
- $dockerfile_content = base64_decode($dockerfile_base64);
- transfer_file_to_container($dockerfile_content, "{$this->workdir}{$this->dockerfile_location}", $this->deployment_uuid, $this->server);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '$dockerfile_base64' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
+ ],
+ );
$this->generate_image_names();
$this->generate_compose_file();
$this->generate_build_env_variables();
@@ -468,18 +565,27 @@ private function deploy_docker_compose_buildpack()
}
$this->generate_image_names();
$this->cleanup_git();
+
+ $this->generate_build_env_variables();
+
$this->application->loadComposeFile(isInit: false);
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$this->application->oldRawParser();
$yaml = $composeFile = $this->application->docker_compose_raw;
- $this->save_environment_variables();
+ $this->generate_runtime_environment_variables();
+
+ // For raw compose, we cannot automatically add secrets configuration
+ // User must define it manually in their docker-compose file
+ if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
+ $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.');
+ }
} else {
$composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'));
- $this->save_environment_variables();
+ $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'] = ["/artifacts/{$this->env_filename}"];
+ $service['env_file'] = [$this->env_filename];
return $service;
});
@@ -491,27 +597,57 @@ private function deploy_docker_compose_buildpack()
return;
}
+
+ // Add build secrets to compose file if enabled and BuildKit is supported
+ if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
+ $composeFile = $this->add_build_secrets_to_compose($composeFile);
+ }
+
$yaml = Yaml::dump(convertToArray($composeFile), 10);
}
$this->docker_compose_base64 = base64_encode($yaml);
- transfer_file_to_container($yaml, "{$this->workdir}{$this->docker_compose_location}", $this->deployment_uuid, $this->server);
+ $this->execute_remote_command([
+ 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.');
if ($this->docker_compose_custom_build_command) {
+ // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
+ $build_command = $this->docker_compose_custom_build_command;
+ if ($this->dockerBuildkitSupported) {
+ $build_command = "DOCKER_BUILDKIT=1 {$build_command}";
+ }
$this->execute_remote_command(
- [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$this->docker_compose_custom_build_command}"), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
);
} else {
$command = "{$this->coolify_variables} docker compose";
+ // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
+ if ($this->dockerBuildkitSupported) {
+ $command = "DOCKER_BUILDKIT=1 {$command}";
+ }
if (filled($this->env_filename)) {
- $command .= " --env-file /artifacts/{$this->env_filename}";
+ $command .= " --env-file {$this->workdir}/{$this->env_filename}";
}
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],
);
@@ -551,7 +687,7 @@ private function deploy_docker_compose_buildpack()
$command = "{$this->coolify_variables} docker compose";
if (filled($this->env_filename)) {
- $command .= " --env-file /artifacts/{$this->env_filename}";
+ $command .= " --env-file {$server_workdir}/{$this->env_filename}";
}
$command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command(
@@ -568,7 +704,7 @@ private function deploy_docker_compose_buildpack()
$command = "{$this->coolify_variables} docker compose";
if ($this->preserveRepository) {
if (filled($this->env_filename)) {
- $command .= " --env-file /artifacts/{$this->env_filename}";
+ $command .= " --env-file {$server_workdir}/{$this->env_filename}";
}
$command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
$this->write_deployment_configurations();
@@ -578,7 +714,7 @@ private function deploy_docker_compose_buildpack()
);
} else {
if (filled($this->env_filename)) {
- $command .= " --env-file /artifacts/{$this->env_filename}";
+ $command .= " --env-file {$this->workdir}/{$this->env_filename}";
}
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command(
@@ -641,6 +777,10 @@ private function deploy_nixpacks_buildpack()
$this->generate_compose_file();
$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->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
}
@@ -663,7 +803,7 @@ private function deploy_static_buildpack()
$this->clone_repository();
$this->cleanup_git();
$this->generate_compose_file();
- $this->build_image();
+ $this->build_static_image();
$this->push_to_docker_registry();
$this->rolling_update();
}
@@ -709,12 +849,13 @@ private function write_deployment_configurations()
$composeFileName = "$mainDir/".addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml';
$this->docker_compose_location = '/'.addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml';
}
- $this->execute_remote_command([
- "mkdir -p $mainDir",
- ]);
- $docker_compose_content = base64_decode($this->docker_compose_base64);
- transfer_file_to_server($docker_compose_content, $composeFileName, $this->server);
$this->execute_remote_command(
+ [
+ "mkdir -p $mainDir",
+ ],
+ [
+ "echo '{$this->docker_compose_base64}' | base64 -d | tee $composeFileName > /dev/null",
+ ],
[
"echo '{$readme}' > $mainDir/README.md",
]
@@ -833,18 +974,17 @@ private function should_skip_build()
{
if (str($this->saved_outputs->get('local_image_found'))->isNotEmpty()) {
if ($this->is_this_additional_server) {
+ $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();
$this->push_to_docker_registry();
$this->rolling_update();
- if ($this->restart_only) {
- $this->post_deployment();
- }
return true;
}
if (! $this->application->isConfigurationChanged()) {
$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();
$this->push_to_docker_registry();
$this->rolling_update();
@@ -885,7 +1025,7 @@ private function check_image_locally_or_remotely()
}
}
- private function save_environment_variables()
+ private function generate_runtime_environment_variables()
{
$envs = collect([]);
$sort = $this->application->settings->is_env_sorting_enabled;
@@ -911,24 +1051,8 @@ private function save_environment_variables()
});
if ($this->pull_request_id === 0) {
$this->env_filename = '.env';
- // Filter out buildtime-only variables from runtime environment
- $runtime_environment_variables = $sorted_environment_variables->filter(function ($env) {
- return ! $env->is_buildtime_only;
- });
- foreach ($runtime_environment_variables as $env) {
- $envs->push($env->key.'='.$env->real_value);
- }
- // Add PORT if not exists, use the first port as default
- if ($this->build_pack !== 'dockercompose') {
- if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
- $envs->push("PORT={$ports[0]}");
- }
- }
- // Add HOST if not exists
- if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) {
- $envs->push('HOST=0.0.0.0');
- }
+ // Generate SERVICE_ variables first for dockercompose
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
@@ -957,26 +1081,38 @@ private function save_environment_variables()
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName);
}
}
- } else {
- $this->env_filename = '.env';
- // Filter out buildtime-only variables from runtime environment for preview
- $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
- return ! $env->is_buildtime_only;
+
+ // Filter runtime variables (only include variables that are available at runtime)
+ $runtime_environment_variables = $sorted_environment_variables->filter(function ($env) {
+ return $env->is_runtime;
});
- foreach ($runtime_environment_variables_preview as $env) {
+
+ // Sort runtime environment variables: those referencing SERVICE_ variables come after others
+ $runtime_environment_variables = $runtime_environment_variables->sortBy(function ($env) {
+ if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) {
+ return 2;
+ }
+
+ return 1;
+ });
+
+ foreach ($runtime_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
}
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
- if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) {
+ if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
$envs->push("PORT={$ports[0]}");
}
}
// Add HOST if not exists
- if ($this->application->environment_variables_preview->where('key', 'HOST')->isEmpty()) {
+ if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) {
$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([]);
@@ -1001,6 +1137,34 @@ private function save_environment_variables()
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
}
}
+
+ // Filter runtime variables for preview (only include variables that are available at runtime)
+ $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
+ return $env->is_runtime;
+ });
+
+ // Sort runtime environment variables: those referencing SERVICE_ variables come after others
+ $runtime_environment_variables_preview = $runtime_environment_variables_preview->sortBy(function ($env) {
+ if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) {
+ return 2;
+ }
+
+ return 1;
+ });
+
+ foreach ($runtime_environment_variables_preview as $env) {
+ $envs->push($env->key.'='.$env->real_value);
+ }
+ // Add PORT if not exists, use the first port as default
+ if ($this->build_pack !== 'dockercompose') {
+ if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) {
+ $envs->push("PORT={$ports[0]}");
+ }
+ }
+ // Add HOST if not exists
+ if ($this->application->environment_variables_preview->where('key', 'HOST')->isEmpty()) {
+ $envs->push('HOST=0.0.0.0');
+ }
}
if ($envs->isEmpty()) {
if ($this->env_filename) {
@@ -1033,22 +1197,71 @@ private function save_environment_variables()
}
$this->env_filename = null;
} else {
- $envs_content = $envs->implode("\n");
- transfer_file_to_container($envs_content, "/artifacts/{$this->env_filename}", $this->deployment_uuid, $this->server);
+ // 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"),
+ ],
- // Save the env filename with preview deployment suffix
- $env_filename = addPreviewDeploymentSuffix($this->env_filename, $this->pull_request_id);
- if ($this->use_build_server) {
- $this->server = $this->original_server;
- transfer_file_to_server($envs_content, "$this->configuration_dir/{$env_filename}", $this->server);
- $this->server = $this->build_server;
- } else {
- transfer_file_to_server($envs_content, "$this->configuration_dir/{$env_filename}", $this->server);
+ );
+ 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;
}
+ 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
+
+ if ($this->environment_variables && $this->environment_variables->isNotEmpty() && $this->env_filename) {
+ $envs_base64 = base64_encode($this->environment_variables->implode("\n"));
+
+ // Write .env file to workdir (for container runtime)
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"),
+ ],
+ );
+
+ // 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",
+ ]
+ );
+ }
+ }
+ }
+
private function elixir_finetunes()
{
if ($this->pull_request_id === 0) {
@@ -1105,6 +1318,7 @@ private function laravel_finetunes()
private function rolling_update()
{
+ $this->checkForCancellation();
if ($this->server->isSwarm()) {
$this->application_deployment_queue->addLogEntry('Rolling update started.');
$this->execute_remote_command(
@@ -1264,8 +1478,11 @@ private function deploy_pull_request()
$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->push_to_docker_registry();
- // $this->stop_running_container();
$this->rolling_update();
}
@@ -1301,22 +1518,26 @@ private function create_workdir()
private function prepare_builder_image()
{
+ $this->checkForCancellation();
$settings = instanceSettings();
$helperImage = config('constants.coolify.helper_image');
$helperImage = "{$helperImage}:{$settings->helper_version}";
// Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
$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.');
}
- $runCommand = "docker run -d --name {$this->deployment_uuid} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
+ $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else {
if ($this->dockerConfigFileExists === 'OK') {
- $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
+ $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else {
- $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
+ $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.");
@@ -1436,11 +1657,14 @@ private function check_git_if_build_needed()
}
$private_key = data_get($this->application, 'private_key.private_key');
if ($private_key) {
- $this->execute_remote_command([
- executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
- ]);
- transfer_file_to_container($private_key, '/root/.ssh/id_rsa', $this->deployment_uuid, $this->server);
+ $private_key = base64_encode($private_key);
$this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
+ ],
[
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
],
@@ -1521,6 +1745,7 @@ private function generate_nixpacks_confs()
{
$nixpacks_command = $this->nixpacks_build_cmd();
$this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command");
+
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true],
[executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true],
@@ -1540,6 +1765,7 @@ private function generate_nixpacks_confs()
$parsed = Toml::Parse($this->nixpacks_plan);
// Do any modifications here
+ // We need to generate envs here because nixpacks need to know to generate a proper Dockerfile
$this->generate_env_variables();
$merged_envs = collect(data_get($parsed, 'variables', []))->merge($this->env_args);
$aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []);
@@ -1712,13 +1938,13 @@ private function generate_env_variables()
$this->env_args->put('SOURCE_COMMIT', $this->commit);
$coolify_envs = $this->generate_coolify_env_variables();
- // Include ALL environment variables (both build-time and runtime) for all build packs
- // This deprecates the need for is_build_time flag
+ // For build process, include only environment variables where is_buildtime = true
if ($this->pull_request_id === 0) {
- // Get all environment variables except NIXPACKS_ prefixed ones for non-nixpacks builds
- $envs = $this->application->build_pack === 'nixpacks'
- ? $this->application->runtime_environment_variables
- : $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get();
+ // Get environment variables that are marked as available during build
+ $envs = $this->application->environment_variables()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true)
+ ->get();
foreach ($envs as $env) {
if (! is_null($env->real_value)) {
@@ -1740,10 +1966,11 @@ private function generate_env_variables()
}
}
} else {
- // Get all preview environment variables except NIXPACKS_ prefixed ones for non-nixpacks builds
- $envs = $this->application->build_pack === 'nixpacks'
- ? $this->application->runtime_environment_variables_preview
- : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get();
+ // 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)
+ ->get();
foreach ($envs as $env) {
if (! is_null($env->real_value)) {
@@ -1769,13 +1996,13 @@ private function generate_env_variables()
private function generate_compose_file()
{
+ $this->checkForCancellation();
$this->create_workdir();
$ports = $this->application->main_port();
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->application->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
- // $environment_variables = $this->generate_environment_variables($ports);
- $this->save_environment_variables();
+ $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)));
@@ -1845,7 +2072,7 @@ private function generate_compose_file()
],
];
if (filled($this->env_filename)) {
- $docker_compose['services'][$this->container_name]['env_file'] = ["/artifacts/{$this->env_filename}"];
+ $docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename];
}
$docker_compose['services'][$this->container_name]['healthcheck'] = [
'test' => [
@@ -2002,7 +2229,7 @@ private function generate_compose_file()
$this->docker_compose = Yaml::dump($docker_compose, 10);
$this->docker_compose_base64 = base64_encode($this->docker_compose);
- transfer_file_to_container(base64_decode($this->docker_compose_base64), "{$this->workdir}/docker-compose.yaml", $this->deployment_uuid, $this->server);
+ $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}/docker-compose.yaml > /dev/null"), 'hidden' => true]);
}
private function generate_local_persistent_volumes()
@@ -2081,16 +2308,74 @@ private function pull_latest_image($image)
);
}
+ private function build_static_image()
+ {
+ $this->application_deployment_queue->addLogEntry('----------------------------------------');
+ $this->application_deployment_queue->addLogEntry('Static deployment. Copying static assets to the image.');
+ if ($this->application->static_image) {
+ $this->pull_latest_image($this->application->static_image);
+ }
+ $dockerfile = base64_encode("FROM {$this->application->static_image}
+ WORKDIR /usr/share/nginx/html/
+ LABEL coolify.deploymentId={$this->deployment_uuid}
+ COPY . .
+ RUN rm -f /usr/share/nginx/html/nginx.conf
+ RUN rm -f /usr/share/nginx/html/Dockerfile
+ RUN rm -f /usr/share/nginx/html/docker-compose.yaml
+ RUN rm -f /usr/share/nginx/html/.env
+ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
+ if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
+ $nginx_config = base64_encode($this->application->custom_nginx_configuration);
+ } else {
+ if ($this->application->settings->is_spa) {
+ $nginx_config = base64_encode(defaultNginxConfiguration('spa'));
+ } else {
+ $nginx_config = base64_encode(defaultNginxConfiguration());
+ }
+ }
+ $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
+ $base64_build_command = base64_encode($build_command);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"),
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"),
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ 'hidden' => true,
+ ]
+ );
+ $this->application_deployment_queue->addLogEntry('Building docker image completed.');
+ }
+
private function build_image()
{
- // Add Coolify related variables to the build args
- $this->environment_variables->filter(function ($key, $value) {
- return str($key)->startsWith('COOLIFY_');
- })->each(function ($key, $value) {
- $this->build_args->push("--build-arg '{$key}'");
- });
+ // Add Coolify related variables to the build args/secrets
+ if ($this->dockerBuildkitSupported) {
+ // 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) {
+ $this->build_args->push("--build-arg '{$key}'");
+ });
- $this->build_args = $this->build_args->implode(' ');
+ $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
+ ? $this->build_args->implode(' ')
+ : (string) $this->build_args;
+ }
$this->application_deployment_queue->addLogEntry('----------------------------------------');
if ($this->disableBuildCache) {
@@ -2103,120 +2388,127 @@ private function build_image()
$this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.');
}
- if ($this->application->settings->is_static || $this->application->build_pack === 'static') {
+ if ($this->application->settings->is_static) {
if ($this->application->static_image) {
$this->pull_latest_image($this->application->static_image);
$this->application_deployment_queue->addLogEntry('Continuing with the building process.');
}
- if ($this->application->build_pack === 'static') {
- $dockerfile = base64_encode("FROM {$this->application->static_image}
-WORKDIR /usr/share/nginx/html/
-LABEL coolify.deploymentId={$this->deployment_uuid}
-COPY . .
-RUN rm -f /usr/share/nginx/html/nginx.conf
-RUN rm -f /usr/share/nginx/html/Dockerfile
-RUN rm -f /usr/share/nginx/html/docker-compose.yaml
-RUN rm -f /usr/share/nginx/html/.env
-COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
- if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
- $nginx_config = base64_encode($this->application->custom_nginx_configuration);
- } else {
- if ($this->application->settings->is_spa) {
- $nginx_config = base64_encode(defaultNginxConfiguration('spa'));
+ if ($this->application->build_pack === 'nixpacks') {
+ $this->nixpacks_plan = base64_encode($this->nixpacks_plan);
+ $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]);
+ if ($this->force_rebuild) {
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
+ 'hidden' => true,
+ ], [
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
+ 'hidden' => true,
+ ]);
+ 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->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}";
} else {
- $nginx_config = base64_encode(defaultNginxConfiguration());
- }
- }
- } else {
- if ($this->application->build_pack === 'nixpacks') {
- $this->nixpacks_plan = base64_encode($this->nixpacks_plan);
- $nixpacks_content = base64_decode($this->nixpacks_plan);
- transfer_file_to_container($nixpacks_content, '/artifacts/thegameplan.json', $this->deployment_uuid, $this->server);
- if ($this->force_rebuild) {
- $this->execute_remote_command([
- executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
- 'hidden' => true,
- ], [
- executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
- 'hidden' => true,
- ]);
$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}";
+ }
+ } else {
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
+ 'hidden' => true,
+ ], [
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
+ 'hidden' => true,
+ ]);
+ if ($this->dockerBuildkitSupported) {
+ // 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}";
} else {
- $this->execute_remote_command([
- executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
- 'hidden' => true,
- ], [
- executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
- 'hidden' => true,
- ]);
$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}";
}
+ }
- $base64_build_command = base64_encode($build_command);
- $this->execute_remote_command(
- [
- transfer_file_to_container(base64_decode($base64_build_command), '/artifacts/build.sh', $this->deployment_uuid, $this->server),
- 'hidden' => true,
- ],
- [
- executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
- 'hidden' => true,
- ],
- [
- executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
- 'hidden' => true,
- ]
- );
- $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
+ $base64_build_command = base64_encode($build_command);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ 'hidden' => true,
+ ]
+ );
+ $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
+ } else {
+ // Dockerfile buildpack
+ 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}");
+ $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}";
+ } 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}";
+ }
} 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}";
- $base64_build_command = base64_encode($build_command);
} 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}";
- $base64_build_command = base64_encode($build_command);
}
- $this->execute_remote_command(
- [
- transfer_file_to_container(base64_decode($base64_build_command), '/artifacts/build.sh', $this->deployment_uuid, $this->server),
- 'hidden' => true,
- ],
- [
- executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
- 'hidden' => true,
- ],
- [
- executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
- 'hidden' => true,
- ]
- );
}
- $dockerfile = base64_encode("FROM {$this->application->static_image}
+ $base64_build_command = base64_encode($build_command);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'),
+ 'hidden' => true,
+ ]
+ );
+ }
+ $dockerfile = base64_encode("FROM {$this->application->static_image}
WORKDIR /usr/share/nginx/html/
LABEL coolify.deploymentId={$this->deployment_uuid}
COPY --from=$this->build_image_name /app/{$this->application->publish_directory} .
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
- if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
- $nginx_config = base64_encode($this->application->custom_nginx_configuration);
+ if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
+ $nginx_config = base64_encode($this->application->custom_nginx_configuration);
+ } else {
+ if ($this->application->settings->is_spa) {
+ $nginx_config = base64_encode(defaultNginxConfiguration('spa'));
} else {
- if ($this->application->settings->is_spa) {
- $nginx_config = base64_encode(defaultNginxConfiguration('spa'));
- } else {
- $nginx_config = base64_encode(defaultNginxConfiguration());
- }
+ $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}";
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- transfer_file_to_container(base64_decode($dockerfile), "{$this->workdir}/Dockerfile", $this->deployment_uuid, $this->server),
+ executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"),
],
[
- transfer_file_to_container(base64_decode($nginx_config), "{$this->workdir}/nginx.conf", $this->deployment_uuid, $this->server),
+ executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"),
],
[
- transfer_file_to_container(base64_decode($base64_build_command), '/artifacts/build.sh', $this->deployment_uuid, $this->server),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
'hidden' => true,
],
[
@@ -2231,15 +2523,27 @@ private function build_image()
} else {
// Pure Dockerfile based deployment
if ($this->application->dockerfile) {
- 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}";
+ 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}");
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ if ($this->force_rebuild) {
+ $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache --pull {$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 --pull {$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 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}";
+ // 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}";
+ } 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}";
+ }
}
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- transfer_file_to_container(base64_decode($base64_build_command), '/artifacts/build.sh', $this->deployment_uuid, $this->server),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
'hidden' => true,
],
[
@@ -2254,8 +2558,7 @@ private function build_image()
} else {
if ($this->application->build_pack === 'nixpacks') {
$this->nixpacks_plan = base64_encode($this->nixpacks_plan);
- $nixpacks_content = base64_decode($this->nixpacks_plan);
- transfer_file_to_container($nixpacks_content, '/artifacts/thegameplan.json', $this->deployment_uuid, $this->server);
+ $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]);
if ($this->force_rebuild) {
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
@@ -2264,7 +2567,14 @@ private function build_image()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
- $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}";
+ if ($this->dockerBuildkitSupported) {
+ // 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}";
+ } 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}";
+ }
} else {
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
@@ -2273,12 +2583,19 @@ private function build_image()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
- $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}";
+ if ($this->dockerBuildkitSupported) {
+ // 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}";
+ } 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}";
+ }
}
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- transfer_file_to_container(base64_decode($base64_build_command), '/artifacts/build.sh', $this->deployment_uuid, $this->server),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
'hidden' => true,
],
[
@@ -2292,16 +2609,27 @@ private function build_image()
);
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
} else {
- 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}";
- $base64_build_command = base64_encode($build_command);
+ // Dockerfile buildpack
+ if ($this->dockerBuildkitSupported) {
+ // 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}";
+ } 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}";
+ }
} 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}";
- $base64_build_command = base64_encode($build_command);
+ // 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}";
+ } 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}";
+ }
}
+ $base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
- transfer_file_to_container(base64_decode($base64_build_command), '/artifacts/build.sh', $this->deployment_uuid, $this->server),
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"),
'hidden' => true,
],
[
@@ -2385,6 +2713,50 @@ 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') {
@@ -2394,49 +2766,428 @@ private function generate_build_env_variables()
$variables = collect([])->merge($this->env_args);
}
- $this->build_args = $variables->map(function ($value, $key) {
- $value = escapeshellarg($value);
+ // Analyze build variables for potential issues
+ if ($variables->isNotEmpty()) {
+ $this->analyzeBuildTimeVariables($variables);
+ }
- return "--build-arg {$key}={$value}";
- });
+ if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ $this->generate_build_secrets($variables);
+ $this->build_args = '';
+ } else {
+ $secrets_hash = '';
+ if ($variables->isNotEmpty()) {
+ $secrets_hash = $this->generate_secrets_hash($variables);
+ }
+
+ $this->build_args = $variables->map(function ($value, $key) {
+ $value = escapeshellarg($value);
+
+ return "--build-arg {$key}={$value}";
+ });
+
+ if ($secrets_hash) {
+ $this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
+ }
+ }
+ }
+
+ private function generate_docker_env_flags_for_secrets()
+ {
+ // Only generate env flags if build secrets are enabled
+ if (! $this->application->settings->use_build_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();
+
+ if ($variables->isEmpty()) {
+ return '';
+ }
+
+ $secrets_hash = $this->generate_secrets_hash($variables);
+ $env_flags = $variables
+ ->map(function ($env) {
+ $escaped_value = escapeshellarg($env->real_value);
+
+ return "-e {$env->key}={$escaped_value}";
+ })
+ ->implode(' ');
+
+ $env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}";
+
+ return $env_flags;
+ }
+
+ private function generate_build_secrets(Collection $variables)
+ {
+ if ($variables->isEmpty()) {
+ $this->build_secrets = '';
+
+ return;
+ }
+
+ $this->build_secrets = $variables
+ ->map(function ($value, $key) {
+ 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()
{
- $this->execute_remote_command([
- executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
- 'hidden' => true,
- 'save' => 'dockerfile',
- ]);
- $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
+ if ($this->dockerBuildkitSupported) {
+ // We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets
+ } else {
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
+ 'hidden' => true,
+ 'save' => 'dockerfile',
+ ]);
+ $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
- // Include ALL environment variables as build args (deprecating is_build_time flag)
- if ($this->pull_request_id === 0) {
- // Get all environment variables except NIXPACKS_ prefixed ones
- $envs = $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get();
- foreach ($envs as $env) {
- if (data_get($env, 'is_multiline') === true) {
- $dockerfile->splice(1, 0, ["ARG {$env->key}"]);
- } else {
- $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
+ if ($this->pull_request_id === 0) {
+ // Only add environment variables that are available during build
+ $envs = $this->application->environment_variables()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true)
+ ->get();
+ foreach ($envs as $env) {
+ if (data_get($env, 'is_multiline') === true) {
+ $dockerfile->splice(1, 0, ["ARG {$env->key}"]);
+ } else {
+ $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
+ }
+ }
+ } else {
+ // Only add preview environment variables that are available during build
+ $envs = $this->application->environment_variables_preview()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true)
+ ->get();
+ foreach ($envs as $env) {
+ if (data_get($env, 'is_multiline') === true) {
+ $dockerfile->splice(1, 0, ["ARG {$env->key}"]);
+ } else {
+ $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
+ }
}
}
- } else {
- // Get all preview environment variables except NIXPACKS_ prefixed ones
- $envs = $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get();
- foreach ($envs as $env) {
- if (data_get($env, 'is_multiline') === true) {
- $dockerfile->splice(1, 0, ["ARG {$env->key}"]);
- } else {
- $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
+
+ 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,
+ ]);
+ }
+ }
+
+ private function modify_dockerfile_for_secrets($dockerfile_path)
+ {
+ // Only process if build secrets are enabled and we have secrets to mount
+ if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) {
+ return;
+ }
+
+ // Read the Dockerfile
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "cat {$dockerfile_path}"),
+ 'hidden' => true,
+ 'save' => 'dockerfile_content',
+ ]);
+
+ $dockerfile = str($this->saved_outputs->get('dockerfile_content'))->trim()->explode("\n");
+
+ // Add BuildKit syntax directive if not present
+ if (! str_starts_with($dockerfile->first(), '# syntax=')) {
+ $dockerfile->prepend('# syntax=docker/dockerfile:1');
+ }
+
+ // Get environment variables for secrets
+ $variables = $this->pull_request_id === 0
+ ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get()
+ : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get();
+
+ 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(' ');
+
+ // 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) {
+ $trimmed = ltrim($line);
+
+ // Skip lines that already have secret mounts or are not RUN commands
+ if (str_contains($line, '--mount=type=secret') || ! str_starts_with($trimmed, 'RUN')) {
+ return $line;
+ }
+
+ // Add mount strings to RUN command
+ $originalCommand = trim(substr($trimmed, 3));
+ $modified = true;
+
+ return "RUN {$mountStrings} {$originalCommand}";
+ });
+
+ if ($modified) {
+ // Write the modified Dockerfile back
+ $dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
+ $this->execute_remote_command([
+ 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;
+ }
+
+ $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();
+
+ 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 $env) {
+ $argsToAdd->push("ARG {$env->key}");
+ }
+
+ ray($argsToAdd);
+ 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();
+
+ if ($variables->isEmpty()) {
+ return $composeFile;
+ }
+
+ $secrets = [];
+ foreach ($variables as $env) {
+ $secrets[$env->key] = [
+ 'environment' => $env->key,
+ ];
+ }
+
+ $services = data_get($composeFile, 'services', []);
+ foreach ($services as $serviceName => &$service) {
+ if (isset($service['build'])) {
+ if (is_string($service['build'])) {
+ $service['build'] = [
+ 'context' => $service['build'],
+ ];
+ }
+ 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;
+ }
}
}
}
- $dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
- $this->execute_remote_command([
- transfer_file_to_container(base64_decode($dockerfile_base64), "{$this->workdir}{$this->dockerfile_location}", $this->deployment_uuid, $this->server),
- 'hidden' => true,
- ]);
+
+ $composeFile['services'] = $services;
+ $existingSecrets = data_get($composeFile, 'secrets', []);
+ if ($existingSecrets instanceof \Illuminate\Support\Collection) {
+ $existingSecrets = $existingSecrets->toArray();
+ }
+ $composeFile['secrets'] = array_replace($existingSecrets, $secrets);
+
+ $this->application_deployment_queue->addLogEntry('Added build secrets configuration to docker-compose file (using environment variables).');
+
+ return $composeFile;
}
private function run_pre_deployment_command()
@@ -2504,8 +3255,23 @@ private function run_post_deployment_command()
throw new RuntimeException('Post-deployment command: Could not find a valid container. Is the container name correct?');
}
+ /**
+ * Check if the deployment was cancelled and abort if it was
+ */
+ private function checkForCancellation(): void
+ {
+ $this->application_deployment_queue->refresh();
+ if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
+ $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
+ throw new \RuntimeException('Deployment cancelled by user', 69420);
+ }
+ }
+
private function next(string $status)
{
+ // Refresh to get latest status
+ $this->application_deployment_queue->refresh();
+
// Never allow changing status from FAILED or CANCELLED_BY_USER to anything else
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
$this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview));
@@ -2513,7 +3279,9 @@ private function next(string $status)
return;
}
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
- return;
+ // Job was cancelled, stop execution
+ $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
+ throw new \RuntimeException('Deployment cancelled by user', 69420);
}
$this->application_deployment_queue->update([
@@ -2523,6 +3291,9 @@ private function next(string $status)
queue_next_deployment($this->application);
if ($status === ApplicationDeploymentStatus::FINISHED->value) {
+ ray($this->application->team()->id);
+ event(new ApplicationConfigurationChanged($this->application->team()->id));
+
if (! $this->only_this_server) {
$this->deploy_to_additional_destinations();
}
@@ -2542,8 +3313,8 @@ public function failed(Throwable $exception): void
$code = $exception->getCode();
if ($code !== 69420) {
// 69420 means failed to push the image to the registry, so we don't need to remove the new version as it is the currently running one
- if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
- // do not remove already running container
+ if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0) {
+ // do not remove already running container for PR deployments
} else {
$this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr');
$this->execute_remote_command(
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 6ac9ae1e6..92db14a61 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -74,8 +74,6 @@ public function __construct(public ScheduledDatabaseBackup $backup)
{
$this->onQueue('high');
$this->timeout = $backup->timeout;
-
- $this->backup_log_uuid = (string) new Cuid2;
}
public function handle(): void
@@ -288,6 +286,17 @@ 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;
try {
if (str($databaseType)->contains('postgres')) {
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index 167bcea38..8b55434f6 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -78,11 +78,11 @@ public function handle()
}
// Server is reachable, check if Docker is available
- // $isUsable = $this->checkDockerAvailability();
+ $isUsable = $this->checkDockerAvailability();
$this->server->settings->update([
'is_reachable' => true,
- 'is_usable' => true,
+ 'is_usable' => $isUsable,
]);
} catch (\Throwable $e) {
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/GlobalSearch.php b/app/Livewire/GlobalSearch.php
new file mode 100644
index 000000000..dacc0d4db
--- /dev/null
+++ b/app/Livewire/GlobalSearch.php
@@ -0,0 +1,372 @@
+searchQuery = '';
+ $this->isModalOpen = false;
+ $this->searchResults = [];
+ $this->allSearchableItems = [];
+ }
+
+ public function openSearchModal()
+ {
+ $this->isModalOpen = true;
+ $this->loadSearchableItems();
+ $this->dispatch('search-modal-opened');
+ }
+
+ public function closeSearchModal()
+ {
+ $this->isModalOpen = false;
+ $this->searchQuery = '';
+ $this->searchResults = [];
+ }
+
+ public static function getCacheKey($teamId)
+ {
+ return 'global_search_items_'.$teamId;
+ }
+
+ public static function clearTeamCache($teamId)
+ {
+ Cache::forget(self::getCacheKey($teamId));
+ }
+
+ public function updatedSearchQuery()
+ {
+ $this->search();
+ }
+
+ private function loadSearchableItems()
+ {
+ // Try to get from Redis cache first
+ $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
+
+ $this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
+ ray()->showQueries();
+ $items = collect();
+ $team = auth()->user()->currentTeam();
+
+ // Get all applications
+ $applications = Application::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($app) {
+ // Collect all FQDNs from the application
+ $fqdns = collect([]);
+
+ // For regular applications
+ if ($app->fqdn) {
+ $fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
+ }
+
+ // For docker compose based applications
+ if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) {
+ try {
+ $composeDomains = json_decode($app->docker_compose_domains, true);
+ if (is_array($composeDomains)) {
+ foreach ($composeDomains as $serviceName => $domains) {
+ if (is_array($domains)) {
+ $fqdns = $fqdns->merge($domains);
+ }
+ }
+ }
+ } catch (\Exception $e) {
+ // Ignore JSON parsing errors
+ }
+ }
+
+ $fqdnsString = $fqdns->implode(' ');
+
+ return [
+ 'id' => $app->id,
+ 'name' => $app->name,
+ 'type' => 'application',
+ 'uuid' => $app->uuid,
+ 'description' => $app->description,
+ 'link' => $app->link(),
+ 'project' => $app->environment->project->name ?? null,
+ 'environment' => $app->environment->name ?? null,
+ 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
+ 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString),
+ ];
+ });
+
+ // Get all services
+ $services = Service::ownedByCurrentTeam()
+ ->with(['environment.project', 'applications'])
+ ->get()
+ ->map(function ($service) {
+ // Collect all FQDNs from service applications
+ $fqdns = collect([]);
+ foreach ($service->applications as $app) {
+ if ($app->fqdn) {
+ $appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
+ $fqdns = $fqdns->merge($appFqdns);
+ }
+ }
+ $fqdnsString = $fqdns->implode(' ');
+
+ return [
+ 'id' => $service->id,
+ 'name' => $service->name,
+ 'type' => 'service',
+ 'uuid' => $service->uuid,
+ 'description' => $service->description,
+ 'link' => $service->link(),
+ 'project' => $service->environment->project->name ?? null,
+ 'environment' => $service->environment->name ?? null,
+ 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
+ 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString),
+ ];
+ });
+
+ // Get all standalone databases
+ $databases = collect();
+
+ // PostgreSQL
+ $databases = $databases->merge(
+ StandalonePostgresql::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'postgresql',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' postgresql '.$db->description),
+ ];
+ })
+ );
+
+ // MySQL
+ $databases = $databases->merge(
+ StandaloneMysql::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'mysql',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' mysql '.$db->description),
+ ];
+ })
+ );
+
+ // MariaDB
+ $databases = $databases->merge(
+ StandaloneMariadb::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'mariadb',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' mariadb '.$db->description),
+ ];
+ })
+ );
+
+ // MongoDB
+ $databases = $databases->merge(
+ StandaloneMongodb::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'mongodb',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' mongodb '.$db->description),
+ ];
+ })
+ );
+
+ // Redis
+ $databases = $databases->merge(
+ StandaloneRedis::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'redis',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' redis '.$db->description),
+ ];
+ })
+ );
+
+ // KeyDB
+ $databases = $databases->merge(
+ StandaloneKeydb::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'keydb',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' keydb '.$db->description),
+ ];
+ })
+ );
+
+ // Dragonfly
+ $databases = $databases->merge(
+ StandaloneDragonfly::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'dragonfly',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' dragonfly '.$db->description),
+ ];
+ })
+ );
+
+ // Clickhouse
+ $databases = $databases->merge(
+ StandaloneClickhouse::ownedByCurrentTeam()
+ ->with(['environment.project'])
+ ->get()
+ ->map(function ($db) {
+ return [
+ 'id' => $db->id,
+ 'name' => $db->name,
+ 'type' => 'database',
+ 'subtype' => 'clickhouse',
+ 'uuid' => $db->uuid,
+ 'description' => $db->description,
+ 'link' => $db->link(),
+ 'project' => $db->environment->project->name ?? null,
+ 'environment' => $db->environment->name ?? null,
+ 'search_text' => strtolower($db->name.' clickhouse '.$db->description),
+ ];
+ })
+ );
+
+ // Get all servers
+ $servers = Server::ownedByCurrentTeam()
+ ->get()
+ ->map(function ($server) {
+ return [
+ 'id' => $server->id,
+ 'name' => $server->name,
+ 'type' => 'server',
+ 'uuid' => $server->uuid,
+ 'description' => $server->description,
+ 'link' => $server->url(),
+ 'project' => null,
+ 'environment' => null,
+ 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description),
+ ];
+ });
+
+ // Merge all collections
+ $items = $items->merge($applications)
+ ->merge($services)
+ ->merge($databases)
+ ->merge($servers);
+
+ return $items->toArray();
+ });
+ }
+
+ private function search()
+ {
+ if (strlen($this->searchQuery) < 2) {
+ $this->searchResults = [];
+
+ return;
+ }
+
+ $query = strtolower($this->searchQuery);
+
+ // Case-insensitive search in the items
+ $this->searchResults = collect($this->allSearchableItems)
+ ->filter(function ($item) use ($query) {
+ return str_contains($item['search_text'], $query);
+ })
+ ->take(20)
+ ->values()
+ ->toArray();
+ }
+
+ public function render()
+ {
+ return view('livewire.global-search');
+ }
+}
diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php
index 66f387fcf..dccd1e499 100644
--- a/app/Livewire/Project/Application/DeploymentNavbar.php
+++ b/app/Livewire/Project/Application/DeploymentNavbar.php
@@ -52,15 +52,24 @@ public function force_start()
public function cancel()
{
- $kill_command = "docker rm -f {$this->application_deployment_queue->deployment_uuid}";
+ $deployment_uuid = $this->application_deployment_queue->deployment_uuid;
+ $kill_command = "docker rm -f {$deployment_uuid}";
$build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id;
$server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id;
+
+ // First, mark the deployment as cancelled to prevent further processing
+ $this->application_deployment_queue->update([
+ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
+ ]);
+
try {
if ($this->application->settings->is_build_server_enabled) {
$server = Server::ownedByCurrentTeam()->find($build_server_id);
} else {
$server = Server::ownedByCurrentTeam()->find($server_id);
}
+
+ // Add cancellation log entry
if ($this->application_deployment_queue->logs) {
$previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
@@ -77,13 +86,35 @@ public function cancel()
'logs' => json_encode($previous_logs, flags: JSON_THROW_ON_ERROR),
]);
}
- instant_remote_process([$kill_command], $server);
+
+ // Try to stop the helper container if it exists
+ // Check if container exists first
+ $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
+ $containerExists = instant_remote_process([$checkCommand], $server);
+
+ if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
+ // Container exists, kill it
+ instant_remote_process([$kill_command], $server);
+ } else {
+ // Container hasn't started yet
+ $this->application_deployment_queue->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
+ }
+
+ // Also try to kill any running process if we have a process ID
+ if ($this->application_deployment_queue->current_process_id) {
+ try {
+ $processKillCommand = "kill -9 {$this->application_deployment_queue->current_process_id}";
+ instant_remote_process([$processKillCommand], $server);
+ } catch (\Throwable $e) {
+ // Process might already be gone, that's ok
+ }
+ }
} catch (\Throwable $e) {
+ // Still mark as cancelled even if cleanup fails
return handleError($e, $this);
} finally {
$this->application_deployment_queue->update([
'current_process_id' => null,
- 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
next_after_cancel($server);
}
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index c77d050cb..ae9bd314b 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;
}
@@ -547,9 +547,10 @@ public function submit($showToaster = true)
$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(',');
diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php
index 2632509ea..7641edcc5 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/CloneMe.php b/app/Livewire/Project/CloneMe.php
index a4f50ee06..3b3e42619 100644
--- a/app/Livewire/Project/CloneMe.php
+++ b/app/Livewire/Project/CloneMe.php
@@ -127,7 +127,7 @@ public function clone(string $type)
$databases = $this->environment->databases();
$services = $this->environment->services;
foreach ($applications as $application) {
- $selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations)->where('id', $this->selectedDestination)->first();
+ $selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations())->where('id', $this->selectedDestination)->first();
clone_application($application, $selectedDestination, [
'environment_id' => $environment->id,
], $this->cloneVolumeData);
diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php
index 706c6c0cd..3f974f63d 100644
--- a/app/Livewire/Project/Database/Import.php
+++ b/app/Livewire/Project/Database/Import.php
@@ -232,12 +232,8 @@ public function runImport()
break;
}
- $this->importCommands[] = [
- 'transfer_file' => [
- 'content' => $restoreCommand,
- 'destination' => $scriptPath,
- ],
- ];
+ $restoreCommandBase64 = base64_encode($restoreCommand);
+ $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$this->importCommands[] = "chmod +x {$scriptPath}";
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php
index 0f496e6db..a2071931e 100644
--- a/app/Livewire/Project/New/GithubPrivateRepository.php
+++ b/app/Livewire/Project/New/GithubPrivateRepository.php
@@ -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']);
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/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/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 9d5a5a39f..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;
@@ -23,7 +24,11 @@ class Add extends Component
public bool $is_literal = false;
- public bool $is_buildtime_only = false;
+ public bool $is_runtime = true;
+
+ public bool $is_buildtime = true;
+
+ public array $problematicVariables = [];
protected $listeners = ['clearAddEnv' => 'clear'];
@@ -32,7 +37,8 @@ class Add extends Component
'value' => 'nullable',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
- 'is_buildtime_only' => 'required|boolean',
+ 'is_runtime' => 'required|boolean',
+ 'is_buildtime' => 'required|boolean',
];
protected $validationAttributes = [
@@ -40,12 +46,14 @@ class Add extends Component
'value' => 'value',
'is_multiline' => 'multiline',
'is_literal' => 'literal',
- 'is_buildtime_only' => 'buildtime only',
+ 'is_runtime' => 'runtime',
+ 'is_buildtime' => 'buildtime',
];
public function mount()
{
$this->parameters = get_route_parameters();
+ $this->problematicVariables = self::getProblematicVariablesForFrontend();
}
public function submit()
@@ -56,7 +64,8 @@ public function submit()
'value' => $this->value,
'is_multiline' => $this->is_multiline,
'is_literal' => $this->is_literal,
- 'is_buildtime_only' => $this->is_buildtime_only,
+ 'is_runtime' => $this->is_runtime,
+ 'is_buildtime' => $this->is_buildtime,
'is_preview' => $this->is_preview,
]);
$this->clear();
@@ -68,6 +77,7 @@ public function clear()
$this->value = '';
$this->is_multiline = false;
$this->is_literal = false;
- $this->is_buildtime_only = false;
+ $this->is_runtime = true;
+ $this->is_buildtime = true;
}
}
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
index 9429c5f25..639c025c7 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
@@ -25,6 +25,8 @@ class All extends Component
public bool $is_env_sorting_enabled = false;
+ public bool $use_build_secrets = false;
+
protected $listeners = [
'saveKey' => 'submit',
'refreshEnvs',
@@ -34,6 +36,7 @@ class All extends Component
public function mount()
{
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
+ $this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
$this->resourceClass = get_class($this->resource);
$resourceWithPreviews = [\App\Models\Application::class];
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
@@ -49,6 +52,7 @@ public function instantSave()
$this->authorize('manageEnvironment', $this->resource);
$this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled;
+ $this->resource->settings->use_build_secrets = $this->use_build_secrets;
$this->resource->settings->save();
$this->getDevView();
$this->dispatch('success', 'Environment variable settings updated.');
@@ -217,7 +221,8 @@ private function createEnvironmentVariable($data)
$environment->value = $data['value'];
$environment->is_multiline = $data['is_multiline'] ?? false;
$environment->is_literal = $data['is_literal'] ?? false;
- $environment->is_buildtime_only = $data['is_buildtime_only'] ?? false;
+ $environment->is_runtime = $data['is_runtime'] ?? true;
+ $environment->is_buildtime = $data['is_buildtime'] ?? true;
$environment->is_preview = $data['is_preview'] ?? false;
$environment->resourceable_id = $this->resource->id;
$environment->resourceable_type = $this->resource->getMorphClass();
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index ab70b70f4..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;
@@ -38,7 +39,9 @@ class Show extends Component
public bool $is_shown_once = false;
- public bool $is_buildtime_only = false;
+ public bool $is_runtime = true;
+
+ public bool $is_buildtime = true;
public bool $is_required = false;
@@ -46,6 +49,8 @@ class Show extends Component
public bool $is_redis_credential = false;
+ public array $problematicVariables = [];
+
protected $listeners = [
'refreshEnvs' => 'refresh',
'refresh',
@@ -58,7 +63,8 @@ class Show extends Component
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
'is_shown_once' => 'required|boolean',
- 'is_buildtime_only' => 'required|boolean',
+ 'is_runtime' => 'required|boolean',
+ 'is_buildtime' => 'required|boolean',
'real_value' => 'nullable',
'is_required' => 'required|boolean',
];
@@ -74,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()
@@ -102,7 +109,8 @@ public function syncData(bool $toModel = false)
} else {
$this->validate();
$this->env->is_required = $this->is_required;
- $this->env->is_buildtime_only = $this->is_buildtime_only;
+ $this->env->is_runtime = $this->is_runtime;
+ $this->env->is_buildtime = $this->is_buildtime;
$this->env->is_shared = $this->is_shared;
}
$this->env->key = $this->key;
@@ -117,7 +125,8 @@ public function syncData(bool $toModel = false)
$this->is_multiline = $this->env->is_multiline;
$this->is_literal = $this->env->is_literal;
$this->is_shown_once = $this->env->is_shown_once;
- $this->is_buildtime_only = $this->env->is_buildtime_only ?? false;
+ $this->is_runtime = $this->env->is_runtime ?? true;
+ $this->is_buildtime = $this->env->is_buildtime ?? true;
$this->is_required = $this->env->is_required ?? false;
$this->is_really_required = $this->env->is_really_required ?? false;
$this->is_shared = $this->env->is_shared ?? false;
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/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php
index 6f62a5b5b..ca2bbd9b4 100644
--- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php
+++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php
@@ -105,6 +105,19 @@ public function loadMoreLogs()
$this->currentPage++;
}
+ public function loadAllLogs()
+ {
+ if (! $this->selectedExecution || ! $this->selectedExecution->message) {
+ return;
+ }
+
+ $lines = collect(explode("\n", $this->selectedExecution->message));
+ $totalLines = $lines->count();
+ $totalPages = ceil($totalLines / $this->logsPerPage);
+
+ $this->currentPage = $totalPages;
+ }
+
public function getLogLinesProperty()
{
if (! $this->selectedExecution) {
diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php
index 760c4df0d..bbc3bd96a 100644
--- a/app/Livewire/Server/Advanced.php
+++ b/app/Livewire/Server/Advanced.php
@@ -27,9 +27,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,36 +39,7 @@ 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)
{
@@ -88,7 +56,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/Navbar.php b/app/Livewire/Server/Navbar.php
index 055290580..beefed12a 100644
--- a/app/Livewire/Server/Navbar.php
+++ b/app/Livewire/Server/Navbar.php
@@ -32,7 +32,7 @@ public function getListeners()
$teamId = auth()->user()->currentTeam()->id;
return [
- 'refreshServerShow' => '$refresh',
+ 'refreshServerShow' => 'refreshServer',
"echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification',
];
}
@@ -134,6 +134,12 @@ public function showNotification()
}
+ public function refreshServer()
+ {
+ $this->server->refresh();
+ $this->server->load('settings');
+ }
+
public function render()
{
return view('livewire.server.navbar');
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.
\ No newline at end of file
diff --git a/resources/views/components/domain-conflict-modal.blade.php b/resources/views/components/domain-conflict-modal.blade.php
index 218a7ef16..fe55a8ba5 100644
--- a/resources/views/components/domain-conflict-modal.blade.php
+++ b/resources/views/components/domain-conflict-modal.blade.php
@@ -30,14 +30,12 @@ class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-f
-
-
Warning: Domain Conflict Detected
-
{{ $slot ?? '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.' }}
-
-
+
+ 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.
+