Merge branch 'next' into template/metamcp

This commit is contained in:
Romain ROCHAS 2025-09-19 10:28:43 +02:00 committed by GitHub
commit e376ac3950
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 5433 additions and 1457 deletions

11
.cursor/mcp.json Normal file
View file

@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

View file

@ -0,0 +1,405 @@
---
alwaysApply: true
---
<laravel-boost-guidelines>
=== 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()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- 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.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## 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] <name>` 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)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== 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:
<code-snippet name="livewire:load example" lang="js">
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);
});
});
</code-snippet>
=== 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 <name>`.
- 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:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### 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.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### 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.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== 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.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### 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:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff"
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### 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.
</laravel-boost-guidelines>

11
.mcp.json Normal file
View file

@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

4
.phpactor.json Normal file
View file

@ -0,0 +1,4 @@
{
"$schema": "/phpactor.schema.json",
"language_server_phpstan.enabled": true
}

View file

@ -4,6 +4,150 @@ # Changelog
## [unreleased]
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(docker)* Add a blank line for improved readability in Dockerfile
## [4.0.0-beta.428] - 2025-09-15
### 🚀 Features
- *(deployment)* Enhance deployment status reporting with detailed information on active deployments and team members
### 🐛 Bug Fixes
- *(application)* Improve watch paths handling by trimming and filtering empty paths to prevent unnecessary triggers
### 🚜 Refactor
- *(deployment)* Streamline environment variable handling for dockercompose and improve sorting of runtime variables
- *(remoteProcess)* Remove command log comments for file transfers to simplify code
- *(remoteProcess)* Remove file transfer handling from remote_process and instant_remote_process functions to simplify code
- *(deployment)* Update environment file paths in docker compose commands to use working directory for improved consistency
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(constants)* Update realtime_version from 1.0.10 to 1.0.11
- *(versions)* Increment coolify version to 4.0.0-beta.428 and update realtime_version to 1.0.10
## [4.0.0-beta.427] - 2025-09-15
### 🚀 Features
- *(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
### 🐛 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
### 🚜 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
### 📚 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
## [4.0.0-beta.426] - 2025-08-28
### 🚜 Refactor
- *(policy)* Simplify ServiceDatabasePolicy methods to always return true and add manageBackups method

405
CLAUDE.md
View file

@ -247,3 +247,408 @@ ### 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
===
<laravel-boost-guidelines>
=== 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()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- 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.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## 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] <name>` 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)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== 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:
<code-snippet name="livewire:load example" lang="js">
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);
});
});
</code-snippet>
=== 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 <name>`.
- 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:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### 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.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### 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.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== 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.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### 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:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff"
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### 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.
</laravel-boost-guidelines>

View file

@ -18,7 +18,7 @@ ## Reporting a Vulnerability
If you discover a security vulnerability, please follow these steps:
1. **DO NOT** disclose the vulnerability publicly.
2. Send a detailed report to: `hi@coollabs.io`.
2. Send a detailed report to: `security@coollabs.io`.
3. Include in your report:
- A description of the vulnerability
- Steps to reproduce the issue

View file

@ -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.'";

View file

@ -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 = <<<EOF
user nginx;
@ -87,7 +86,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
'volumes' => [
[
'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);

View file

@ -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.'";

View file

@ -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.'";

View file

@ -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";
}
}

View file

@ -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";
}
}

View file

@ -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";
}
}

View file

@ -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";
}
}

View file

@ -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.'";

View file

@ -26,6 +26,8 @@ class GetContainersStatus
public $server;
protected ?Collection $applicationContainerStatuses;
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{
$this->containers = $containers;
@ -94,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) {
@ -119,11 +125,16 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$application = $this->applications->where('id', $applicationId)->first();
if ($application) {
$foundApplications[] = $application->id;
$statusFromDb = $application->status;
if ($statusFromDb !== $containerStatus) {
$application->update(['status' => $containerStatus]);
} else {
$application->update(['last_online_at' => now()]);
// Store container status for aggregation
if (! isset($this->applicationContainerStatuses)) {
$this->applicationContainerStatuses = collect();
}
if (! $this->applicationContainerStatuses->has($applicationId)) {
$this->applicationContainerStatuses->put($applicationId, collect());
}
$containerName = data_get($labels, 'com.docker.compose.service');
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
} else {
// Notify user that this container should not be there.
@ -320,6 +331,97 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
}
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
}
// Aggregate multi-container application statuses
if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) {
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
$application = $this->applications->where('id', $applicationId)->first();
if (! $application) {
continue;
}
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
if ($aggregatedStatus) {
$statusFromDb = $application->status;
if ($statusFromDb !== $aggregatedStatus) {
$application->update(['status' => $aggregatedStatus]);
} else {
$application->update(['last_online_at' => now()]);
}
}
}
}
ServiceChecked::dispatch($this->server->team->id);
}
private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
{
// Parse docker compose to check for excluded 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) {
// Check if container should be excluded
$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
}
}
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, don't update status
if ($relevantStatuses->isEmpty()) {
return null;
}
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasExited = false;
foreach ($relevantStatuses as $status) {
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)';
}
// All containers are exited
return 'exited (unhealthy)';
}
}

View file

@ -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);
}
}

View file

@ -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.',

View file

@ -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",

View file

@ -1,268 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Jobs\CheckAndStartSentinelJob;
use App\Jobs\ServerStorageCheckJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Notifications\Container\ContainerRestarted;
use Illuminate\Support\Arr;
use Lorisleiva\Actions\Concerns\AsAction;
class ServerCheck
{
use AsAction;
public Server $server;
public bool $isSentinel = false;
public $containers;
public $databases;
public function handle(Server $server, $data = null)
{
$this->server = $server;
try {
if ($this->server->isFunctional() === false) {
return 'Server is not functional.';
}
if (! $this->server->isSwarmWorker() && ! $this->server->isBuildServer()) {
if (isset($data)) {
$data = collect($data);
$this->server->sentinelHeartbeat();
$this->containers = collect(data_get($data, 'containers'));
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
$containerReplicates = null;
$this->isSentinel = true;
} else {
['containers' => $this->containers, 'containerReplicates' => $containerReplicates] = $this->server->getContainers();
// ServerStorageCheckJob::dispatch($this->server);
}
if (is_null($this->containers)) {
return 'No containers found.';
}
if (isset($containerReplicates)) {
foreach ($containerReplicates as $containerReplica) {
$name = data_get($containerReplica, 'Name');
$this->containers = $this->containers->map(function ($container) use ($name, $containerReplica) {
if (data_get($container, 'Spec.Name') === $name) {
$replicas = data_get($containerReplica, 'Replicas');
$running = str($replicas)->explode('/')[0];
$total = str($replicas)->explode('/')[1];
if ($running === $total) {
data_set($container, 'State.Status', 'running');
data_set($container, 'State.Health.Status', 'healthy');
} else {
data_set($container, 'State.Status', 'starting');
data_set($container, 'State.Health.Status', 'unhealthy');
}
}
return $container;
});
}
}
$this->checkContainers();
if ($this->server->isSentinelEnabled() && $this->isSentinel === false) {
CheckAndStartSentinelJob::dispatch($this->server);
}
if ($this->server->isLogDrainEnabled()) {
$this->checkLogDrainContainer();
}
if ($this->server->proxySet() && ! $this->server->proxy->force_stop) {
$foundProxyContainer = $this->containers->filter(function ($value, $key) {
if ($this->server->isSwarm()) {
return data_get($value, 'Spec.Name') === 'coolify-proxy_traefik';
} else {
return data_get($value, 'Name') === '/coolify-proxy';
}
})->first();
$proxyStatus = data_get($foundProxyContainer, 'State.Status', 'exited');
if (! $foundProxyContainer || $proxyStatus !== 'running') {
try {
$shouldStart = CheckProxy::run($this->server);
if ($shouldStart) {
StartProxy::run($this->server, async: false);
$this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server));
}
} catch (\Throwable $e) {
}
} else {
$this->server->proxy->status = data_get($foundProxyContainer, 'State.Status');
$this->server->save();
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
}
}
}
} catch (\Throwable $e) {
return handleError($e);
}
}
private function checkLogDrainContainer()
{
$foundLogDrainContainer = $this->containers->filter(function ($value, $key) {
return data_get($value, 'Name') === '/coolify-log-drain';
})->first();
if ($foundLogDrainContainer) {
$status = data_get($foundLogDrainContainer, 'State.Status');
if ($status !== 'running') {
StartLogDrain::dispatch($this->server);
}
} else {
StartLogDrain::dispatch($this->server);
}
}
private function checkContainers()
{
foreach ($this->containers as $container) {
if ($this->isSentinel) {
$labels = Arr::undot(data_get($container, 'labels'));
} else {
if ($this->server->isSwarm()) {
$labels = Arr::undot(data_get($container, 'Spec.Labels'));
} else {
$labels = Arr::undot(data_get($container, 'Config.Labels'));
}
}
$managed = data_get($labels, 'coolify.managed');
if (! $managed) {
continue;
}
$uuid = data_get($labels, 'coolify.name');
if (! $uuid) {
$uuid = data_get($labels, 'com.docker.compose.service');
}
if ($this->isSentinel) {
$containerStatus = data_get($container, 'state');
$containerHealth = data_get($container, 'health_status');
} else {
$containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
}
$containerStatus = "$containerStatus ($containerHealth)";
$applicationId = data_get($labels, 'coolify.applicationId');
$serviceId = data_get($labels, 'coolify.serviceId');
$databaseId = data_get($labels, 'coolify.databaseId');
$pullRequestId = data_get($labels, 'coolify.pullRequestId');
if ($applicationId) {
// Application
if ($pullRequestId != 0) {
if (str($applicationId)->contains('-')) {
$applicationId = str($applicationId)->before('-');
}
$preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first();
if ($preview) {
$preview->update(['status' => $containerStatus]);
}
} else {
$application = Application::where('id', $applicationId)->first();
if ($application) {
$application->update([
'status' => $containerStatus,
'last_online_at' => now(),
]);
}
}
} elseif (isset($serviceId)) {
// Service
$subType = data_get($labels, 'coolify.service.subType');
$subId = data_get($labels, 'coolify.service.subId');
$service = Service::where('id', $serviceId)->first();
if (! $service) {
continue;
}
if ($subType === 'application') {
$service = ServiceApplication::where('id', $subId)->first();
} else {
$service = ServiceDatabase::where('id', $subId)->first();
}
if ($service) {
$service->update([
'status' => $containerStatus,
'last_online_at' => now(),
]);
if ($subType === 'database') {
$isPublic = data_get($service, 'is_public');
if ($isPublic) {
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
if ($this->isSentinel) {
return data_get($value, 'name') === $uuid.'-proxy';
} else {
if ($this->server->isSwarm()) {
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
} else {
return data_get($value, 'Name') === "/$uuid-proxy";
}
}
})->first();
if (! $foundTcpProxy) {
StartDatabaseProxy::run($service);
}
}
}
}
} else {
// Database
if (is_null($this->databases)) {
$this->databases = $this->server->databases();
}
$database = $this->databases->where('uuid', $uuid)->first();
if ($database) {
$database->update([
'status' => $containerStatus,
'last_online_at' => now(),
]);
$isPublic = data_get($database, 'is_public');
if ($isPublic) {
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
if ($this->isSentinel) {
return data_get($value, 'name') === $uuid.'-proxy';
} else {
if ($this->server->isSwarm()) {
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
} else {
return data_get($value, 'Name') === "/$uuid-proxy";
}
}
})->first();
if (! $foundTcpProxy) {
StartDatabaseProxy::run($database);
// $this->server->team?->notify(new ContainerRestarted("TCP Proxy for database", $this->server));
}
}
}
}
}
}
}

View file

@ -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') {

View file

@ -10,7 +10,7 @@ class StartSentinel
{
use AsAction;
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null)
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null)
{
if ($server->isSwarm() || $server->isBuildServer()) {
return;
@ -44,7 +44,9 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
];
if (isDev()) {
// data_set($environments, 'DEBUG', 'true');
// $image = 'sentinel';
if ($customImage && ! empty($customImage)) {
$image = $customImage;
}
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
}
$dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"';

View file

@ -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';
}
}

View file

@ -0,0 +1,151 @@
<?php
namespace App\Actions\Stripe;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Support\Collection;
use Stripe\StripeClient;
class CancelSubscription
{
private User $user;
private bool $isDryRun;
private ?StripeClient $stripe = null;
public function __construct(User $user, bool $isDryRun = false)
{
$this->user = $user;
$this->isDryRun = $isDryRun;
if (! $isDryRun && isCloud()) {
$this->stripe = new StripeClient(config('subscription.stripe_api_key'));
}
}
public function getSubscriptionsPreview(): Collection
{
$subscriptions = collect();
// Get all teams the user belongs to
$teams = $this->user->teams;
foreach ($teams as $team) {
// Only include subscriptions from teams where user is owner
$userRole = $team->pivot->role;
if ($userRole === 'owner' && $team->subscription) {
$subscription = $team->subscription;
// Only include active subscriptions
if ($subscription->stripe_subscription_id &&
$subscription->stripe_invoice_paid) {
$subscriptions->push($subscription);
}
}
}
return $subscriptions;
}
public function execute(): array
{
if ($this->isDryRun) {
return [
'cancelled' => 0,
'failed' => 0,
'errors' => [],
];
}
$cancelledCount = 0;
$failedCount = 0;
$errors = [];
$subscriptions = $this->getSubscriptionsPreview();
foreach ($subscriptions as $subscription) {
try {
$this->cancelSingleSubscription($subscription);
$cancelledCount++;
} catch (\Exception $e) {
$failedCount++;
$errorMessage = "Failed to cancel subscription {$subscription->stripe_subscription_id}: ".$e->getMessage();
$errors[] = $errorMessage;
\Log::error($errorMessage);
}
}
return [
'cancelled' => $cancelledCount,
'failed' => $failedCount,
'errors' => $errors,
];
}
private function cancelSingleSubscription(Subscription $subscription): void
{
if (! $this->stripe) {
throw new \Exception('Stripe client not initialized');
}
$subscriptionId = $subscription->stripe_subscription_id;
// Cancel the subscription immediately (not at period end)
$this->stripe->subscriptions->cancel($subscriptionId, []);
// Update local database
$subscription->update([
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_past_due' => false,
'stripe_feedback' => 'User account deleted',
'stripe_comment' => 'Subscription cancelled due to user account deletion at '.now()->toDateTimeString(),
]);
// Call the team's subscription ended method to handle cleanup
if ($subscription->team) {
$subscription->team->subscriptionEnded();
}
\Log::info("Cancelled Stripe subscription: {$subscriptionId} for team: {$subscription->team->name}");
}
/**
* Cancel a single subscription by ID (helper method for external use)
*/
public static function cancelById(string $subscriptionId): bool
{
try {
if (! isCloud()) {
return false;
}
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripe->subscriptions->cancel($subscriptionId, []);
// Update local record if exists
$subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->first();
if ($subscription) {
$subscription->update([
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_past_due' => false,
]);
if ($subscription->team) {
$subscription->team->subscriptionEnded();
}
}
return true;
} catch (\Exception $e) {
\Log::error("Failed to cancel subscription {$subscriptionId}: ".$e->getMessage());
return false;
}
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace App\Actions\User;
use App\Models\User;
use Illuminate\Support\Collection;
class DeleteUserResources
{
private User $user;
private bool $isDryRun;
public function __construct(User $user, bool $isDryRun = false)
{
$this->user = $user;
$this->isDryRun = $isDryRun;
}
public function getResourcesPreview(): array
{
$applications = collect();
$databases = collect();
$services = collect();
// Get all teams the user belongs to
$teams = $this->user->teams;
foreach ($teams as $team) {
// Get all servers for this team
$servers = $team->servers;
foreach ($servers as $server) {
// Get applications
$serverApplications = $server->applications;
$applications = $applications->merge($serverApplications);
// Get databases
$serverDatabases = $this->getAllDatabasesForServer($server);
$databases = $databases->merge($serverDatabases);
// Get services
$serverServices = $server->services;
$services = $services->merge($serverServices);
}
}
return [
'applications' => $applications->unique('id'),
'databases' => $databases->unique('id'),
'services' => $services->unique('id'),
];
}
public function execute(): array
{
if ($this->isDryRun) {
return [
'applications' => 0,
'databases' => 0,
'services' => 0,
];
}
$deletedCounts = [
'applications' => 0,
'databases' => 0,
'services' => 0,
];
$resources = $this->getResourcesPreview();
// Delete applications
foreach ($resources['applications'] as $application) {
try {
$application->forceDelete();
$deletedCounts['applications']++;
} catch (\Exception $e) {
\Log::error("Failed to delete application {$application->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
// Delete databases
foreach ($resources['databases'] as $database) {
try {
$database->forceDelete();
$deletedCounts['databases']++;
} catch (\Exception $e) {
\Log::error("Failed to delete database {$database->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
// Delete services
foreach ($resources['services'] as $service) {
try {
$service->forceDelete();
$deletedCounts['services']++;
} catch (\Exception $e) {
\Log::error("Failed to delete service {$service->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
return $deletedCounts;
}
private function getAllDatabasesForServer($server): Collection
{
$databases = collect();
// Get all standalone database types
$databases = $databases->merge($server->postgresqls);
$databases = $databases->merge($server->mysqls);
$databases = $databases->merge($server->mariadbs);
$databases = $databases->merge($server->mongodbs);
$databases = $databases->merge($server->redis);
$databases = $databases->merge($server->keydbs);
$databases = $databases->merge($server->dragonflies);
$databases = $databases->merge($server->clickhouses);
return $databases;
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace App\Actions\User;
use App\Models\Server;
use App\Models\User;
use Illuminate\Support\Collection;
class DeleteUserServers
{
private User $user;
private bool $isDryRun;
public function __construct(User $user, bool $isDryRun = false)
{
$this->user = $user;
$this->isDryRun = $isDryRun;
}
public function getServersPreview(): Collection
{
$servers = collect();
// Get all teams the user belongs to
$teams = $this->user->teams;
foreach ($teams as $team) {
// Only include servers from teams where user is owner or admin
$userRole = $team->pivot->role;
if ($userRole === 'owner' || $userRole === 'admin') {
$teamServers = $team->servers;
$servers = $servers->merge($teamServers);
}
}
// Return unique servers (in case same server is in multiple teams)
return $servers->unique('id');
}
public function execute(): array
{
if ($this->isDryRun) {
return [
'servers' => 0,
];
}
$deletedCount = 0;
$servers = $this->getServersPreview();
foreach ($servers as $server) {
try {
// Skip the default server (ID 0) which is the Coolify host
if ($server->id === 0) {
\Log::info('Skipping deletion of Coolify host server (ID: 0)');
continue;
}
// The Server model's forceDeleting event will handle cleanup of:
// - destinations
// - settings
$server->forceDelete();
$deletedCount++;
} catch (\Exception $e) {
\Log::error("Failed to delete server {$server->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
return [
'servers' => $deletedCount,
];
}
}

View file

@ -0,0 +1,202 @@
<?php
namespace App\Actions\User;
use App\Models\Team;
use App\Models\User;
class DeleteUserTeams
{
private User $user;
private bool $isDryRun;
public function __construct(User $user, bool $isDryRun = false)
{
$this->user = $user;
$this->isDryRun = $isDryRun;
}
public function getTeamsPreview(): array
{
$teamsToDelete = collect();
$teamsToTransfer = collect();
$teamsToLeave = collect();
$edgeCases = collect();
$teams = $this->user->teams;
foreach ($teams as $team) {
// Skip root team (ID 0)
if ($team->id === 0) {
continue;
}
$userRole = $team->pivot->role;
$memberCount = $team->members->count();
if ($memberCount === 1) {
// User is alone in the team - delete it
$teamsToDelete->push($team);
} elseif ($userRole === 'owner') {
// Check if there are other owners
$otherOwners = $team->members
->where('id', '!=', $this->user->id)
->filter(function ($member) {
return $member->pivot->role === 'owner';
});
if ($otherOwners->isNotEmpty()) {
// There are other owners, but check if this user is paying for the subscription
if ($this->isUserPayingForTeamSubscription($team)) {
// User is paying for the subscription - this is an edge case
$edgeCases->push([
'team' => $team,
'reason' => 'User is paying for the team\'s Stripe subscription but there are other owners. The subscription needs to be cancelled or transferred to another owner\'s payment method.',
]);
} else {
// There are other owners and user is not paying, just remove this user
$teamsToLeave->push($team);
}
} else {
// User is the only owner, check for replacement
$newOwner = $this->findNewOwner($team);
if ($newOwner) {
$teamsToTransfer->push([
'team' => $team,
'new_owner' => $newOwner,
]);
} else {
// No suitable replacement found - this is an edge case
$edgeCases->push([
'team' => $team,
'reason' => 'No suitable owner replacement found. Team has only regular members without admin privileges.',
]);
}
}
} else {
// User is just a member - remove them from the team
$teamsToLeave->push($team);
}
}
return [
'to_delete' => $teamsToDelete,
'to_transfer' => $teamsToTransfer,
'to_leave' => $teamsToLeave,
'edge_cases' => $edgeCases,
];
}
public function execute(): array
{
if ($this->isDryRun) {
return [
'deleted' => 0,
'transferred' => 0,
'left' => 0,
];
}
$counts = [
'deleted' => 0,
'transferred' => 0,
'left' => 0,
];
$preview = $this->getTeamsPreview();
// Check for edge cases - should not happen here as we check earlier, but be safe
if ($preview['edge_cases']->isNotEmpty()) {
throw new \Exception('Edge cases detected during execution. This should not happen.');
}
// Delete teams where user is alone
foreach ($preview['to_delete'] as $team) {
try {
// The Team model's deleting event will handle cleanup of:
// - private keys
// - sources
// - tags
// - environment variables
// - s3 storages
// - notification settings
$team->delete();
$counts['deleted']++;
} catch (\Exception $e) {
\Log::error("Failed to delete team {$team->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
// Transfer ownership for teams where user is owner but not alone
foreach ($preview['to_transfer'] as $item) {
try {
$team = $item['team'];
$newOwner = $item['new_owner'];
// Update the new owner's role to owner
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
// Remove the current user from the team
$team->members()->detach($this->user->id);
$counts['transferred']++;
} catch (\Exception $e) {
\Log::error("Failed to transfer ownership of team {$item['team']->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
// Remove user from teams where they're just a member
foreach ($preview['to_leave'] as $team) {
try {
$team->members()->detach($this->user->id);
$counts['left']++;
} catch (\Exception $e) {
\Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());
throw $e; // Re-throw to trigger rollback
}
}
return $counts;
}
private function findNewOwner(Team $team): ?User
{
// Only look for admins as potential new owners
// We don't promote regular members automatically
$otherAdmin = $team->members
->where('id', '!=', $this->user->id)
->filter(function ($member) {
return $member->pivot->role === 'admin';
})
->first();
return $otherAdmin;
}
private function isUserPayingForTeamSubscription(Team $team): bool
{
if (! $team->subscription || ! $team->subscription->stripe_customer_id) {
return false;
}
// In Stripe, we need to check if the customer email matches the user's email
// This would require a Stripe API call to get customer details
// For now, we'll check if the subscription was created by this user
// Alternative approach: Check if user is the one who initiated the subscription
// We could store this information when the subscription is created
// For safety, we'll assume if there's an active subscription and multiple owners,
// we should treat it as an edge case that needs manual review
if ($team->subscription->stripe_subscription_id &&
$team->subscription->stripe_invoice_paid) {
// Active subscription exists - we should be cautious
return true;
}
return false;
}
}

View file

@ -0,0 +1,722 @@
<?php
namespace App\Console\Commands;
use App\Actions\Stripe\CancelSubscription;
use App\Actions\User\DeleteUserResources;
use App\Actions\User\DeleteUserServers;
use App\Actions\User\DeleteUserTeams;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class CloudDeleteUser extends Command
{
protected $signature = 'cloud:delete-user {email}
{--dry-run : Preview what will be deleted without actually deleting}
{--skip-stripe : Skip Stripe subscription cancellation}
{--skip-resources : Skip resource deletion}';
protected $description = 'Delete a user from the cloud instance with phase-by-phase confirmation';
private bool $isDryRun = false;
private bool $skipStripe = false;
private bool $skipResources = false;
private User $user;
public function handle()
{
if (! isCloud()) {
$this->error('This command is only available on cloud instances.');
return 1;
}
$email = $this->argument('email');
$this->isDryRun = $this->option('dry-run');
$this->skipStripe = $this->option('skip-stripe');
$this->skipResources = $this->option('skip-resources');
if ($this->isDryRun) {
$this->info('🔍 DRY RUN MODE - No data will be deleted');
$this->newLine();
}
try {
$this->user = User::whereEmail($email)->firstOrFail();
} catch (\Exception $e) {
$this->error("User with email '{$email}' not found.");
return 1;
}
$this->logAction("Starting user deletion process for: {$email}");
// Phase 1: Show User Overview (outside transaction)
if (! $this->showUserOverview()) {
$this->info('User deletion cancelled.');
return 0;
}
// If not dry run, wrap everything in a transaction
if (! $this->isDryRun) {
try {
DB::beginTransaction();
// Phase 2: Delete Resources
if (! $this->skipResources) {
if (! $this->deleteResources()) {
DB::rollBack();
$this->error('User deletion failed at resource deletion phase. All changes rolled back.');
return 1;
}
}
// Phase 3: Delete Servers
if (! $this->deleteServers()) {
DB::rollBack();
$this->error('User deletion failed at server deletion phase. All changes rolled back.');
return 1;
}
// Phase 4: Handle Teams
if (! $this->handleTeams()) {
DB::rollBack();
$this->error('User deletion failed at team handling phase. All changes rolled back.');
return 1;
}
// Phase 5: Cancel Stripe Subscriptions
if (! $this->skipStripe && isCloud()) {
if (! $this->cancelStripeSubscriptions()) {
DB::rollBack();
$this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.');
return 1;
}
}
// Phase 6: Delete User Profile
if (! $this->deleteUserProfile()) {
DB::rollBack();
$this->error('User deletion failed at final phase. All changes rolled back.');
return 1;
}
// Commit the transaction
DB::commit();
$this->newLine();
$this->info('✅ User deletion completed successfully!');
$this->logAction("User deletion completed for: {$email}");
} catch (\Exception $e) {
DB::rollBack();
$this->error('An error occurred during user deletion: '.$e->getMessage());
$this->logAction("User deletion failed for {$email}: ".$e->getMessage());
return 1;
}
} else {
// Dry run mode - just run through the phases without transaction
// Phase 2: Delete Resources
if (! $this->skipResources) {
if (! $this->deleteResources()) {
$this->info('User deletion would be cancelled at resource deletion phase.');
return 0;
}
}
// Phase 3: Delete Servers
if (! $this->deleteServers()) {
$this->info('User deletion would be cancelled at server deletion phase.');
return 0;
}
// Phase 4: Handle Teams
if (! $this->handleTeams()) {
$this->info('User deletion would be cancelled at team handling phase.');
return 0;
}
// Phase 5: Cancel Stripe Subscriptions
if (! $this->skipStripe && isCloud()) {
if (! $this->cancelStripeSubscriptions()) {
$this->info('User deletion would be cancelled at Stripe cancellation phase.');
return 0;
}
}
// Phase 6: Delete User Profile
if (! $this->deleteUserProfile()) {
$this->info('User deletion would be cancelled at final phase.');
return 0;
}
$this->newLine();
$this->info('✅ DRY RUN completed successfully! No data was deleted.');
}
return 0;
}
private function showUserOverview(): bool
{
$this->info('═══════════════════════════════════════');
$this->info('PHASE 1: USER OVERVIEW');
$this->info('═══════════════════════════════════════');
$this->newLine();
$teams = $this->user->teams;
$ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner');
$memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner');
// Collect all servers from all teams
$allServers = collect();
$allApplications = collect();
$allDatabases = collect();
$allServices = collect();
$activeSubscriptions = collect();
foreach ($teams as $team) {
$servers = $team->servers;
$allServers = $allServers->merge($servers);
foreach ($servers as $server) {
$resources = $server->definedResources();
foreach ($resources as $resource) {
if ($resource instanceof \App\Models\Application) {
$allApplications->push($resource);
} elseif ($resource instanceof \App\Models\Service) {
$allServices->push($resource);
} else {
$allDatabases->push($resource);
}
}
}
if ($team->subscription && $team->subscription->stripe_subscription_id) {
$activeSubscriptions->push($team->subscription);
}
}
$this->table(
['Property', 'Value'],
[
['User', $this->user->email],
['User ID', $this->user->id],
['Created', $this->user->created_at->format('Y-m-d H:i:s')],
['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')],
['Teams (Total)', $teams->count()],
['Teams (Owner)', $ownedTeams->count()],
['Teams (Member)', $memberTeams->count()],
['Servers', $allServers->unique('id')->count()],
['Applications', $allApplications->count()],
['Databases', $allDatabases->count()],
['Services', $allServices->count()],
['Active Stripe Subscriptions', $activeSubscriptions->count()],
]
);
$this->newLine();
$this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!');
$this->newLine();
if (! $this->confirm('Do you want to continue with the deletion process?', false)) {
return false;
}
return true;
}
private function deleteResources(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 2: DELETE RESOURCES');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new DeleteUserResources($this->user, $this->isDryRun);
$resources = $action->getResourcesPreview();
if ($resources['applications']->isEmpty() &&
$resources['databases']->isEmpty() &&
$resources['services']->isEmpty()) {
$this->info('No resources to delete.');
return true;
}
$this->info('Resources to be deleted:');
$this->newLine();
if ($resources['applications']->isNotEmpty()) {
$this->warn("Applications to be deleted ({$resources['applications']->count()}):");
$this->table(
['Name', 'UUID', 'Server', 'Status'],
$resources['applications']->map(function ($app) {
return [
$app->name,
$app->uuid,
$app->destination->server->name,
$app->status ?? 'unknown',
];
})->toArray()
);
$this->newLine();
}
if ($resources['databases']->isNotEmpty()) {
$this->warn("Databases to be deleted ({$resources['databases']->count()}):");
$this->table(
['Name', 'Type', 'UUID', 'Server'],
$resources['databases']->map(function ($db) {
return [
$db->name,
class_basename($db),
$db->uuid,
$db->destination->server->name,
];
})->toArray()
);
$this->newLine();
}
if ($resources['services']->isNotEmpty()) {
$this->warn("Services to be deleted ({$resources['services']->count()}):");
$this->table(
['Name', 'UUID', 'Server'],
$resources['services']->map(function ($service) {
return [
$service->name,
$service->uuid,
$service->server->name,
];
})->toArray()
);
$this->newLine();
}
$this->error('⚠️ THIS ACTION CANNOT BE UNDONE!');
if (! $this->confirm('Are you sure you want to delete all these resources?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Deleting resources...');
$result = $action->execute();
$this->info("Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services");
$this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services");
}
return true;
}
private function deleteServers(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 3: DELETE SERVERS');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new DeleteUserServers($this->user, $this->isDryRun);
$servers = $action->getServersPreview();
if ($servers->isEmpty()) {
$this->info('No servers to delete.');
return true;
}
$this->warn("Servers to be deleted ({$servers->count()}):");
$this->table(
['ID', 'Name', 'IP', 'Description', 'Resources Count'],
$servers->map(function ($server) {
$resourceCount = $server->definedResources()->count();
return [
$server->id,
$server->name,
$server->ip,
$server->description ?? '-',
$resourceCount,
];
})->toArray()
);
$this->newLine();
$this->error('⚠️ WARNING: Deleting servers will remove all server configurations!');
if (! $this->confirm('Are you sure you want to delete all these servers?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Deleting servers...');
$result = $action->execute();
$this->info("Deleted {$result['servers']} servers");
$this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}");
}
return true;
}
private function handleTeams(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 4: HANDLE TEAMS');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new DeleteUserTeams($this->user, $this->isDryRun);
$preview = $action->getTeamsPreview();
// Check for edge cases first - EXIT IMMEDIATELY if found
if ($preview['edge_cases']->isNotEmpty()) {
$this->error('═══════════════════════════════════════');
$this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED');
$this->error('═══════════════════════════════════════');
$this->newLine();
foreach ($preview['edge_cases'] as $edgeCase) {
$team = $edgeCase['team'];
$reason = $edgeCase['reason'];
$this->error("Team: {$team->name} (ID: {$team->id})");
$this->error("Issue: {$reason}");
// Show team members for context
$this->info('Current members:');
foreach ($team->members as $member) {
$role = $member->pivot->role;
$this->line(" - {$member->name} ({$member->email}) - Role: {$role}");
}
// Check for active resources
$resourceCount = 0;
foreach ($team->servers as $server) {
$resources = $server->definedResources();
$resourceCount += $resources->count();
}
if ($resourceCount > 0) {
$this->warn(" ⚠️ This team has {$resourceCount} active resources!");
}
// Show subscription details if relevant
if ($team->subscription && $team->subscription->stripe_subscription_id) {
$this->warn(' ⚠️ Active Stripe subscription details:');
$this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}");
$this->warn(" Customer ID: {$team->subscription->stripe_customer_id}");
// Show other owners who could potentially take over
$otherOwners = $team->members
->where('id', '!=', $this->user->id)
->filter(function ($member) {
return $member->pivot->role === 'owner';
});
if ($otherOwners->isNotEmpty()) {
$this->info(' Other owners who could take over billing:');
foreach ($otherOwners as $owner) {
$this->line(" - {$owner->name} ({$owner->email})");
}
}
}
$this->newLine();
}
$this->error('Please resolve these issues manually before retrying:');
// Check if any edge case involves subscription payment issues
$hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) {
return str_contains($edgeCase['reason'], 'Stripe subscription');
});
if ($hasSubscriptionIssue) {
$this->info('For teams with subscription payment issues:');
$this->info('1. Cancel the subscription through Stripe dashboard, OR');
$this->info('2. Transfer the subscription to another owner\'s payment method, OR');
$this->info('3. Have the other owner create a new subscription after cancelling this one');
$this->newLine();
}
$hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) {
return str_contains($edgeCase['reason'], 'No suitable owner replacement');
});
if ($hasNoOwnerReplacement) {
$this->info('For teams with no suitable owner replacement:');
$this->info('1. Assign an admin role to a trusted member, OR');
$this->info('2. Transfer team resources to another team, OR');
$this->info('3. Delete the team manually if no longer needed');
$this->newLine();
}
$this->error('USER DELETION ABORTED DUE TO EDGE CASES');
$this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling");
// Exit immediately - don't proceed with deletion
if (! $this->isDryRun) {
DB::rollBack();
}
exit(1);
}
if ($preview['to_delete']->isEmpty() &&
$preview['to_transfer']->isEmpty() &&
$preview['to_leave']->isEmpty()) {
$this->info('No team changes needed.');
return true;
}
if ($preview['to_delete']->isNotEmpty()) {
$this->warn('Teams to be DELETED (user is the only member):');
$this->table(
['ID', 'Name', 'Resources', 'Subscription'],
$preview['to_delete']->map(function ($team) {
$resourceCount = 0;
foreach ($team->servers as $server) {
$resourceCount += $server->definedResources()->count();
}
$hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id
? '⚠️ YES - '.$team->subscription->stripe_subscription_id
: 'No';
return [
$team->id,
$team->name,
$resourceCount,
$hasSubscription,
];
})->toArray()
);
$this->newLine();
}
if ($preview['to_transfer']->isNotEmpty()) {
$this->warn('Teams where ownership will be TRANSFERRED:');
$this->table(
['Team ID', 'Team Name', 'New Owner', 'New Owner Email'],
$preview['to_transfer']->map(function ($item) {
return [
$item['team']->id,
$item['team']->name,
$item['new_owner']->name,
$item['new_owner']->email,
];
})->toArray()
);
$this->newLine();
}
if ($preview['to_leave']->isNotEmpty()) {
$this->warn('Teams where user will be REMOVED (other owners/admins exist):');
$userId = $this->user->id;
$this->table(
['ID', 'Name', 'User Role', 'Other Members'],
$preview['to_leave']->map(function ($team) use ($userId) {
$userRole = $team->members->where('id', $userId)->first()->pivot->role;
$otherMembers = $team->members->count() - 1;
return [
$team->id,
$team->name,
$userRole,
$otherMembers,
];
})->toArray()
);
$this->newLine();
}
$this->error('⚠️ WARNING: Team changes affect access control and ownership!');
if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Processing team changes...');
$result = $action->execute();
$this->info("Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}");
$this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}");
}
return true;
}
private function cancelStripeSubscriptions(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 5: CANCEL STRIPE SUBSCRIPTIONS');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new CancelSubscription($this->user, $this->isDryRun);
$subscriptions = $action->getSubscriptionsPreview();
if ($subscriptions->isEmpty()) {
$this->info('No Stripe subscriptions to cancel.');
return true;
}
$this->info('Stripe subscriptions to cancel:');
$this->newLine();
$totalMonthlyValue = 0;
foreach ($subscriptions as $subscription) {
$team = $subscription->team;
$planId = $subscription->stripe_plan_id;
// Try to get the price from config
$monthlyValue = $this->getSubscriptionMonthlyValue($planId);
$totalMonthlyValue += $monthlyValue;
$this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})");
if ($monthlyValue > 0) {
$this->line(" Monthly value: \${$monthlyValue}");
}
if ($subscription->stripe_cancel_at_period_end) {
$this->line(' ⚠️ Already set to cancel at period end');
}
}
if ($totalMonthlyValue > 0) {
$this->newLine();
$this->warn("Total monthly value: \${$totalMonthlyValue}");
}
$this->newLine();
$this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!');
if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Cancelling subscriptions...');
$result = $action->execute();
$this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed");
if ($result['failed'] > 0 && ! empty($result['errors'])) {
$this->error('Failed subscriptions:');
foreach ($result['errors'] as $error) {
$this->error(" - {$error}");
}
}
$this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}");
}
return true;
}
private function deleteUserProfile(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 6: DELETE USER PROFILE');
$this->info('═══════════════════════════════════════');
$this->newLine();
$this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!');
$this->newLine();
$this->info('User profile to be deleted:');
$this->table(
['Property', 'Value'],
[
['Email', $this->user->email],
['Name', $this->user->name],
['User ID', $this->user->id],
['Created', $this->user->created_at->format('Y-m-d H:i:s')],
['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'],
['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'],
]
);
$this->newLine();
$this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:");
$confirmation = $this->ask('Confirmation');
if ($confirmation !== "DELETE {$this->user->email}") {
$this->error('Confirmation text does not match. Deletion cancelled.');
return false;
}
if (! $this->isDryRun) {
$this->info('Deleting user profile...');
try {
$this->user->delete();
$this->info('User profile deleted successfully.');
$this->logAction("User profile deleted: {$this->user->email}");
} catch (\Exception $e) {
$this->error('Failed to delete user profile: '.$e->getMessage());
$this->logAction("Failed to delete user profile {$this->user->email}: ".$e->getMessage());
return false;
}
}
return true;
}
private function getSubscriptionMonthlyValue(string $planId): int
{
// Map plan IDs to monthly values based on config
$subscriptionConfigs = config('subscription');
foreach ($subscriptionConfigs as $key => $value) {
if ($value === $planId && str_contains($key, 'stripe_price_id_')) {
// Extract price from key pattern: stripe_price_id_basic_monthly -> basic
$planType = str($key)->after('stripe_price_id_')->before('_')->toString();
// Map to known prices (you may need to adjust these based on your actual pricing)
return match ($planType) {
'basic' => 29,
'pro' => 49,
'ultimate' => 99,
default => 0
};
}
}
return 0;
}
private function logAction(string $message): void
{
$logMessage = "[CloudDeleteUser] {$message}";
if ($this->isDryRun) {
$logMessage = "[DRY RUN] {$logMessage}";
}
Log::channel('single')->info($logMessage);
// Also log to a dedicated user deletion log file
$logFile = storage_path('logs/user-deletions.log');
$timestamp = now()->format('Y-m-d H:i:s');
file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX);
}
}

View file

@ -2429,7 +2429,6 @@ public function envs(Request $request)
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@ -2470,7 +2469,7 @@ public function envs(Request $request)
)]
public function update_env_by_uuid(Request $request)
{
$allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal'];
$allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -2495,7 +2494,6 @@ public function update_env_by_uuid(Request $request)
'key' => 'string|required',
'value' => 'string|nullable',
'is_preview' => 'boolean',
'is_build_time' => 'boolean',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
@ -2516,16 +2514,12 @@ public function update_env_by_uuid(Request $request)
], 422);
}
$is_preview = $request->is_preview ?? false;
$is_build_time = $request->is_build_time ?? false;
$is_literal = $request->is_literal ?? false;
$key = str($request->key)->trim()->replace(' ', '_')->value;
if ($is_preview) {
$env = $application->environment_variables_preview->where('key', $key)->first();
if ($env) {
$env->value = $request->value;
if ($env->is_build_time != $is_build_time) {
$env->is_build_time = $is_build_time;
}
if ($env->is_literal != $is_literal) {
$env->is_literal = $is_literal;
}
@ -2538,6 +2532,12 @@ 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_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();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@ -2550,9 +2550,6 @@ public function update_env_by_uuid(Request $request)
$env = $application->environment_variables->where('key', $key)->first();
if ($env) {
$env->value = $request->value;
if ($env->is_build_time != $is_build_time) {
$env->is_build_time = $is_build_time;
}
if ($env->is_literal != $is_literal) {
$env->is_literal = $is_literal;
}
@ -2565,6 +2562,12 @@ 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_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();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@ -2619,7 +2622,6 @@ public function update_env_by_uuid(Request $request)
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@ -2690,7 +2692,7 @@ public function create_bulk_envs(Request $request)
], 400);
}
$bulk_data = collect($bulk_data)->map(function ($item) {
return collect($item)->only(['key', 'value', 'is_preview', 'is_build_time', 'is_literal']);
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal']);
});
$returnedEnvs = collect();
foreach ($bulk_data as $item) {
@ -2698,7 +2700,6 @@ public function create_bulk_envs(Request $request)
'key' => 'string|required',
'value' => 'string|nullable',
'is_preview' => 'boolean',
'is_build_time' => 'boolean',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
@ -2710,7 +2711,6 @@ public function create_bulk_envs(Request $request)
], 422);
}
$is_preview = $item->get('is_preview') ?? false;
$is_build_time = $item->get('is_build_time') ?? false;
$is_literal = $item->get('is_literal') ?? false;
$is_multi_line = $item->get('is_multiline') ?? false;
$is_shown_once = $item->get('is_shown_once') ?? false;
@ -2719,9 +2719,7 @@ public function create_bulk_envs(Request $request)
$env = $application->environment_variables_preview->where('key', $key)->first();
if ($env) {
$env->value = $item->get('value');
if ($env->is_build_time != $is_build_time) {
$env->is_build_time = $is_build_time;
}
if ($env->is_literal != $is_literal) {
$env->is_literal = $is_literal;
}
@ -2731,16 +2729,23 @@ 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_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 {
$env = $application->environment_variables()->create([
'key' => $item->get('key'),
'value' => $item->get('value'),
'is_preview' => $is_preview,
'is_build_time' => $is_build_time,
'is_literal' => $is_literal,
'is_multiline' => $is_multi_line,
'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@ -2749,9 +2754,6 @@ public function create_bulk_envs(Request $request)
$env = $application->environment_variables->where('key', $key)->first();
if ($env) {
$env->value = $item->get('value');
if ($env->is_build_time != $is_build_time) {
$env->is_build_time = $is_build_time;
}
if ($env->is_literal != $is_literal) {
$env->is_literal = $is_literal;
}
@ -2761,16 +2763,23 @@ 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_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 {
$env = $application->environment_variables()->create([
'key' => $item->get('key'),
'value' => $item->get('value'),
'is_preview' => $is_preview,
'is_build_time' => $is_build_time,
'is_literal' => $is_literal,
'is_multiline' => $is_multi_line,
'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@ -2814,7 +2823,6 @@ public function create_bulk_envs(Request $request)
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@ -2854,7 +2862,7 @@ public function create_bulk_envs(Request $request)
)]
public function create_env(Request $request)
{
$allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal'];
$allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -2874,7 +2882,6 @@ public function create_env(Request $request)
'key' => 'string|required',
'value' => 'string|nullable',
'is_preview' => 'boolean',
'is_build_time' => 'boolean',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
@ -2908,10 +2915,11 @@ public function create_env(Request $request)
'key' => $request->key,
'value' => $request->value,
'is_preview' => $request->is_preview ?? false,
'is_build_time' => $request->is_build_time ?? false,
'is_literal' => $request->is_literal ?? false,
'is_multiline' => $request->is_multiline ?? false,
'is_shown_once' => $request->is_shown_once ?? false,
'is_runtime' => $request->is_runtime ?? true,
'is_buildtime' => $request->is_buildtime ?? true,
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@ -2931,10 +2939,11 @@ public function create_env(Request $request)
'key' => $request->key,
'value' => $request->value,
'is_preview' => $request->is_preview ?? false,
'is_build_time' => $request->is_build_time ?? false,
'is_literal' => $request->is_literal ?? false,
'is_multiline' => $request->is_multiline ?? false,
'is_shown_once' => $request->is_shown_once ?? false,
'is_runtime' => $request->is_runtime ?? true,
'is_buildtime' => $request->is_buildtime ?? true,
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);

View file

@ -353,7 +353,6 @@ public function create_service(Request $request)
'value' => $generatedValue,
'resourceable_id' => $service->id,
'resourceable_type' => $service->getMorphClass(),
'is_build_time' => false,
'is_preview' => false,
]);
});
@ -919,7 +918,6 @@ public function envs(Request $request)
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@ -975,7 +973,6 @@ public function update_env_by_uuid(Request $request)
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'value' => 'string|nullable',
'is_build_time' => 'boolean',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
@ -1039,7 +1036,6 @@ public function update_env_by_uuid(Request $request)
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@ -1105,7 +1101,6 @@ public function create_bulk_envs(Request $request)
$validator = customApiValidator($item, [
'key' => 'string|required',
'value' => 'string|nullable',
'is_build_time' => 'boolean',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
@ -1161,7 +1156,6 @@ public function create_bulk_envs(Request $request)
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'],
@ -1216,7 +1210,6 @@ public function create_env(Request $request)
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'value' => 'string|nullable',
'is_build_time' => 'boolean',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',

File diff suppressed because it is too large Load diff

View file

@ -1,31 +0,0 @@
<?php
namespace App\Jobs;
use App\Actions\Docker\GetContainersStatus;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class DEPRECATEDContainerStatusJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 4;
public function backoff(): int
{
return isDev() ? 1 : 3;
}
public function __construct(public Server $server) {}
public function handle()
{
GetContainersStatus::run($this->server);
}
}

View file

@ -1,34 +0,0 @@
<?php
namespace App\Jobs;
use App\Actions\Server\ResourcesCheck;
use App\Actions\Server\ServerCheck;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class DEPRECATEDServerCheckNewJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 60;
public function __construct(public Server $server) {}
public function handle()
{
try {
ServerCheck::run($this->server);
ResourcesCheck::dispatch($this->server);
} catch (\Throwable $e) {
return handleError($e);
}
}
}

View file

@ -1,162 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
class DEPRECATEDServerResourceManager implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The time when this job execution started.
*/
private ?Carbon $executionTime = null;
private InstanceSettings $settings;
private string $instanceTimezone;
/**
* Create a new job instance.
*/
public function __construct()
{
$this->onQueue('high');
}
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [
(new WithoutOverlapping('server-resource-manager'))
->releaseAfter(60),
];
}
public function handle(): void
{
// Freeze the execution time at the start of the job
$this->executionTime = Carbon::now();
$this->settings = instanceSettings();
$this->instanceTimezone = $this->settings->instance_timezone ?: config('app.timezone');
if (validate_timezone($this->instanceTimezone) === false) {
$this->instanceTimezone = config('app.timezone');
}
// Process server checks - don't let failures stop the job
try {
$this->processServerChecks();
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process server checks', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
private function processServerChecks(): void
{
$servers = $this->getServers();
foreach ($servers as $server) {
try {
$this->processServer($server);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing server', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
]);
}
}
}
private function getServers()
{
$allServers = Server::where('ip', '!=', '1.2.3.4');
if (isCloud()) {
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
$own = Team::find(0)->servers;
return $servers->merge($own);
} else {
return $allServers->get();
}
}
private function processServer(Server $server): void
{
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
if (validate_timezone($serverTimezone) === false) {
$serverTimezone = config('app.timezone');
}
// Sentinel check
$lastSentinelUpdate = $server->sentinel_updated_at;
if (Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($server->waitBeforeDoingSshCheck()))) {
// Dispatch ServerCheckJob if due
$checkFrequency = isCloud() ? '*/5 * * * *' : '* * * * *'; // Every 5 min for cloud, every minute for self-hosted
if ($this->shouldRunNow($checkFrequency, $serverTimezone)) {
ServerCheckJob::dispatch($server);
}
// Dispatch ServerStorageCheckJob if due
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
}
if ($this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone)) {
ServerStorageCheckJob::dispatch($server);
}
}
// Dispatch DockerCleanupJob if due
$dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) {
$dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency];
}
if ($this->shouldRunNow($dockerCleanupFrequency, $serverTimezone)) {
DockerCleanupJob::dispatch($server, false, $server->settings->delete_unused_volumes, $server->settings->delete_unused_networks);
}
// Dispatch ServerPatchCheckJob if due (weekly)
if ($this->shouldRunNow('0 0 * * 0', $serverTimezone)) { // Weekly on Sunday at midnight
ServerPatchCheckJob::dispatch($server);
}
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
if ($server->isSentinelEnabled() && $this->shouldRunNow('0 0 * * *', $serverTimezone)) {
dispatch(function () use ($server) {
$server->restartContainer('coolify-sentinel');
});
}
}
private function shouldRunNow(string $frequency, string $timezone): bool
{
$cron = new CronExpression($frequency);
// Use the frozen execution time, not the current time
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone);
return $cron->isDue($executionTime);
}
}

View file

@ -65,6 +65,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public Collection $foundApplicationPreviewsIds;
public Collection $applicationContainerStatuses;
public bool $foundProxy = false;
public bool $foundLogDrainContainer = false;
@ -87,6 +89,7 @@ public function __construct(public Server $server, public $data)
$this->foundServiceApplicationIds = collect();
$this->foundApplicationPreviewsIds = collect();
$this->foundServiceDatabaseIds = collect();
$this->applicationContainerStatuses = collect();
$this->allApplicationIds = collect();
$this->allDatabaseUuids = collect();
$this->allTcpProxyUuids = collect();
@ -155,7 +158,14 @@ public function handle()
if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) {
$this->foundApplicationIds->push($applicationId);
}
$this->updateApplicationStatus($applicationId, $containerStatus);
// Store container status for aggregation
if (! $this->applicationContainerStatuses->has($applicationId)) {
$this->applicationContainerStatuses->put($applicationId, collect());
}
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
} else {
$previewKey = $applicationId.':'.$pullRequestId;
if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) {
@ -205,9 +215,86 @@ public function handle()
$this->updateAdditionalServersStatus();
// Aggregate multi-container application statuses
$this->aggregateMultiContainerStatuses();
$this->checkLogDrainContainer();
}
private function aggregateMultiContainerStatuses()
{
if ($this->applicationContainerStatuses->isEmpty()) {
return;
}
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
$application = $this->applications->where('id', $applicationId)->first();
if (! $application) {
continue;
}
// Parse docker compose to check for excluded 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) {
// Check if container should be excluded
$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
}
}
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, don't update status
if ($relevantStatuses->isEmpty()) {
continue;
}
// Aggregate status: if any container is running, app is running
$hasRunning = false;
$hasUnhealthy = false;
foreach ($relevantStatuses as $status) {
if (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
}
}
}
$aggregatedStatus = null;
if ($hasRunning) {
$aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
} else {
// All containers are exited
$aggregatedStatus = 'exited (unhealthy)';
}
// Update application status with aggregated result
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
}
}
}
private function updateApplicationStatus(string $applicationId, string $containerStatus)
{
$application = $this->applications->where('id', $applicationId)->first();

View file

@ -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) {

View file

@ -58,7 +58,7 @@ public function handle(): void
case 'checkout.session.completed':
$clientReferenceId = data_get($data, 'client_reference_id');
if (is_null($clientReferenceId)) {
send_internal_notification('Checkout session completed without client reference id.');
// send_internal_notification('Checkout session completed without client reference id.');
break;
}
$userId = Str::before($clientReferenceId, ':');
@ -68,7 +68,7 @@ public function handle(): void
$team = Team::find($teamId);
$found = $team->members->where('id', $userId)->first();
if (! $found->isAdmin()) {
send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
}
$subscription = Subscription::where('team_id', $teamId)->first();
@ -95,7 +95,7 @@ public function handle(): void
$customerId = data_get($data, 'customer');
$planId = data_get($data, 'lines.data.0.plan.id');
if (Str::contains($excludedPlans, $planId)) {
send_internal_notification('Subscription excluded.');
// send_internal_notification('Subscription excluded.');
break;
}
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
@ -110,16 +110,38 @@ public function handle(): void
break;
case 'invoice.payment_failed':
$customerId = data_get($data, 'customer');
$invoiceId = data_get($data, 'id');
$paymentIntentId = data_get($data, 'payment_intent');
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
// send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No subscription found for customer: {$customerId}");
}
$team = data_get($subscription, 'team');
if (! $team) {
send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId);
// send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
}
// Verify payment status with Stripe API before sending failure notification
if ($paymentIntentId) {
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
break;
}
if (! $subscription->stripe_invoice_paid && $subscription->created_at->diffInMinutes(now()) < 5) {
SubscriptionInvoiceFailedJob::dispatch($team)->delay(now()->addSeconds(60));
break;
}
} catch (\Exception $e) {
}
}
if (! $subscription->stripe_invoice_paid) {
SubscriptionInvoiceFailedJob::dispatch($team);
// send_internal_notification('Invoice payment failed: '.$customerId);
@ -129,11 +151,11 @@ public function handle(): void
$customerId = data_get($data, 'customer');
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
// send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
}
if ($subscription->stripe_invoice_paid) {
send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
// send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
return;
}
@ -154,7 +176,7 @@ public function handle(): void
$team = Team::find($teamId);
$found = $team->members->where('id', $userId)->first();
if (! $found->isAdmin()) {
send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
}
$subscription = Subscription::where('team_id', $teamId)->first();
@ -177,7 +199,7 @@ public function handle(): void
$subscriptionId = data_get($data, 'items.data.0.subscription') ?? data_get($data, 'id');
$planId = data_get($data, 'items.data.0.plan.id') ?? data_get($data, 'plan.id');
if (Str::contains($excludedPlans, $planId)) {
send_internal_notification('Subscription excluded.');
// send_internal_notification('Subscription excluded.');
break;
}
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
@ -194,7 +216,7 @@ public function handle(): void
'stripe_invoice_paid' => false,
]);
} else {
send_internal_notification('No subscription and team id found');
// send_internal_notification('No subscription and team id found');
throw new \RuntimeException('No subscription and team id found');
}
}
@ -230,7 +252,7 @@ public function handle(): void
$subscription->update([
'stripe_past_due' => true,
]);
send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId);
// send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId);
}
}
if ($status === 'unpaid') {
@ -238,13 +260,13 @@ public function handle(): void
$subscription->update([
'stripe_invoice_paid' => false,
]);
send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId);
// send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId);
}
$team = data_get($subscription, 'team');
if ($team) {
$team->subscriptionEnded();
} else {
send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId);
// send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
}
}
@ -273,11 +295,11 @@ public function handle(): void
if ($team) {
$team->subscriptionEnded();
} else {
send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId);
// send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No team found in Coolify for customer: {$customerId}");
}
} else {
send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
// send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
}
break;

View file

@ -23,6 +23,47 @@ public function __construct(protected Team $team)
public function handle()
{
try {
// Double-check subscription status before sending failure notification
$subscription = $this->team->subscription;
if ($subscription && $subscription->stripe_customer_id) {
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
if ($subscription->stripe_subscription_id) {
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
if (in_array($stripeSubscription->status, ['active', 'trialing'])) {
if (! $subscription->stripe_invoice_paid) {
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
}
return;
}
}
$invoices = $stripe->invoices->all([
'customer' => $subscription->stripe_customer_id,
'limit' => 3,
]);
foreach ($invoices->data as $invoice) {
if ($invoice->paid && $invoice->created > (time() - 3600)) {
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
return;
}
}
} catch (\Exception $e) {
}
}
// If we reach here, payment genuinely failed
$session = getStripeCustomerPortalSession($this->team);
$mail = new MailMessage;
$mail->view('emails.subscription-invoice-failed', [

View file

@ -0,0 +1,372 @@
<?php
namespace App\Livewire;
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
class GlobalSearch extends Component
{
public $searchQuery = '';
public $isModalOpen = false;
public $searchResults = [];
public $allSearchableItems = [];
public function mount()
{
$this->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');
}
}

View file

@ -42,7 +42,7 @@ public function submit()
'content' => 'User: `'.auth()->user()?->email.'` with subject: `'.$this->subject.'` has the following problem: `'.$this->description.'`',
]);
} else {
send_user_an_email($mail, auth()->user()?->email, 'hi@coollabs.io');
send_user_an_email($mail, auth()->user()?->email, 'feedback@coollabs.io');
}
$this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.');
$this->reset('description', 'subject');

View file

@ -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);
}

View file

@ -703,7 +703,6 @@ private function updateServiceEnvironmentVariables()
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
], [
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
@ -712,7 +711,6 @@ private function updateServiceEnvironmentVariables()
'key' => "SERVICE_URL_{$serviceNameFormatted}",
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
// Create/update port-specific variables if port exists
@ -721,7 +719,6 @@ private function updateServiceEnvironmentVariables()
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
], [
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
@ -729,7 +726,6 @@ private function updateServiceEnvironmentVariables()
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
}

View file

@ -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);

View file

@ -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}";

View file

@ -63,7 +63,6 @@ public function submit()
EnvironmentVariable::create([
'key' => $key,
'value' => $variable,
'is_build_time' => false,
'is_preview' => false,
'resourceable_id' => $service->id,
'resourceable_type' => $service->getMorphClass(),

View file

@ -97,7 +97,6 @@ public function mount()
'value' => $value,
'resourceable_id' => $service->id,
'resourceable_type' => $service->getMorphClass(),
'is_build_time' => false,
'is_preview' => false,
]);
}

View file

@ -19,28 +19,32 @@ class Add extends Component
public ?string $value = null;
public bool $is_build_time = false;
public bool $is_multiline = false;
public bool $is_literal = false;
public bool $is_runtime = true;
public bool $is_buildtime = true;
protected $listeners = ['clearAddEnv' => 'clear'];
protected $rules = [
'key' => 'required|string',
'value' => 'nullable',
'is_build_time' => 'required|boolean',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
'is_runtime' => 'required|boolean',
'is_buildtime' => 'required|boolean',
];
protected $validationAttributes = [
'key' => 'key',
'value' => 'value',
'is_build_time' => 'build',
'is_multiline' => 'multiline',
'is_literal' => 'literal',
'is_runtime' => 'runtime',
'is_buildtime' => 'buildtime',
];
public function mount()
@ -54,9 +58,10 @@ public function submit()
$this->dispatch('saveKey', [
'key' => $this->key,
'value' => $this->value,
'is_build_time' => $this->is_build_time,
'is_multiline' => $this->is_multiline,
'is_literal' => $this->is_literal,
'is_runtime' => $this->is_runtime,
'is_buildtime' => $this->is_buildtime,
'is_preview' => $this->is_preview,
]);
$this->clear();
@ -66,8 +71,9 @@ public function clear()
{
$this->key = '';
$this->value = '';
$this->is_build_time = false;
$this->is_multiline = false;
$this->is_literal = false;
$this->is_runtime = true;
$this->is_buildtime = true;
}
}

View file

@ -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,13 +36,14 @@ 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'));
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
$this->showPreview = true;
}
$this->sortEnvironmentVariables();
$this->getDevView();
}
public function instantSave()
@ -49,34 +52,38 @@ 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->sortEnvironmentVariables();
$this->getDevView();
$this->dispatch('success', 'Environment variable settings updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function sortEnvironmentVariables()
public function getEnvironmentVariablesProperty()
{
if ($this->is_env_sorting_enabled === false) {
if ($this->resource->environment_variables) {
$this->resource->environment_variables = $this->resource->environment_variables->sortBy('order')->values();
}
if ($this->resource->environment_variables_preview) {
$this->resource->environment_variables_preview = $this->resource->environment_variables_preview->sortBy('order')->values();
}
return $this->resource->environment_variables()->orderBy('order')->get();
}
$this->getDevView();
return $this->resource->environment_variables;
}
public function getEnvironmentVariablesPreviewProperty()
{
if ($this->is_env_sorting_enabled === false) {
return $this->resource->environment_variables_preview()->orderBy('order')->get();
}
return $this->resource->environment_variables_preview;
}
public function getDevView()
{
$this->variables = $this->formatEnvironmentVariables($this->resource->environment_variables);
$this->variables = $this->formatEnvironmentVariables($this->environmentVariables);
if ($this->showPreview) {
$this->variablesPreview = $this->formatEnvironmentVariables($this->resource->environment_variables_preview);
$this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview);
}
}
@ -97,7 +104,7 @@ private function formatEnvironmentVariables($variables)
public function switch()
{
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->sortEnvironmentVariables();
$this->getDevView();
}
public function submit($data = null)
@ -111,7 +118,7 @@ public function submit($data = null)
}
$this->updateOrder();
$this->sortEnvironmentVariables();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
@ -212,9 +219,10 @@ private function createEnvironmentVariable($data)
$environment = new EnvironmentVariable;
$environment->key = $data['key'];
$environment->value = $data['value'];
$environment->is_build_time = $data['is_build_time'] ?? false;
$environment->is_multiline = $data['is_multiline'] ?? false;
$environment->is_literal = $data['is_literal'] ?? 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();
@ -276,7 +284,6 @@ private function updateOrCreateVariables($isPreview, $variables)
$environment = new EnvironmentVariable;
$environment->key = $key;
$environment->value = $value;
$environment->is_build_time = false;
$environment->is_multiline = false;
$environment->is_preview = $isPreview;
$environment->resourceable_id = $this->resource->id;
@ -293,7 +300,6 @@ private function updateOrCreateVariables($isPreview, $variables)
public function refreshEnvs()
{
$this->resource->refresh();
$this->sortEnvironmentVariables();
$this->getDevView();
}
}

View file

@ -32,14 +32,16 @@ class Show extends Component
public bool $is_shared = false;
public bool $is_build_time = false;
public bool $is_multiline = false;
public bool $is_literal = false;
public bool $is_shown_once = false;
public bool $is_runtime = true;
public bool $is_buildtime = true;
public bool $is_required = false;
public bool $is_really_required = false;
@ -55,10 +57,11 @@ class Show extends Component
protected $rules = [
'key' => 'required|string',
'value' => 'nullable',
'is_build_time' => 'required|boolean',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
'is_shown_once' => 'required|boolean',
'is_runtime' => 'required|boolean',
'is_buildtime' => 'required|boolean',
'real_value' => 'nullable',
'is_required' => 'required|boolean',
];
@ -101,8 +104,9 @@ public function syncData(bool $toModel = false)
]);
} else {
$this->validate();
$this->env->is_build_time = $this->is_build_time;
$this->env->is_required = $this->is_required;
$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;
@ -114,10 +118,11 @@ public function syncData(bool $toModel = false)
} else {
$this->key = $this->env->key;
$this->value = $this->env->value;
$this->is_build_time = $this->env->is_build_time ?? 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_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;
@ -139,9 +144,6 @@ public function checkEnvs()
public function serialize()
{
data_forget($this->env, 'real_value');
if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) {
data_forget($this->env, 'is_build_time');
}
}
public function lock()

View file

@ -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) {

View file

@ -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');

View file

@ -78,7 +78,10 @@ public function addDynamicConfiguration()
$yaml = Yaml::dump($yaml, 10, 2);
$this->value = $yaml;
}
transfer_file_to_server($this->value, $file, $this->server);
$base64_value = base64_encode($this->value);
instant_remote_process([
"echo '{$base64_value}' | base64 -d | tee {$file} > /dev/null",
], $this->server);
if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy();
}

View file

@ -63,6 +63,8 @@ class Show extends Component
public bool $isSentinelDebugEnabled;
public ?string $sentinelCustomDockerImage = null;
public string $serverTimezone;
public function getListeners()
@ -267,7 +269,8 @@ public function restartSentinel()
{
try {
$this->authorize('manageSentinel', $this->server);
$this->server->restartSentinel();
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
$this->server->restartSentinel($customImage);
$this->dispatch('success', 'Restarting Sentinel.');
} catch (\Throwable $e) {
return handleError($e, $this);
@ -295,12 +298,38 @@ public function updatedIsMetricsEnabled($value)
}
}
public function updatedIsBuildServer($value)
{
try {
$this->authorize('update', $this->server);
if ($value === true && $this->isSentinelEnabled) {
$this->isSentinelEnabled = false;
$this->isMetricsEnabled = false;
$this->isSentinelDebugEnabled = false;
StopSentinel::dispatch($this->server);
$this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.');
}
$this->submit();
// Dispatch event to refresh the navbar
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function updatedIsSentinelEnabled($value)
{
try {
$this->authorize('manageSentinel', $this->server);
if ($value === true) {
StartSentinel::run($this->server, true);
if ($this->isBuildServer) {
$this->isSentinelEnabled = false;
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
return;
}
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
StartSentinel::run($this->server, true, null, $customImage);
} else {
$this->isMetricsEnabled = false;
$this->isSentinelDebugEnabled = false;

View file

@ -4,6 +4,7 @@
use App\Enums\ApplicationDeploymentStatus;
use App\Services\ConfigurationGenerator;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -110,7 +111,7 @@
class Application extends BaseModel
{
use HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5';
@ -123,66 +124,6 @@ class Application extends BaseModel
'http_basic_auth_password' => 'encrypted',
];
public function customNetworkAliases(): Attribute
{
return Attribute::make(
set: function ($value) {
if (is_null($value) || $value === '') {
return null;
}
// If it's already a JSON string, decode it
if (is_string($value) && $this->isJson($value)) {
$value = json_decode($value, true);
}
// If it's a string but not JSON, treat it as a comma-separated list
if (is_string($value) && ! is_array($value)) {
$value = explode(',', $value);
}
$value = collect($value)
->map(function ($alias) {
if (is_string($alias)) {
return str_replace(' ', '-', trim($alias));
}
return null;
})
->filter()
->unique() // Remove duplicate values
->values()
->toArray();
return empty($value) ? null : json_encode($value);
},
get: function ($value) {
if (is_null($value)) {
return null;
}
if (is_string($value) && $this->isJson($value)) {
return json_decode($value, true);
}
return is_array($value) ? $value : [];
}
);
}
/**
* Check if a string is a valid JSON
*/
private function isJson($string)
{
if (! is_string($string)) {
return false;
}
json_decode($string);
return json_last_error() === JSON_ERROR_NONE;
}
protected static function booted()
{
static::addGlobalScope('withRelations', function ($builder) {
@ -250,6 +191,66 @@ protected static function booted()
});
}
public function customNetworkAliases(): Attribute
{
return Attribute::make(
set: function ($value) {
if (is_null($value) || $value === '') {
return null;
}
// If it's already a JSON string, decode it
if (is_string($value) && $this->isJson($value)) {
$value = json_decode($value, true);
}
// If it's a string but not JSON, treat it as a comma-separated list
if (is_string($value) && ! is_array($value)) {
$value = explode(',', $value);
}
$value = collect($value)
->map(function ($alias) {
if (is_string($alias)) {
return str_replace(' ', '-', trim($alias));
}
return null;
})
->filter()
->unique() // Remove duplicate values
->values()
->toArray();
return empty($value) ? null : json_encode($value);
},
get: function ($value) {
if (is_null($value)) {
return null;
}
if (is_string($value) && $this->isJson($value)) {
return json_decode($value, true);
}
return is_array($value) ? $value : [];
}
);
}
/**
* Check if a string is a valid JSON
*/
private function isJson($string)
{
if (! is_string($string)) {
return false;
}
json_decode($string);
return json_last_error() === JSON_ERROR_NONE;
}
public static function ownedByCurrentTeamAPI(int $teamId)
{
return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name');
@ -728,7 +729,14 @@ public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
->orderBy('key', 'asc');
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
}
public function runtime_environment_variables()
@ -738,14 +746,6 @@ public function runtime_environment_variables()
->where('key', 'not like', 'NIXPACKS_%');
}
public function build_environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
->where('is_build_time', true)
->where('key', 'not like', 'NIXPACKS_%');
}
public function nixpacks_environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
@ -757,7 +757,14 @@ public function environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC");
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
}
public function runtime_environment_variables_preview()
@ -767,14 +774,6 @@ public function runtime_environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%');
}
public function build_environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
->where('is_build_time', true)
->where('key', 'not like', 'NIXPACKS_%');
}
public function nixpacks_environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
@ -934,11 +933,11 @@ public function isLogDrainEnabled()
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels);
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels.$this->settings->use_build_secrets);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_build_time', 'is_multiline', 'is_literal'])->sort());
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
} else {
$newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_build_time', 'is_multiline', 'is_literal'])->sort());
$newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
}
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
@ -1075,20 +1074,26 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
if (is_null($private_key)) {
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.');
}
$private_key = base64_encode($private_key);
$base_comamnd = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} {$customRepository}";
$commands = collect([]);
if ($exec_in_docker) {
$commands = collect([
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
]);
} else {
$commands = collect([
'mkdir -p /root/.ssh',
"echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
'chmod 600 /root/.ssh/id_rsa',
]);
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'));
// SSH key transfer handled by ApplicationDeploymentJob, assume key is already in container
$commands->push(executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'));
$commands->push(executeInDocker($deployment_uuid, $base_comamnd));
} else {
$server = $this->destination->server;
$commands->push('mkdir -p /root/.ssh');
transfer_file_to_server($private_key, '/root/.ssh/id_rsa', $server);
$commands->push('chmod 600 /root/.ssh/id_rsa');
$commands->push($base_comamnd);
}
@ -1214,6 +1219,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
if (is_null($private_key)) {
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.');
}
$private_key = base64_encode($private_key);
$escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command_base = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
if ($only_checkout) {
@ -1221,18 +1227,18 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base);
}
$commands = collect([]);
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'));
// SSH key transfer handled by ApplicationDeploymentJob, assume key is already in container
$commands->push(executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'));
$commands = collect([
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
]);
} else {
$server = $this->destination->server;
$commands->push('mkdir -p /root/.ssh');
transfer_file_to_server($private_key, '/root/.ssh/id_rsa', $server);
$commands->push('chmod 600 /root/.ssh/id_rsa');
$commands = collect([
'mkdir -p /root/.ssh',
"echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
'chmod 600 /root/.ssh/id_rsa',
]);
}
if ($pull_request_id !== 0) {
if ($git_type === 'gitlab') {
@ -1565,7 +1571,19 @@ public function isWatchPathsTriggered(Collection $modified_files): bool
if (is_null($this->watch_paths)) {
return false;
}
$watch_paths = collect(explode("\n", $this->watch_paths));
$watch_paths = collect(explode("\n", $this->watch_paths))
->map(function (string $path): string {
return trim($path);
})
->filter(function (string $path): bool {
return strlen($path) > 0;
});
// If no valid patterns after filtering, don't trigger
if ($watch_paths->isEmpty()) {
return false;
}
$matches = $modified_files->filter(function ($file) use ($watch_paths) {
return $watch_paths->contains(function ($glob) use ($file) {
return fnmatch($glob, $file);

View file

@ -14,10 +14,11 @@
'uuid' => ['type' => 'string'],
'resourceable_type' => ['type' => 'string'],
'resourceable_id' => ['type' => 'integer'],
'is_build_time' => ['type' => 'boolean'],
'is_literal' => ['type' => 'boolean'],
'is_multiline' => ['type' => 'boolean'],
'is_preview' => ['type' => 'boolean'],
'is_runtime' => ['type' => 'boolean'],
'is_buildtime' => ['type' => 'boolean'],
'is_shared' => ['type' => 'boolean'],
'is_shown_once' => ['type' => 'boolean'],
'key' => ['type' => 'string'],
@ -35,15 +36,16 @@ class EnvironmentVariable extends BaseModel
protected $casts = [
'key' => 'string',
'value' => 'encrypted',
'is_build_time' => 'boolean',
'is_multiline' => 'boolean',
'is_preview' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
'version' => 'string',
'resourceable_type' => 'string',
'resourceable_id' => 'integer',
];
protected $appends = ['real_value', 'is_shared', 'is_really_required'];
protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify'];
protected static function booted()
{
@ -61,8 +63,8 @@ protected static function booted()
ModelsEnvironmentVariable::create([
'key' => $environment_variable->key,
'value' => $environment_variable->value,
'is_build_time' => $environment_variable->is_build_time,
'is_multiline' => $environment_variable->is_multiline ?? false,
'is_literal' => $environment_variable->is_literal ?? false,
'resourceable_type' => Application::class,
'resourceable_id' => $environment_variable->resourceable_id,
'is_preview' => true,
@ -137,6 +139,32 @@ protected function isReallyRequired(): Attribute
);
}
protected function isNixpacks(): Attribute
{
return Attribute::make(
get: function () {
if (str($this->key)->startsWith('NIXPACKS_')) {
return true;
}
return false;
}
);
}
protected function isCoolify(): Attribute
{
return Attribute::make(
get: function () {
if (str($this->key)->startsWith('SERVICE_')) {
return true;
}
return false;
}
);
}
protected function isShared(): Attribute
{
return Attribute::make(

View file

@ -159,7 +159,8 @@ public function saveStorageOnServer()
$chmod = data_get($this, 'chmod');
$chown = data_get($this, 'chown');
if ($content) {
transfer_file_to_server($content, $path, $server);
$content = base64_encode($content);
$commands->push("echo '$content' | base64 -d | tee $path > /dev/null");
} else {
$commands->push("touch $path");
}
@ -174,9 +175,7 @@ public function saveStorageOnServer()
$commands->push("mkdir -p $path > /dev/null 2>&1 || true");
}
if ($commands->count() > 0) {
return instant_remote_process($commands, $server);
}
return instant_remote_process($commands, $server);
}
// Accessor for convenient access

View file

@ -13,6 +13,7 @@
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -55,7 +56,7 @@
class Server extends BaseModel
{
use HasFactory, SchemalessAttributesTrait, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes;
public static $batch_counter = 0;
@ -309,7 +310,10 @@ public function setupDefaultRedirect()
$conf = Yaml::dump($dynamic_conf, 12, 2);
}
$conf = $banner.$conf;
transfer_file_to_server($conf, $default_redirect_file, $this);
$base64 = base64_encode($conf);
instant_remote_process([
"echo '$base64' | base64 -d | tee $default_redirect_file > /dev/null",
], $this);
}
if ($proxy_type === 'CADDY') {
@ -443,10 +447,11 @@ public function setupDynamicProxyConfiguration()
"# Do not edit it manually (only if you know what are you doing).\n\n".
$yaml;
$base64 = base64_encode($yaml);
instant_remote_process([
"mkdir -p $dynamic_config_path",
"echo '$base64' | base64 -d | tee $file > /dev/null",
], $this);
transfer_file_to_server($yaml, $file, $this);
}
} elseif ($this->proxyType() === 'CADDY') {
$file = "$dynamic_config_path/coolify.caddy";
@ -469,7 +474,10 @@ public function setupDynamicProxyConfiguration()
}
reverse_proxy coolify:8080
}";
transfer_file_to_server($caddy_file, $file, $this);
$base64 = base64_encode($caddy_file);
instant_remote_process([
"echo '$base64' | base64 -d | tee $file > /dev/null",
], $this);
$this->reloadCaddy();
}
}
@ -1252,13 +1260,13 @@ public function isIpv6(): bool
return str($this->ip)->contains(':');
}
public function restartSentinel(bool $async = true)
public function restartSentinel(?string $customImage = null, bool $async = true)
{
try {
if ($async) {
StartSentinel::dispatch($this, true);
StartSentinel::dispatch($this, true, null, $customImage);
} else {
StartSentinel::run($this, true);
StartSentinel::run($this, true, null, $customImage);
}
} catch (\Throwable $e) {
return handleError($e);
@ -1312,6 +1320,7 @@ private function disableSshMux(): void
public function generateCaCertificate()
{
try {
ray('Generating CA certificate for server', $this->id);
SslHelper::generateSslCertificate(
commonName: 'Coolify CA Certificate',
serverId: $this->id,
@ -1319,6 +1328,7 @@ public function generateCaCertificate()
validityDays: 10 * 365
);
$caCertificate = SslCertificate::where('server_id', $this->id)->where('is_ca_certificate', true)->first();
ray('CA certificate generated', $caCertificate);
if ($caCertificate) {
$certificateContent = $caCertificate->ssl_certificate;
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\ProcessStatus;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -41,7 +42,7 @@
)]
class Service extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5';
@ -1113,7 +1114,6 @@ public function saveExtraFields($fields)
$this->environment_variables()->create([
'key' => $key,
'value' => $value,
'is_build_time' => false,
'resourceable_id' => $this->id,
'resourceable_type' => $this->getMorphClass(),
'is_preview' => false,
@ -1230,14 +1230,14 @@ public function scheduled_tasks(): HasMany
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderBy('key', 'asc');
}
public function environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC");
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
}
public function workdir()
@ -1281,10 +1281,8 @@ public function saveComposeConfigs()
if ($envs->count() === 0) {
$commands[] = 'touch .env';
} else {
$envs_content = $envs->implode("\n");
transfer_file_to_server($envs_content, $this->workdir().'/.env', $this->server);
return;
$envs_base64 = base64_encode($envs->implode("\n"));
$commands[] = "echo '$envs_base64' | base64 -d | tee .env > /dev/null";
}
instant_remote_process($commands, $this->server);

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandaloneClickhouse extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@ -43,6 +44,11 @@ protected static function booted()
});
}
public static function ownedByCurrentTeam()
{
return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(
@ -266,7 +272,14 @@ public function destination()
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderBy('key', 'asc');
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
}
public function runtime_environment_variables()

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandaloneDragonfly extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@ -43,6 +44,11 @@ protected static function booted()
});
}
public static function ownedByCurrentTeam()
{
return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(
@ -341,6 +347,13 @@ public function isBackupSolutionAvailable()
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderBy('key', 'asc');
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
}
}

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandaloneKeydb extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@ -43,6 +44,11 @@ protected static function booted()
});
}
public static function ownedByCurrentTeam()
{
return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(
@ -341,6 +347,13 @@ public function isBackupSolutionAvailable()
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderBy('key', 'asc');
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
}
}

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -10,7 +11,7 @@
class StandaloneMariadb extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@ -44,6 +45,11 @@ protected static function booted()
});
}
public static function ownedByCurrentTeam()
{
return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(
@ -262,7 +268,14 @@ public function destination(): MorphTo
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderBy('key', 'asc');
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
}
public function runtime_environment_variables()

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandaloneMongodb extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@ -46,6 +47,11 @@ protected static function booted()
});
}
public static function ownedByCurrentTeam()
{
return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(
@ -363,6 +369,13 @@ public function isBackupSolutionAvailable()
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderBy('key', 'asc');
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
}
}

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandaloneMysql extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@ -44,6 +45,11 @@ protected static function booted()
});
}
public static function ownedByCurrentTeam()
{
return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(
@ -345,6 +351,13 @@ public function isBackupSolutionAvailable()
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderBy('key', 'asc');
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
}
}

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandalonePostgresql extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@ -44,6 +45,11 @@ protected static function booted()
});
}
public static function ownedByCurrentTeam()
{
return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
public function workdir()
{
return database_configuration_dir()."/{$this->uuid}";
@ -296,7 +302,14 @@ public function scheduledBackups()
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderBy('key', 'asc');
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
}
public function isBackupSolutionAvailable()

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandaloneRedis extends BaseModel
{
use HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@ -45,6 +46,11 @@ protected static function booted()
});
}
public static function ownedByCurrentTeam()
{
return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
protected function serverStatus(): Attribute
{
return Attribute::make(
@ -388,6 +394,13 @@ public function redisUsername(): Attribute
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderBy('key', 'asc');
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
ELSE 3
END,
LOWER(key) ASC
");
}
}

View file

@ -80,9 +80,23 @@ public function boot(): void
) {
$user->updated_at = now();
$user->save();
$user->currentTeam = $user->teams->firstWhere('personal_team', true);
if (! $user->currentTeam) {
$user->currentTeam = $user->recreate_personal_team();
// Check if user has a pending invitation they haven't accepted yet
$invitation = \App\Models\TeamInvitation::whereEmail($email)->first();
if ($invitation && $invitation->isValid()) {
// User is logging in for the first time after being invited
// Attach them to the invited team if not already attached
if (! $user->teams()->where('team_id', $invitation->team->id)->exists()) {
$user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
}
$user->currentTeam = $invitation->team;
$invitation->delete();
} else {
// Normal login - use personal team
$user->currentTeam = $user->teams->firstWhere('personal_team', true);
if (! $user->currentTeam) {
$user->currentTeam = $user->recreate_personal_team();
}
}
session(['currentTeam' => $user->currentTeam]);

View file

@ -129,7 +129,6 @@ protected function getEnvironmentVariables(): array
$variables->push([
'key' => $env->key,
'value' => $env->value,
'is_build_time' => $env->is_build_time,
'is_preview' => $env->is_preview,
'is_multiline' => $env->is_multiline,
]);
@ -145,7 +144,6 @@ protected function getPreviewEnvironmentVariables(): array
$variables->push([
'key' => $env->key,
'value' => $env->value,
'is_build_time' => $env->is_build_time,
'is_preview' => $env->is_preview,
'is_multiline' => $env->is_multiline,
]);

View file

@ -0,0 +1,81 @@
<?php
namespace App\Traits;
use App\Livewire\GlobalSearch;
trait ClearsGlobalSearchCache
{
protected static function bootClearsGlobalSearchCache()
{
static::saving(function ($model) {
// Only clear cache if searchable fields are being changed
if ($model->hasSearchableChanges()) {
$teamId = $model->getTeamIdForCache();
if (filled($teamId)) {
GlobalSearch::clearTeamCache($teamId);
}
}
});
static::created(function ($model) {
// Always clear cache when model is created
$teamId = $model->getTeamIdForCache();
if (filled($teamId)) {
GlobalSearch::clearTeamCache($teamId);
}
});
static::deleted(function ($model) {
// Always clear cache when model is deleted
$teamId = $model->getTeamIdForCache();
if (filled($teamId)) {
GlobalSearch::clearTeamCache($teamId);
}
});
}
private function hasSearchableChanges(): bool
{
// Define searchable fields based on model type
$searchableFields = ['name', 'description'];
// Add model-specific searchable fields
if ($this instanceof \App\Models\Application) {
$searchableFields[] = 'fqdn';
$searchableFields[] = 'docker_compose_domains';
} elseif ($this instanceof \App\Models\Server) {
$searchableFields[] = 'ip';
} elseif ($this instanceof \App\Models\Service) {
// Services don't have direct fqdn, but name and description are covered
}
// Database models only have name and description as searchable
// Check if any searchable field is dirty
foreach ($searchableFields as $field) {
if ($this->isDirty($field)) {
return true;
}
}
return false;
}
private function getTeamIdForCache()
{
// For database models, team is accessed through environment.project.team
if (method_exists($this, 'team')) {
$team = $this->team();
if (filled($team)) {
return is_object($team) ? $team->id : null;
}
}
// For models with direct team_id property
if (property_exists($this, 'team_id') || isset($this->team_id)) {
return $this->team_id;
}
return null;
}
}

View file

@ -46,6 +46,14 @@ public function execute_remote_command(...$commands)
}
}
// Check for cancellation before executing commands
if (isset($this->application_deployment_queue)) {
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
throw new \RuntimeException('Deployment cancelled by user', 69420);
}
}
$maxRetries = config('constants.ssh.max_retries');
$attempt = 0;
$lastError = null;
@ -73,6 +81,12 @@ public function execute_remote_command(...$commands)
// Add log entry for the retry
if (isset($this->application_deployment_queue)) {
$this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage);
// Check for cancellation during retry wait
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
throw new \RuntimeException('Deployment cancelled by user during retry', 69420);
}
}
sleep($delay);
@ -85,6 +99,11 @@ public function execute_remote_command(...$commands)
// If we exhausted all retries and still failed
if (! $commandExecuted && $lastError) {
// Now we can set the status to FAILED since all retries have been exhausted
if (isset($this->application_deployment_queue)) {
$this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
$this->application_deployment_queue->save();
}
throw $lastError;
}
});
@ -160,8 +179,8 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
$process_result = $process->wait();
if ($process_result->exitCode() !== 0) {
if (! $ignore_errors) {
$this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
$this->application_deployment_queue->save();
// Don't immediately set to FAILED - let the retry logic handle it
// This prevents premature status changes during retryable SSH errors
throw new \RuntimeException($process_result->errorOutput());
}
}

View file

@ -1069,9 +1069,9 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
}
}
}
$compose_content = Yaml::dump($yaml_compose);
transfer_file_to_server($compose_content, "/tmp/{$uuid}.yml", $server);
$base64_compose = base64_encode(Yaml::dump($yaml_compose));
instant_remote_process([
"echo {$base64_compose} | base64 -d | tee /tmp/{$uuid}.yml > /dev/null",
"chmod 600 /tmp/{$uuid}.yml",
"docker compose -f /tmp/{$uuid}.yml config --no-interpolate --no-path-resolution -q",
"rm /tmp/{$uuid}.yml",
@ -1093,11 +1093,11 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100
{
if ($server->isSwarm()) {
$output = instant_remote_process([
"docker service logs -n {$lines} {$container_id}",
"docker service logs -n {$lines} {$container_id} 2>&1",
], $server);
} else {
$output = instant_remote_process([
"docker logs -n {$lines} {$container_id}",
"docker logs -n {$lines} {$container_id} 2>&1",
], $server);
}
@ -1105,7 +1105,6 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100
return $output;
}
function escapeEnvVariables($value)
{
$search = ['\\', "\r", "\t", "\x0", '"', "'"];

View file

@ -342,7 +342,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_build_time' => false,
'is_preview' => false,
]);
}
@ -355,7 +354,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_build_time' => false,
'is_preview' => false,
]);
}
@ -384,7 +382,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_build_time' => false,
'is_preview' => false,
]);
if ($resource->build_pack === 'dockercompose') {
@ -418,7 +415,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_build_time' => false,
'is_preview' => false,
]);
if ($resource->build_pack === 'dockercompose') {
@ -446,7 +442,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_build_time' => false,
'is_preview' => false,
]);
}
@ -760,7 +755,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_build_time' => false,
'is_preview' => false,
]);
@ -777,7 +771,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_build_time' => false,
'is_preview' => false,
]);
} else {
@ -813,7 +806,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_build_time' => false,
'is_preview' => false,
'is_required' => $isRequired,
]);
@ -828,7 +820,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_build_time' => false,
'is_preview' => false,
'is_required' => $isRequired,
]);
@ -886,7 +877,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
], [
'value' => $coolifyUrl->__toString(),
'is_build_time' => false,
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
@ -895,7 +885,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
], [
'value' => $coolifyFqdn,
'is_build_time' => false,
'is_preview' => false,
]);
} else {
@ -1343,7 +1332,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_build_time' => false,
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
@ -1352,7 +1340,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_build_time' => false,
'is_preview' => false,
]);
}
@ -1364,7 +1351,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_build_time' => false,
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
@ -1373,7 +1359,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_build_time' => false,
'is_preview' => false,
]);
}
@ -1403,7 +1388,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_build_time' => false,
'is_preview' => false,
]);
@ -1423,7 +1407,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_build_time' => false,
'is_preview' => false,
]);
@ -1435,7 +1418,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_build_time' => false,
'is_preview' => false,
]);
}
@ -1754,7 +1736,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_build_time' => false,
'is_preview' => false,
]);
@ -1771,7 +1752,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_build_time' => false,
'is_preview' => false,
]);
} else {
@ -1807,7 +1787,6 @@ function serviceParser(Service $resource): Collection
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_build_time' => false,
'is_preview' => false,
'is_required' => $isRequired,
]);
@ -1822,7 +1801,6 @@ function serviceParser(Service $resource): Collection
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_build_time' => false,
'is_preview' => false,
'is_required' => $isRequired,
]);

View file

@ -29,31 +29,11 @@ function remote_process(
$type = $type ?? ActivityTypes::INLINE->value;
$command = $command instanceof Collection ? $command->toArray() : $command;
// Process commands and handle file transfers
$processed_commands = [];
foreach ($command as $cmd) {
if (is_array($cmd) && isset($cmd['transfer_file'])) {
// Handle file transfer command
$transfer_data = $cmd['transfer_file'];
$content = $transfer_data['content'];
$destination = $transfer_data['destination'];
// Execute file transfer immediately
transfer_file_to_server($content, $destination, $server, ! $ignore_errors);
// Add a comment to the command log for visibility
$processed_commands[] = "# File transferred via SCP: $destination";
} else {
// Regular string command
$processed_commands[] = $cmd;
}
}
if ($server->isNonRoot()) {
$processed_commands = parseCommandsByLineForSudo(collect($processed_commands), $server);
$command = parseCommandsByLineForSudo(collect($command), $server);
}
$command_string = implode("\n", $processed_commands);
$command_string = implode("\n", $command);
if (Auth::check()) {
$teams = Auth::user()->teams->pluck('id');
@ -200,30 +180,10 @@ function instant_remote_process(Collection|array $command, Server $server, bool
{
$command = $command instanceof Collection ? $command->toArray() : $command;
// Process commands and handle file transfers
$processed_commands = [];
foreach ($command as $cmd) {
if (is_array($cmd) && isset($cmd['transfer_file'])) {
// Handle file transfer command
$transfer_data = $cmd['transfer_file'];
$content = $transfer_data['content'];
$destination = $transfer_data['destination'];
// Execute file transfer immediately
transfer_file_to_server($content, $destination, $server, $throwError);
// Add a comment to the command log for visibility
$processed_commands[] = "# File transferred via SCP: $destination";
} else {
// Regular string command
$processed_commands[] = $cmd;
}
}
if ($server->isNonRoot() && ! $no_sudo) {
$processed_commands = parseCommandsByLineForSudo(collect($processed_commands), $server);
$command = parseCommandsByLineForSudo(collect($command), $server);
}
$command_string = implode("\n", $processed_commands);
$command_string = implode("\n", $command);
return \App\Helpers\SshRetryHandler::retry(
function () use ($server, $command_string) {

View file

@ -69,11 +69,12 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
$fileVolume->content = $content;
$fileVolume->is_directory = false;
$fileVolume->save();
$content = base64_encode($content);
$dir = str($fileLocation)->dirname();
instant_remote_process([
"mkdir -p $dir",
"echo '$content' | base64 -d | tee $fileLocation",
], $server);
transfer_file_to_server($content, $fileLocation, $server);
} elseif ($isFile === 'NOK' && $isDir === 'NOK' && $fileVolume->is_directory && $isInit) {
// Does not exists (no dir or file), flagged as directory, is init
$fileVolume->content = null;
@ -133,7 +134,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
'key' => $variableName,
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
if ($port) {
@ -144,7 +144,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
'key' => $variableName,
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
}
@ -163,7 +162,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
'key' => $variableName,
], [
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
if ($port) {
@ -174,7 +172,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
'key' => $variableName,
], [
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
}

View file

@ -1125,30 +1125,77 @@ function get_public_ips()
function isAnyDeploymentInprogress()
{
$runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get();
$basicDetails = $runningJobs->map(function ($job) {
return [
'id' => $job->id,
'created_at' => $job->created_at,
'application_id' => $job->application_id,
'server_id' => $job->server_id,
'horizon_job_id' => $job->horizon_job_id,
'status' => $job->status,
];
});
echo 'Running jobs: '.json_encode($basicDetails)."\n";
if ($runningJobs->isEmpty()) {
echo "No deployments in progress.\n";
exit(0);
}
$horizonJobIds = [];
$deploymentDetails = [];
foreach ($runningJobs as $runningJob) {
$horizonJobStatus = getJobStatus($runningJob->horizon_job_id);
if ($horizonJobStatus === 'unknown' || $horizonJobStatus === 'reserved') {
$horizonJobIds[] = $runningJob->horizon_job_id;
// Get application and team information
$application = Application::find($runningJob->application_id);
$teamMembers = [];
$deploymentUrl = '';
if ($application) {
// Get team members through the application's project
$team = $application->team();
if ($team) {
$teamMembers = $team->members()->pluck('email')->toArray();
}
// Construct the full deployment URL
if ($runningJob->deployment_url) {
$baseUrl = base_url();
$deploymentUrl = $baseUrl.$runningJob->deployment_url;
}
}
$deploymentDetails[] = [
'id' => $runningJob->id,
'application_name' => $runningJob->application_name ?? 'Unknown',
'server_name' => $runningJob->server_name ?? 'Unknown',
'deployment_url' => $deploymentUrl,
'team_members' => $teamMembers,
'created_at' => $runningJob->created_at->format('Y-m-d H:i:s'),
'horizon_job_id' => $runningJob->horizon_job_id,
];
}
}
if (count($horizonJobIds) === 0) {
echo "No deployments in progress.\n";
echo "No active deployments in progress (all jobs completed or failed).\n";
exit(0);
}
$horizonJobIds = collect($horizonJobIds)->unique()->toArray();
echo 'There are '.count($horizonJobIds)." deployments in progress.\n";
// Display enhanced deployment information
echo "\n=== Running Deployments ===\n";
echo 'Total active deployments: '.count($horizonJobIds)."\n\n";
foreach ($deploymentDetails as $index => $deployment) {
echo 'Deployment #'.($index + 1).":\n";
echo ' Application: '.$deployment['application_name']."\n";
echo ' Server: '.$deployment['server_name']."\n";
echo ' Started: '.$deployment['created_at']."\n";
if ($deployment['deployment_url']) {
echo ' URL: '.$deployment['deployment_url']."\n";
}
if (! empty($deployment['team_members'])) {
echo ' Team members: '.implode(', ', $deployment['team_members'])."\n";
} else {
echo " Team members: No team members found\n";
}
echo ' Horizon Job ID: '.$deployment['horizon_job_id']."\n";
echo "\n";
}
exit(1);
}
@ -1564,7 +1611,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $fqdn,
'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@ -1644,7 +1690,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $fqdn,
'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@ -1683,7 +1728,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $generatedValue,
'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@ -1722,7 +1766,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
'resourceable_id' => $resource->id,
], [
'value' => $defaultValue,
'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@ -2413,7 +2456,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $fqdn,
'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@ -2425,7 +2467,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
EnvironmentVariable::create([
'key' => $key,
'value' => $generatedValue,
'is_build_time' => false,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
@ -2459,20 +2500,17 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($foundEnv) {
$defaultValue = data_get($foundEnv, 'value');
}
$isBuildTime = data_get($foundEnv, 'is_build_time', false);
if ($foundEnv) {
$foundEnv->update([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_build_time' => $isBuildTime,
'value' => $defaultValue,
]);
} else {
EnvironmentVariable::create([
'key' => $key,
'value' => $defaultValue,
'is_build_time' => $isBuildTime,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,

View file

@ -62,6 +62,7 @@
"barryvdh/laravel-debugbar": "^3.15.4",
"driftingly/rector-laravel": "^2.0.5",
"fakerphp/faker": "^1.24.1",
"laravel/boost": "^1.1",
"laravel/dusk": "^8.3.3",
"laravel/pint": "^1.24",
"laravel/telescope": "^5.10",

192
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "a78cf8fdfec25eac43de77c05640dc91",
"content-hash": "a993799242581bd06b5939005ee458d9",
"packages": [
{
"name": "amphp/amp",
@ -12747,6 +12747,71 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "laravel/boost",
"version": "v1.1.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
"reference": "70f909465bf73dad7e791fad8b7716b3b2712076"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/boost/zipball/70f909465bf73dad7e791fad8b7716b3b2712076",
"reference": "70f909465bf73dad7e791fad8b7716b3b2712076",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.9",
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/contracts": "^10.0|^11.0|^12.0",
"illuminate/routing": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"laravel/mcp": "^0.1.1",
"laravel/prompts": "^0.1.9|^0.3",
"laravel/roster": "^0.2.5",
"php": "^8.1"
},
"require-dev": {
"laravel/pint": "^1.14",
"mockery/mockery": "^1.6",
"orchestra/testbench": "^8.22.0|^9.0|^10.0",
"pestphp/pest": "^2.0|^3.0",
"phpstan/phpstan": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Boost\\BoostServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Boost\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.",
"homepage": "https://github.com/laravel/boost",
"keywords": [
"ai",
"dev",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
"time": "2025-09-04T12:16:09+00:00"
},
{
"name": "laravel/dusk",
"version": "v8.3.3",
@ -12821,6 +12886,70 @@
},
"time": "2025-06-10T13:59:27+00:00"
},
{
"name": "laravel/mcp",
"version": "v0.1.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "6d6284a491f07c74d34f48dfd999ed52c567c713"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713",
"reference": "6d6284a491f07c74d34f48dfd999ed52c567c713",
"shasum": ""
},
"require": {
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/contracts": "^10.0|^11.0|^12.0",
"illuminate/http": "^10.0|^11.0|^12.0",
"illuminate/routing": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/validation": "^10.0|^11.0|^12.0",
"php": "^8.1|^8.2"
},
"require-dev": {
"laravel/pint": "^1.14",
"orchestra/testbench": "^8.22.0|^9.0|^10.0",
"phpstan/phpstan": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
"Workbench\\App\\": "workbench/app/",
"Laravel\\Mcp\\Tests\\": "tests/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "The easiest way to add MCP servers to your Laravel app.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
"dev",
"laravel",
"mcp"
],
"support": {
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2025-08-16T09:50:43+00:00"
},
{
"name": "laravel/pint",
"version": "v1.24.0",
@ -12890,6 +13019,67 @@
},
"time": "2025-07-10T18:09:32+00:00"
},
{
"name": "laravel/roster",
"version": "v0.2.6",
"source": {
"type": "git",
"url": "https://github.com/laravel/roster.git",
"reference": "5615acdf860c5a5c61d04aba44f2d3312550c514"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/roster/zipball/5615acdf860c5a5c61d04aba44f2d3312550c514",
"reference": "5615acdf860c5a5c61d04aba44f2d3312550c514",
"shasum": ""
},
"require": {
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/contracts": "^10.0|^11.0|^12.0",
"illuminate/routing": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"php": "^8.1|^8.2",
"symfony/yaml": "^6.4|^7.2"
},
"require-dev": {
"laravel/pint": "^1.14",
"mockery/mockery": "^1.6",
"orchestra/testbench": "^8.22.0|^9.0|^10.0",
"pestphp/pest": "^2.0|^3.0",
"phpstan/phpstan": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Roster\\RosterServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Roster\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Detect packages & approaches in use within a Laravel project",
"homepage": "https://github.com/laravel/roster",
"keywords": [
"dev",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/roster/issues",
"source": "https://github.com/laravel/roster"
},
"time": "2025-09-04T07:31:39+00:00"
},
{
"name": "laravel/telescope",
"version": "v5.10.2",

View file

@ -2,8 +2,8 @@
return [
'coolify' => [
'version' => '4.0.0-beta.427',
'helper_version' => '1.0.10',
'version' => '4.0.0-beta.429',
'helper_version' => '1.0.11',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('environment_variables', function (Blueprint $table) {
// Check if the column exists before trying to drop it
if (Schema::hasColumn('environment_variables', 'is_build_time')) {
// Drop the is_build_time column
// Note: The unique constraints that included is_build_time were tied to old foreign key columns
// (application_id, service_id, database_id) which were removed in migration 2024_12_16_134437.
// Those constraints should no longer exist in the database.
$table->dropColumn('is_build_time');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('environment_variables', function (Blueprint $table) {
// Re-add the is_build_time column
if (! Schema::hasColumn('environment_variables', 'is_build_time')) {
$table->boolean('is_build_time')->default(false)->after('value');
}
});
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('environment_variables', function (Blueprint $table) {
$table->boolean('is_buildtime_only')->default(false)->after('is_preview');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('environment_variables', function (Blueprint $table) {
$table->dropColumn('is_buildtime_only');
});
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->boolean('use_build_secrets')->default(false)->after('is_build_server_enabled');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->dropColumn('use_build_secrets');
});
}
};

View file

@ -0,0 +1,67 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('environment_variables', function (Blueprint $table) {
// Add new boolean fields with defaults
$table->boolean('is_runtime')->default(true)->after('is_buildtime_only');
$table->boolean('is_buildtime')->default(true)->after('is_runtime');
});
// Migrate existing data from is_buildtime_only to new fields
DB::table('environment_variables')
->where('is_buildtime_only', true)
->update([
'is_runtime' => false,
'is_buildtime' => true,
]);
DB::table('environment_variables')
->where('is_buildtime_only', false)
->update([
'is_runtime' => true,
'is_buildtime' => true,
]);
// Remove the old is_buildtime_only column
Schema::table('environment_variables', function (Blueprint $table) {
$table->dropColumn('is_buildtime_only');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('environment_variables', function (Blueprint $table) {
// Re-add the is_buildtime_only column
$table->boolean('is_buildtime_only')->default(false)->after('is_preview');
});
// Restore data to is_buildtime_only based on new fields
DB::table('environment_variables')
->where('is_runtime', false)
->where('is_buildtime', true)
->update(['is_buildtime_only' => true]);
DB::table('environment_variables')
->where('is_runtime', true)
->update(['is_buildtime_only' => false]);
// Remove new columns
Schema::table('environment_variables', function (Blueprint $table) {
$table->dropColumn(['is_runtime', 'is_buildtime']);
});
}
};

View file

@ -14,6 +14,7 @@ ARG NIXPACKS_VERSION=1.40.0
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
FROM minio/mc:${MINIO_VERSION} AS minio-client
FROM ${BASE_IMAGE} AS base

View file

@ -7,7 +7,7 @@
"dependencies": {
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"axios": "1.8.4",
"axios": "1.12.0",
"cookie": "1.0.2",
"dotenv": "16.5.0",
"node-pty": "1.0.0",
@ -36,13 +36,13 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},

View file

@ -5,7 +5,7 @@
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"cookie": "1.0.2",
"axios": "1.8.4",
"axios": "1.12.0",
"dotenv": "16.5.0",
"node-pty": "1.0.0",
"ws": "8.18.1"

View file

@ -4,6 +4,19 @@ if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
exec </dev/tty
fi
# Generate service templates and OpenAPI documentation
echo "🔄 Generating service templates..."
php artisan generate:services
echo "📚 Generating OpenAPI documentation..."
php artisan generate:openapi
# Add the generated files to the commit
git add templates/service-templates*.json
git add openapi.json openapi.yaml
echo "✅ Generated files have been added to the commit"
# Get list of stashed PHP files
stashed_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.php')

View file

@ -2773,10 +2773,6 @@
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
"is_build_time": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in build time."
},
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@ -2870,10 +2866,6 @@
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
"is_build_time": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in build time."
},
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@ -2972,10 +2964,6 @@
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
"is_build_time": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in build time."
},
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@ -7179,10 +7167,6 @@
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
"is_build_time": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in build time."
},
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@ -7276,10 +7260,6 @@
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
"is_build_time": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in build time."
},
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@ -7378,10 +7358,6 @@
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
"is_build_time": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in build time."
},
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@ -8375,9 +8351,6 @@
"resourceable_id": {
"type": "integer"
},
"is_build_time": {
"type": "boolean"
},
"is_literal": {
"type": "boolean"
},
@ -8387,6 +8360,12 @@
"is_preview": {
"type": "boolean"
},
"is_runtime": {
"type": "boolean"
},
"is_buildtime": {
"type": "boolean"
},
"is_shared": {
"type": "boolean"
},

View file

@ -1778,9 +1778,6 @@ paths:
is_preview:
type: boolean
description: 'The flag to indicate if the environment variable is used in preview deployments.'
is_build_time:
type: boolean
description: 'The flag to indicate if the environment variable is used in build time.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
@ -1843,9 +1840,6 @@ paths:
is_preview:
type: boolean
description: 'The flag to indicate if the environment variable is used in preview deployments.'
is_build_time:
type: boolean
description: 'The flag to indicate if the environment variable is used in build time.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
@ -1901,7 +1895,7 @@ paths:
properties:
data:
type: array
items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_build_time: { type: boolean, description: 'The flag to indicate if the environment variable is used in build time.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
type: object
responses:
'201':
@ -4615,9 +4609,6 @@ paths:
is_preview:
type: boolean
description: 'The flag to indicate if the environment variable is used in preview deployments.'
is_build_time:
type: boolean
description: 'The flag to indicate if the environment variable is used in build time.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
@ -4680,9 +4671,6 @@ paths:
is_preview:
type: boolean
description: 'The flag to indicate if the environment variable is used in preview deployments.'
is_build_time:
type: boolean
description: 'The flag to indicate if the environment variable is used in build time.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
@ -4738,7 +4726,7 @@ paths:
properties:
data:
type: array
items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_build_time: { type: boolean, description: 'The flag to indicate if the environment variable is used in build time.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object }
type: object
responses:
'201':
@ -5417,14 +5405,16 @@ components:
type: string
resourceable_id:
type: integer
is_build_time:
type: boolean
is_literal:
type: boolean
is_multiline:
type: boolean
is_preview:
type: boolean
is_runtime:
type: boolean
is_buildtime:
type: boolean
is_shared:
type: boolean
is_shown_once:

View file

@ -1,19 +1,19 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.427"
},
"nightly": {
"version": "4.0.0-beta.428"
},
"nightly": {
"version": "4.0.0-beta.429"
},
"helper": {
"version": "1.0.10"
"version": "1.0.11"
},
"realtime": {
"version": "1.0.10"
},
"sentinel": {
"version": "0.0.15"
"version": "0.0.16"
}
}
}

View file

@ -59,20 +59,20 @@
if (this.zoom === '90') {
const style = document.createElement('style');
style.textContent = `
html {
font-size: 93.75%;
}
:root {
--vh: 1vh;
}
@media (min-width: 1024px) {
html {
font-size: 87.5%;
font-size: 93.75%;
}
}
`;
:root {
--vh: 1vh;
}
@media (min-width: 1024px) {
html {
font-size: 87.5%;
}
}
`;
document.head.appendChild(style);
}
}
@ -82,6 +82,9 @@
<div class="text-2xl font-bold tracking-wide dark:text-white">Coolify</div>
<x-version />
</div>
<div>
<livewire:global-search />
</div>
<livewire:settings-dropdown />
</div>
<div class="px-2 pt-2 pb-7">
@ -278,7 +281,7 @@ class="{{ request()->is('team*') ? 'menu-item-active menu-item' : 'menu-item' }}
Teams
</a>
</li>
@if (isCloud())
@if (isCloud() && auth()->user()->isAdmin())
<li>
<a title="Subscription"
class="{{ request()->is('subscription*') ? 'menu-item-active menu-item' : 'menu-item' }}"

View file

@ -0,0 +1,236 @@
<div x-data="{
modalOpen: false,
selectedIndex: -1,
openModal() {
this.modalOpen = true;
this.selectedIndex = -1;
@this.openSearchModal();
},
closeModal() {
this.modalOpen = false;
this.selectedIndex = -1;
@this.closeSearchModal();
},
navigateResults(direction) {
const results = document.querySelectorAll('.search-result-item');
if (results.length === 0) return;
if (direction === 'down') {
this.selectedIndex = Math.min(this.selectedIndex + 1, results.length - 1);
} else if (direction === 'up') {
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
}
if (this.selectedIndex >= 0 && this.selectedIndex < results.length) {
results[this.selectedIndex].focus();
results[this.selectedIndex].scrollIntoView({ block: 'nearest' });
} else if (this.selectedIndex === -1) {
this.$refs.searchInput?.focus();
}
},
init() {
// Listen for / key press globally
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(e.target.tagName) && !this.modalOpen) {
e.preventDefault();
this.openModal();
}
});
// Listen for Cmd+K or Ctrl+K globally
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (this.modalOpen) {
this.closeModal();
} else {
this.openModal();
}
}
});
// Listen for Escape key to close modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.modalOpen) {
this.closeModal();
}
});
// Listen for arrow keys when modal is open
document.addEventListener('keydown', (e) => {
if (!this.modalOpen) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
this.navigateResults('down');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.navigateResults('up');
}
});
}
}">
<!-- Search bar in navbar -->
<div class="flex justify-center">
<button @click="openModal()" type="button" title="Search (Press / or ⌘K)"
class="flex items-center gap-1.5 px-2.5 py-1.5 bg-neutral-100 dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-200 rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-neutral-500 dark:text-neutral-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<kbd
class="px-1 py-0.5 text-xs font-semibold text-neutral-500 dark:text-neutral-400 bg-neutral-200 dark:bg-coolgray-200 rounded">/</kbd>
</button>
</div>
<!-- Modal overlay -->
<template x-teleport="body">
<div x-show="modalOpen" x-cloak
class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen">
<div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs">
</div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full py-6 border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300"
@click.stop>
<div class="flex justify-between items-center pb-3">
<h3 class="pr-8 text-2xl font-bold">Search</h3>
<button @click="closeModal()"
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="relative w-auto">
<input type="text" wire:model.live.debounce.500ms="searchQuery"
placeholder="Type to search for applications, services, databases, and servers..."
x-ref="searchInput" x-init="$watch('modalOpen', value => { if (value) setTimeout(() => $refs.searchInput.focus(), 100) })" class="w-full input mb-4" />
<!-- Search results -->
<div class="relative min-h-[330px] max-h-[400px] overflow-y-auto scrollbar">
<!-- Loading indicator -->
<div wire:loading.flex wire:target="searchQuery"
class="min-h-[330px] items-center justify-center">
<div class="text-center">
<svg class="animate-spin mx-auto h-8 w-8 text-neutral-400"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
Searching...
</p>
</div>
</div>
<!-- Results content - hidden while loading -->
<div wire:loading.remove wire:target="searchQuery">
@if (strlen($searchQuery) >= 2 && count($searchResults) > 0)
<div class="space-y-1 my-4 pb-4">
@foreach ($searchResults as $index => $result)
<a href="{{ $result['link'] ?? '#' }}"
class="search-result-item block p-3 mx-1 hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:ring-1 focus:ring-coollabs focus:bg-neutral-100 dark:focus:bg-coolgray-200 ">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-neutral-900 dark:text-white">
{{ $result['name'] }}
</span>
@if ($result['type'] === 'server')
<span
class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white">
Server
</span>
@endif
</div>
<div class="flex items-center gap-2">
@if (!empty($result['project']) && !empty($result['environment']))
<span
class="text-xs text-neutral-500 dark:text-neutral-400">
{{ $result['project'] }} / {{ $result['environment'] }}
</span>
@endif
@if ($result['type'] === 'application')
<span
class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white">
Application
</span>
@elseif ($result['type'] === 'service')
<span
class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white">
Service
</span>
@elseif ($result['type'] === 'database')
<span
class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white">
{{ ucfirst($result['subtype'] ?? 'Database') }}
</span>
@endif
</div>
@if (!empty($result['description']))
<div
class="text-sm text-neutral-600 dark:text-neutral-400 mt-0.5">
{{ Str::limit($result['description'], 100) }}
</div>
@endif
</div>
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 ml-2 h-4 w-4 text-neutral-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</a>
@endforeach
</div>
@elseif (strlen($searchQuery) >= 2 && count($searchResults) === 0)
<div class="flex items-center justify-center min-h-[330px]">
<div class="text-center">
<p class="text-sm text-neutral-600 dark:text-neutral-400">
No results found for "<strong>{{ $searchQuery }}</strong>"
</p>
<p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2">
Try different keywords or check the spelling
</p>
</div>
</div>
@elseif (strlen($searchQuery) > 0 && strlen($searchQuery) < 2)
<div class="flex items-center justify-center min-h-[330px]">
<div class="text-center">
<p class="text-sm text-neutral-600 dark:text-neutral-400">
Type at least 2 characters to search
</p>
</div>
</div>
@else
<div class="flex items-center justify-center min-h-[330px]">
<div class="text-center">
<p class="text-sm text-neutral-600 dark:text-neutral-400">
Start typing to search
</p>
<p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2">
Search for applications, services, databases, and servers
</p>
</div>
</div>
@endif
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>

View file

@ -3,17 +3,19 @@
<x-forms.textarea x-show="$wire.is_multiline === true" x-cloak id="value" label="Value" required />
<x-forms.input x-show="$wire.is_multiline === false" x-cloak placeholder="production" id="value"
x-bind:label="$wire.is_multiline === false && 'Value'" required />
@if (data_get($parameters, 'application_uuid'))
<x-forms.checkbox id="is_build_time"
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
label="Is Build Variable?" />
@endif
<x-forms.checkbox id="is_multiline" label="Is Multiline?" />
@if (!$shared)
@if (!$shared || $isNixpacks)
<x-forms.checkbox id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
<x-forms.checkbox id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
<x-forms.checkbox id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
<x-forms.checkbox id="is_multiline" label="Is Multiline?" />
<x-forms.button type="submit" @click="slideOverOpen=false">
Save
</x-forms.button>

View file

@ -13,17 +13,34 @@
@endcan
</div>
<div>Environment variables (secrets) for this resource. </div>
@if ($resourceClass === 'App\Models\Application' && data_get($resource, 'build_pack') !== 'dockercompose')
<div class="w-64 pt-2">
@can('manageEnvironment', $resource)
<x-forms.checkbox id="is_env_sorting_enabled" label="Sort alphabetically"
helper="Turn this off if one environment is dependent on an other. It will be sorted by creation order (like you pasted them or in the order you created them)."
instantSave></x-forms.checkbox>
@else
<x-forms.checkbox id="is_env_sorting_enabled" label="Sort alphabetically"
helper="Turn this off if one environment is dependent on an other. It will be sorted by creation order (like you pasted them or in the order you created them)."
disabled></x-forms.checkbox>
@endcan
@if ($resourceClass === 'App\Models\Application')
<div class="flex flex-col gap-2 pt-2">
@if (data_get($resource, 'build_pack') !== 'dockercompose')
<div class="w-64">
@can('manageEnvironment', $resource)
<x-forms.checkbox id="is_env_sorting_enabled" label="Sort alphabetically"
helper="Turn this off if one environment is dependent on another. It will be sorted by creation order (like you pasted them or in the order you created them)."
instantSave></x-forms.checkbox>
@else
<x-forms.checkbox id="is_env_sorting_enabled" label="Sort alphabetically"
helper="Turn this off if one environment is dependent on another. It will be sorted by creation order (like you pasted them or in the order you created them)."
disabled></x-forms.checkbox>
@endcan
</div>
@endif
@if (data_get($resource, 'build_pack') !== 'dockercompose')
<div class="w-64">
@can('manageEnvironment', $resource)
<x-forms.checkbox id="use_build_secrets" label="Use Docker Build Secrets"
helper="Enable Docker BuildKit secrets for enhanced security during builds. Secrets won't be exposed in the final image. Requires Docker 18.09+ with BuildKit support."
instantSave></x-forms.checkbox>
@else
<x-forms.checkbox id="use_build_secrets" label="Use Docker Build Secrets"
helper="Enable Docker BuildKit secrets for enhanced security during builds. Secrets won't be exposed in the final image. Requires Docker 18.09+ with BuildKit support."
disabled></x-forms.checkbox>
@endcan
</div>
@endif
</div>
@endif
@if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose')
@ -45,14 +62,7 @@
<h3>Production Environment Variables</h3>
<div>Environment (secrets) variables for Production.</div>
</div>
@php
$requiredEmptyVars = $resource->environment_variables->filter(function ($env) {
return $env->is_required && empty($env->value);
});
$otherVars = $resource->environment_variables->diff($requiredEmptyVars);
@endphp
@forelse ($requiredEmptyVars->merge($otherVars) as $env)
@forelse ($this->environmentVariables as $env)
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}"
:env="$env" :type="$resource->type()" />
@empty
@ -63,7 +73,7 @@
<h3>Preview Deployments Environment Variables</h3>
<div>Environment (secrets) variables for Preview Deployments.</div>
</div>
@foreach ($resource->environment_variables_preview as $env)
@foreach ($this->environmentVariablesPreview as $env)
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}"
:env="$env" :type="$resource->type()" />
@endforeach

View file

@ -55,107 +55,115 @@
</div>
@endcan
@can('update', $this->env)
<div class="flex flex-col w-full gap-2 lg:flex-row">
@if (!$is_redis_credential)
@if ($type === 'service')
<x-forms.checkbox instantSave id="is_build_time"
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
label="Is Build Variable?" />
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else
@if ($is_shared)
<x-forms.checkbox instantSave id="is_build_time"
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
label="Is Build Variable?" />
<div class="flex flex-col w-full gap-3">
<div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap">
@if (!$is_redis_credential)
@if ($type === 'service')
<x-forms.checkbox instantSave id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
<x-forms.checkbox instantSave id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else
@if ($isSharedVariable)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@if ($is_shared)
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else
<x-forms.checkbox instantSave id="is_build_time"
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for dockerfile, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
label="Is Build Variable?" />
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@if ($isSharedVariable)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@else
@if (!$env->is_coolify)
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
@endif
<x-forms.checkbox instantSave id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$env->is_nixpacks)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox instantSave id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
@endif
@endif
@endif
@endif
@endif
@endif
@endif
<div class="flex-1"></div>
@if ($isDisabled)
<x-forms.button disabled type="submit">
Update
</x-forms.button>
<x-forms.button wire:click='lock'>
Lock
</x-forms.button>
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
confirmationText="{{ $key }}" buttonFullWidth="true"
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@else
<x-forms.button type="submit">
Update
</x-forms.button>
<x-forms.button wire:click='lock'>
Lock
</x-forms.button>
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
confirmationText="{{ $key }}" buttonFullWidth="true"
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@endif
</div>
<div class="flex w-full justify-end gap-2">
@if ($isDisabled)
<x-forms.button disabled type="submit">Update</x-forms.button>
<x-forms.button wire:click='lock'>Lock</x-forms.button>
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
confirmationText="{{ $key }}" buttonFullWidth="true"
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@else
<x-forms.button type="submit">Update</x-forms.button>
<x-forms.button wire:click='lock'>Lock</x-forms.button>
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
confirmationText="{{ $key }}" buttonFullWidth="true"
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@endif
</div>
</div>
@else
<div class="flex flex-col w-full gap-2 flex-wrap lg:flex-row">
@if (!$is_redis_credential)
@if ($type === 'service')
<x-forms.checkbox disabled id="is_build_time"
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
label="Is Build Variable?" />
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
<x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else
@if ($is_shared)
<x-forms.checkbox disabled id="is_build_time"
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
label="Is Build Variable?" />
<div class="flex flex-col w-full gap-3">
<div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap">
@if (!$is_redis_credential)
@if ($type === 'service')
<x-forms.checkbox disabled id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
<x-forms.checkbox disabled id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
<x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else
@if ($isSharedVariable)
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@if ($is_shared)
<x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@else
<x-forms.checkbox disabled id="is_build_time"
helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for dockerfile, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`"
label="Is Build Variable?" />
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@if ($isSharedVariable)
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@else
<x-forms.checkbox disabled id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
<x-forms.checkbox disabled id="is_runtime"
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox disabled id="is_literal"
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
label="Is Literal?" />
@endif
@endif
@endif
@endif
@endif
@endif
<div class="flex-1"></div>
</div>
</div>
@endcan
@endif

View file

@ -20,7 +20,11 @@
@if (count($containers) === 0)
<div>No containers are running or terminal access is disabled on this server.</div>
@else
<form class="w-full flex gap-2 items-end" wire:submit="$dispatchSelf('connectToContainer')">
<form class="w-96 min-w-fit flex gap-2 items-end" wire:submit="$dispatchSelf('connectToContainer')"
x-data="{ autoConnected: false }" x-init="if ({{ count($containers) }} === 1 && !autoConnected) {
autoConnected = true;
$nextTick(() => $wire.dispatchSelf('connectToContainer'));
}">
<x-forms.select label="Container" id="container" required wire:model.live="selected_container">
@foreach ($containers as $container)
@if ($loop->first)

View file

@ -14,7 +14,7 @@
}">
@forelse($executions as $execution)
<a wire:click="selectTask({{ data_get($execution, 'id') }})" @class([
'flex flex-col border-l-2 transition-colors p-4 cursor-pointer bg-white hover:bg-gray-100 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 text-black dark:text-white',
'relative flex flex-col border-l-2 transition-colors p-4 cursor-pointer bg-white hover:bg-gray-100 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 text-black dark:text-white',
'bg-gray-200 dark:bg-coolgray-200' => data_get($execution, 'id') == $selectedKey,
'border-blue-500/50 border-dashed' => data_get($execution, 'status') === 'running',
'border-error' => data_get($execution, 'status') === 'failed',
@ -67,18 +67,22 @@
@endif
@if ($this->logLines->isNotEmpty())
<div>
<pre class="whitespace-pre-wrap">
<div class="max-h-[600px] overflow-y-auto border border-gray-200 dark:border-coolgray-300 rounded p-4 bg-gray-50 dark:bg-coolgray-100 scrollbar">
<pre class="whitespace-pre-wrap">
@foreach ($this->logLines as $line)
{{ $line }}
@endforeach
</pre>
<div class="flex gap-2">
</div>
<div class="flex gap-2 mt-4">
@if ($this->hasMoreLogs())
<x-forms.button wire:click.prevent="loadMoreLogs" isHighlighted>
Load More
</x-forms.button>
<x-forms.button wire:click.prevent="loadAllLogs">
Load All
</x-forms.button>
@endif
</div>
</div>
@else

View file

@ -211,6 +211,14 @@ class="w-full input opacity-50 cursor-not-allowed"
:canResource="$server">Save</x-forms.button>
<x-forms.button wire:click='restartSentinel' canGate="update"
:canResource="$server">Restart</x-forms.button>
<x-slide-over fullScreen>
<x-slot:title>Sentinel Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server"
container="coolify-sentinel" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">Logs</x-forms.button>
</x-slide-over>
@else
<x-status.stopped status="Out of sync" noLoading
title="{{ $sentinelUpdatedAt }}" />
@ -218,6 +226,14 @@ class="w-full input opacity-50 cursor-not-allowed"
:canResource="$server">Save</x-forms.button>
<x-forms.button wire:click='restartSentinel' canGate="update"
:canResource="$server">Sync</x-forms.button>
<x-slide-over fullScreen>
<x-slot:title>Sentinel Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server"
container="coolify-sentinel" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">Logs</x-forms.button>
</x-slide-over>
@endif
</div>
@endif
@ -243,6 +259,22 @@ class="w-full input opacity-50 cursor-not-allowed"
label="Enable Metrics (enable Sentinel first)" />
@endif
</div>
@if (isDev() && $server->isSentinelEnabled())
<div class="pt-4" x-data="{
customImage: localStorage.getItem('sentinel_custom_docker_image_{{ $server->uuid }}') || '',
saveCustomImage() {
localStorage.setItem('sentinel_custom_docker_image_{{ $server->uuid }}', this.customImage);
$wire.set('sentinelCustomDockerImage', this.customImage);
}
}" x-init="$wire.set('sentinelCustomDockerImage', customImage)">
<x-forms.input
x-model="customImage"
@input.debounce.500ms="saveCustomImage()"
placeholder="e.g., sentinel:latest or myregistry/sentinel:dev"
label="Custom Sentinel Docker Image (Dev Only)"
helper="Override the default Sentinel Docker image for testing. Leave empty to use the default." />
</div>
@endif
@if ($server->isSentinelEnabled())
<div class="flex flex-wrap gap-2 sm:flex-nowrap items-end">
<x-forms.input canGate="update" :canResource="$server" type="password" id="sentinelToken"

View file

@ -0,0 +1,103 @@
# documentation: https://n8n.io
# slogan: n8n is an extendable workflow automation tool with queue mode and workers.
# category: automation
# tags: n8n,workflow,automation,open,source,low,code,queue,worker,scalable
# logo: svgs/n8n.png
# port: 5678
services:
n8n:
image: docker.n8n.io/n8nio/n8n
environment:
- SERVICE_URL_N8N_5678
- N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N}
- WEBHOOK_URL=${SERVICE_URL_N8N}
- N8N_HOST=${SERVICE_URL_N8N}
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-Europe/Berlin}
- TZ=${TZ:-Europe/Berlin}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB:-n8n}
- DB_POSTGRESDB_HOST=postgresql
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_USER=$SERVICE_USER_POSTGRES
- DB_POSTGRESDB_SCHEMA=public
- DB_POSTGRESDB_PASSWORD=$SERVICE_PASSWORD_POSTGRES
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_HEALTH_CHECK_ACTIVE=true
- N8N_ENCRYPTION_KEY=${SERVICE_PASSWORD_ENCRYPTION}
- N8N_RUNNERS_ENABLED=true
- OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true
- N8N_BLOCK_ENV_ACCESS_IN_NODE=${N8N_BLOCK_ENV_ACCESS_IN_NODE:-true}
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-true}
volumes:
- n8n-data:/home/node/.n8n
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5678/"]
interval: 5s
timeout: 20s
retries: 10
n8n-worker:
image: docker.n8n.io/n8nio/n8n
command: worker
environment:
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-Europe/Berlin}
- TZ=${TZ:-Europe/Berlin}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB:-n8n}
- DB_POSTGRESDB_HOST=postgresql
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_USER=$SERVICE_USER_POSTGRES
- DB_POSTGRESDB_SCHEMA=public
- DB_POSTGRESDB_PASSWORD=$SERVICE_PASSWORD_POSTGRES
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_HEALTH_CHECK_ACTIVE=true
- N8N_ENCRYPTION_KEY=${SERVICE_PASSWORD_ENCRYPTION}
- N8N_RUNNERS_ENABLED=true
- N8N_BLOCK_ENV_ACCESS_IN_NODE=${N8N_BLOCK_ENV_ACCESS_IN_NODE:-true}
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-true}
volumes:
- n8n-data:/home/node/.n8n
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5678/healthz"]
interval: 5s
timeout: 20s
retries: 10
depends_on:
n8n:
condition: service_healthy
postgresql:
condition: service_healthy
redis:
condition: service_healthy
postgresql:
image: postgres:16-alpine
volumes:
- postgresql-data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=$SERVICE_USER_POSTGRES
- POSTGRES_PASSWORD=$SERVICE_PASSWORD_POSTGRES
- POSTGRES_DB=${POSTGRES_DB:-n8n}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 20s
retries: 10
redis:
image: redis:6-alpine
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more