Merge branch 'next' into next

This commit is contained in:
Sepcnt 2025-09-25 20:42:54 +08:00 committed by GitHub
commit dcca834113
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
167 changed files with 8450 additions and 3131 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>

56
.github/workflows/chore-pr-comments.yml vendored Normal file
View file

@ -0,0 +1,56 @@
name: Add comment based on label
on:
pull_request_target:
types:
- labeled
jobs:
add-comment:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
actions: none
checks: none
deployments: none
issues: none
packages: none
repository-projects: none
security-events: none
statuses: none
strategy:
matrix:
include:
- label: "⚙️ Service"
body: |
Hi @${{ github.event.pull_request.user.login }}! 👋
It appears to us that you are either adding a new service or making changes to an existing one.
We kindly ask you to also review and update the **Coolify Documentation** to include this new service or it's new configuration needs.
This will help ensure that our documentation remains accurate and up-to-date for all users.
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
How to Contribute a new Service to the Docs: https://coolify.io/docs/get-started/contribute/service#adding-a-new-service-template-to-the-coolify-documentation
- label: "🛠️ Feature"
body: |
Hi @${{ github.event.pull_request.user.login }}! 👋
It appears to us that you are adding a new feature to Coolify.
We kindly ask you to also update the **Coolify Documentation** to include information about this new feature.
This will help ensure that our documentation remains accurate and up-to-date for all users.
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
How to Contribute to the Docs: https://coolify.io/docs/get-started/contribute/documentation
# - label: "✨ Enhancement"
# body: |
# It appears to us that you are making an enhancement to Coolify.
# We kindly ask you to also review and update the Coolify Documentation to include information about this enhancement if applicable.
# This will help ensure that our documentation remains accurate and up-to-date for all users.
steps:
- name: Add comment
if: github.event.label.name == matrix.label
run: gh pr comment "$NUMBER" --body "$BODY"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.pull_request.number }}
BODY: ${{ matrix.body }}

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,252 @@ # Changelog
## [unreleased] ## [unreleased]
### 🐛 Bug Fixes
- *(docker)* Adjust openssh-client installation in Dockerfile to avoid version bug
- *(docker)* Streamline openssh-client installation in Dockerfile
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(versions)* Increment coolify version numbers to 4.0.0-beta.431 and 4.0.0-beta.432 in configuration files
## [4.0.0-beta.430] - 2025-09-24
### 🐛 Bug Fixes
- *(PreviewCompose)* Adds port to preview urls
- *(deployment-job)* Enhance build time variable analysis
### 📚 Documentation
- Update changelog
- Update changelog
## [4.0.0-beta.429] - 2025-09-23
### 🚀 Features
- *(environment)* Replace is_buildtime_only with is_runtime and is_buildtime flags for environment variables, updating related logic and views
- *(deployment)* Handle buildtime and runtime variables during deployment
- *(search)* Implement global search functionality with caching and modal interface
- *(search)* Enable query logging for global search caching
- *(environment)* Add dynamic checkbox options for environment variable settings based on user permissions and variable types
- *(redaction)* Implement sensitive information redaction in logs and commands
- *(api)* Add endpoint to update backup configuration by UUID and backup ID; modify response to include backup id
- *(databases)* Enhance backup management API with new endpoints and improved data handling
- *(github)* Add GitHub app management endpoints
- *(github)* Add update and delete endpoints for GitHub apps
- *(databases)* Enhance backup update and deletion logic with validation
- *(environment-variables)* Implement environment variable analysis for build-time issues
- *(databases)* Implement unique UUID generation for backup execution
- *(cloud-check)* Enhance subscription reporting in CloudCheckSubscription command
- *(cloud-check)* Enhance CloudCheckSubscription command with fix options
- *(stripe)* Enhance subscription handling and verification process
- *(private-key-refresh)* Add refresh dispatch on private key update and connection check
- *(comments)* Add automated comments for labeled pull requests to guide documentation updates
- *(comments)* Ping PR author
### 🐛 Bug Fixes
- *(docker)* Enhance container status aggregation to include restarting and exited states
- *(environment)* Correct grammatical errors in helper text for environment variable sorting checkbox
- *(ui)* Change order and fix ui on small screens
- Order for git deploy types
- *(deployment)* Enhance Dockerfile modification for build-time variables and secrets during deployment in case of docker compose buildpack
- Hide sensitive email change fields in team member responses
- *(domains)* Trim whitespace from domains before validation
- *(databases)* Update backup retrieval logic to include team context
- *(environment-variables)* Update affected services in environment variable analysis
- *(team)* Clear stripe_subscription_id on subscription end
- *(github)* Update authentication method for GitHub app operations
- *(databases)* Restrict database updates to allowed fields only
- *(cache)* Add Model import to ClearsGlobalSearchCache trait for improved functionality
- *(environment-variables)* Correct method call syntax in analyzeBuildVariable function
- *(clears-global-search-cache)* Refine team retrieval logic in getTeamIdForCache method
- *(subscription-job)* Enhance retry logic for VerifyStripeSubscriptionStatusJob
- *(environment-variable)* Update checkbox visibility and helper text for build and runtime options
- *(deployment-job)* Escape single quotes in build arguments for Docker Compose command
### 🚜 Refactor
- *(environment)* Conditionally render Docker Build Secrets checkbox based on build pack type
- *(search)* Optimize cache clearing logic to only trigger on searchable field changes
- *(environment)* Streamline rendering of Docker Build Secrets checkbox and adjust layout for environment variable settings
- *(proxy)* Streamline proxy configuration form layout and improve button placements
- *(remoteProcess)* Remove redundant file transfer functions for improved clarity
- *(github)* Enhance API request handling and validation
- *(databases)* Remove deprecated backup parameters from API documentation
- *(databases)* Streamline backup queries to use team context
- *(databases)* Update backup queries to use team-specific method
- *(server)* Update dispatch messages and streamline data synchronization
- *(cache)* Update team retrieval method in ClearsGlobalSearchCache trait
- *(database-backup)* Move unique UUID generation for backup execution to database loop
- *(cloud-commands)* Consolidate and enhance subscription management commands
- *(toast-component)* Improve layout and icon handling in toast notifications
- *(private-key-update)* Implement transaction for private key association and connection validation
### 📚 Documentation
- Update changelog
- Update changelog
- *(claude)* Update testing guidelines and add note on Application::team relationship
### 🎨 Styling
- *(environment-variable)* Adjust SVG icon margin for improved layout in locked state
- *(proxy)* Adjust padding in proxy configuration form for better visual alignment
### ⚙️ Miscellaneous Tasks
- Change order of runtime and buildtime
- *(docker-compose)* Update soketi image version to 1.0.10 in production and Windows configurations
- *(versions)* Update coolify version numbers to 4.0.0-beta.430 and 4.0.0-beta.431 in configuration files
## [4.0.0-beta.428] - 2025-09-15
### 📚 Documentation
- Update changelog
## [4.0.0-beta.427] - 2025-09-15
### 🚀 Features
- Improve detection of special network modes
- *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic
- *(ui)* Display current version in settings dropdown and update UI accordingly
- *(settings)* Add option to restrict PR deployments to repository members and contributors
- *(command)* Implement SSH command retry logic with exponential backoff and logging for better error handling
- *(ssh)* Add Sentry tracking for SSH retry events to enhance error monitoring
- *(exceptions)* Introduce NonReportableException to handle known errors and update Handler for selective reporting
- *(sudo-helper)* Add helper functions for command parsing and ownership management with sudo
- *(dev-command)* Dispatch CheckHelperImageJob during instance initialization to enhance setup process
- *(ssh-multiplexing)* Enhance multiplexed connection management with health checks and metadata caching
- *(ssh-multiplexing)* Add connection age metadata handling to improve multiplexed connection management
- *(database-backup)* Enhance error handling and output management in DatabaseBackupJob
- *(application)* Display parsing version in development mode and clean up domain conflict modal markup
- *(deployment)* Add SERVICE_NAME variables for service discovery
- *(storages)* Add method to retrieve the first storage ID for improved stability in storage display
- *(environment)* Add 'is_literal' attribute to environment variable for enhanced configuration options
- *(pre-commit)* Automate generation of service templates and OpenAPI documentation during pre-commit hook
- *(execute-container)* Enhance container command form with auto-connect feature for single container scenarios
- *(environment)* Introduce 'is_buildtime_only' attribute to environment variables for improved build-time configuration
- *(templates)* Add n8n service with PostgreSQL and worker support for enhanced workflow automation
- *(user-management)* Implement user deletion command with phased resource and subscription cancellation, including dry run option
- *(sentinel)* Add support for custom Docker images in StartSentinel and related methods
- *(sentinel)* Add slide-over for viewing Sentinel logs and custom Docker image input for development
- *(executions)* Add 'Load All' button to view all logs and implement loadAllLogs method for complete log retrieval
- *(auth)* Enhance user login flow to handle team invitations, attaching users to invited teams upon first login and maintaining personal team logic for regular logins
- *(laravel-boost)* Add Laravel Boost guidelines and MCP server configuration to enhance development experience
- *(deployment)* Enhance deployment status reporting with detailed information on active deployments and team members
- *(deployment)* Implement cancellation checks during deployment process to enhance user control and prevent unnecessary execution
- *(deployment)* Introduce 'use_build_secrets' setting for enhanced security during Docker builds and update related logic in deployment process
### 🐛 Bug Fixes
- *(ui)* Transactional email settings link on members page (#6491)
- *(api)* Add custom labels generation for applications with readonly container label setting enabled
- *(ui)* Add cursor pointer to upgrade button for better user interaction
- *(templates)* Update SECRET_KEY environment variable in getoutline.yaml to use SERVICE_HEX_32_OUTLINE
- *(command)* Enhance database deletion command to support multiple database types
- *(command)* Enhance cleanup process for stuck application previews by adding force delete for trashed records
- *(user)* Ensure email attributes are stored in lowercase for consistency and prevent case-related issues
- *(webhook)* Replace delete with forceDelete for application previews to ensure immediate removal
- *(ssh)* Introduce SshRetryHandler and SshRetryable trait for enhanced SSH command retry logic with exponential backoff and error handling
- Appwrite template - 500 errors, missing env vars etc.
- *(LocalFileVolume)* Add missing directory creation command for workdir in saveStorageOnServer method
- *(ScheduledTaskJob)* Replace generic Exception with NonReportableException for better error handling
- *(web-routes)* Enhance backup response messages to clarify local and S3 availability
- *(proxy)* Replace CheckConfiguration with GetProxyConfiguration and SaveConfiguration with SaveProxyConfiguration for improved clarity and consistency in proxy management
- *(private-key)* Implement transaction handling and error verification for private key storage operations
- *(deployment)* Add COOLIFY_* environment variables to Nixpacks build context for enhanced deployment configuration
- *(application)* Add functionality to stop and remove Docker containers on server
- *(templates)* Update 'compose' configuration for Appwrite service to enhance compatibility and streamline deployment
- *(security)* Update contact email for reporting vulnerabilities to enhance privacy
- *(feedback)* Update feedback email address to improve communication with users
- *(security)* Update contact email for vulnerability reports to improve security communication
- *(navbar)* Restrict subscription link visibility to admin users in cloud environment
- *(docker)* Enhance container status aggregation for multi-container applications, including exclusion handling based on docker-compose configuration
- *(application)* Improve watch paths handling by trimming and filtering empty paths to prevent unnecessary triggers
- *(server)* Update server usability check to reflect actual Docker availability status
- *(server)* Add build server check to disable Sentinel and update related logic
- *(server)* Implement refreshServer method and update navbar event listener for improved server state management
- *(deployment)* Prevent removal of running containers for pull request deployments in case of failure
- *(docker)* Redirect stderr to stdout for container log retrieval to capture error messages
- *(clone)* Update destinations method call to ensure correct retrieval of selected destination
### 🚜 Refactor
- *(jobs)* Pull github changelogs from cdn instead of github
- *(command)* Streamline database deletion process to handle multiple database types and improve user experience
- *(command)* Improve database collection logic for deletion command by using unique identifiers and enhancing user experience
- *(command)* Remove InitChangelog command as it is no longer needed
- *(command)* Streamline Init command by removing unnecessary options and enhancing error handling for various operations
- *(webhook)* Replace direct forceDelete calls with DeleteResourceJob dispatch for application previews
- *(command)* Replace forceDelete calls with DeleteResourceJob dispatch for all stuck resources in cleanup process
- *(command)* Simplify SSH command retry logic by removing unnecessary logging and improving delay calculation
- *(ssh)* Enhance error handling in SSH command execution and improve connection validation logging
- *(backlog)* Remove outdated guidelines and project manager agent files to streamline task management documentation
- *(error-handling)* Remove ray debugging statements from CheckUpdates and shared helper functions to clean up error reporting
- *(file-transfer)* Replace base64 encoding with direct file transfer method across multiple database actions for improved clarity and efficiency
- *(remoteProcess)* Remove debugging statement from transfer_file_to_server function to clean up code
- *(dns-validation)* Rename DNS validation functions for consistency and clarity, and remove unused code
- *(file-transfer)* Replace base64 encoding with direct file transfer method in various components for improved clarity and efficiency
- *(private-key)* Remove debugging statement from storeInFileSystem method for cleaner code
- *(github-webhook)* Restructure application processing by grouping applications by server for improved deployment handling
- *(deployment)* Enhance queuing logic to support concurrent deployments by including pull request ID in checks
- *(remoteProcess)* Remove debugging statement from transfer_file_to_container function for cleaner code
- *(deployment)* Streamline next deployment queuing logic by repositioning queue_next_deployment call
- *(deployment)* Add validation for pull request existence in deployment process to enhance error handling
- *(database)* Remove volume_configuration_dir and streamline configuration directory usage in MongoDB and PostgreSQL handlers
- *(application-source)* Improve layout and accessibility of Git repository links in the application source view
- *(models)* Remove 'is_readonly' attribute from multiple database models for consistency
- *(webhook)* Remove Webhook model and related logic; add migrations to drop webhooks and kubernetes tables
- *(clone)* Consolidate application cloning logic into a dedicated function for improved maintainability and readability
- *(clone)* Integrate preview cloning logic directly into application cloning function for improved clarity and maintainability
- *(application)* Enhance environment variable retrieval in configuration change check for improved accuracy
- *(clone)* Enhance application cloning by separating production and preview environment variable handling
- *(deployment)* Add environment variable copying logic to Docker build commands for pull requests
- *(environment)* Standardize service name formatting by replacing '-' and '.' with '_' in environment variable keys
- *(deployment)* Update environment file handling in Docker commands to use '/artifacts/' path and streamline variable management
- *(openapi)* Remove 'is_build_time' attribute from environment variable definitions to streamline configuration
- *(environment)* Remove 'is_build_time' attribute from environment variable handling across the application to simplify configuration
- *(environment)* Streamline environment variable handling by replacing sorting methods with direct property access and enhancing query ordering for improved performance
- *(stripe-jobs)* Comment out internal notification calls and add subscription status verification before sending failure notifications
- *(deployment)* Streamline environment variable handling for dockercompose and improve sorting of runtime variables
- *(remoteProcess)* Remove command log comments for file transfers to simplify code
- *(remoteProcess)* Remove file transfer handling from remote_process and instant_remote_process functions to simplify code
- *(deployment)* Update environment file paths in docker compose commands to use working directory for improved consistency
- *(server)* Remove debugging ray call from validateConnection method for cleaner code
- *(deployment)* Conditionally cleanup build secrets based on Docker BuildKit support and remove redundant calls for improved efficiency
- *(deployment)* Remove redundant environment variable documentation from Dockerfile comments to streamline the deployment process
- *(deployment)* Streamline Docker BuildKit detection and environment variable handling for enhanced security during application deployment
- *(deployment)* Optimize BuildKit capabilities detection and remove unnecessary comments for cleaner deployment logic
- *(deployment)* Rename method for modifying Dockerfile to improve clarity and streamline build secrets integration
### 📚 Documentation
- Update changelog
- *(testing-patterns)* Add important note to always run tests inside the `coolify` container for clarity
### ⚙️ Miscellaneous Tasks
- Update coolify version to 4.0.0-beta.427 and nightly version to 4.0.0-beta.428
- Use main value then fallback to service_ values
- Remove webhooks table cleanup
- *(cleanup)* Remove deprecated ServerCheck and related job classes to streamline codebase
- *(versions)* Update sentinel version from 0.0.15 to 0.0.16 in versions.json files
- *(constants)* Update realtime_version from 1.0.10 to 1.0.11
- *(versions)* Increment coolify version to 4.0.0-beta.428 and update realtime_version to 1.0.10
- *(docker)* Add a blank line for improved readability in Dockerfile
- *(versions)* Bump coolify version to 4.0.0-beta.429 and nightly version to 4.0.0-beta.430
## [4.0.0-beta.426] - 2025-08-28
### 🚜 Refactor ### 🚜 Refactor
- *(policy)* Simplify ServiceDatabasePolicy methods to always return true and add manageBackups method - *(policy)* Simplify ServiceDatabasePolicy methods to always return true and add manageBackups method

409
CLAUDE.md
View file

@ -247,3 +247,412 @@ ### Project Information
- [Project Overview](.cursor/rules/project-overview.mdc) - High-level project structure - [Project Overview](.cursor/rules/project-overview.mdc) - High-level project structure
- [Technology Stack](.cursor/rules/technology-stack.mdc) - Detailed tech stack information - [Technology Stack](.cursor/rules/technology-stack.mdc) - Detailed tech stack information
- [Cursor Rules Guide](.cursor/rules/cursor_rules.mdc) - How to maintain cursor rules - [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>
Random other things you should remember:
- App\Models\Application::team must return a relationship instance., always use team()

View file

@ -76,7 +76,7 @@ ## Big Sponsors
* [QuantCDN](https://www.quantcdn.io?ref=coolify.io) - Enterprise-grade content delivery network * [QuantCDN](https://www.quantcdn.io?ref=coolify.io) - Enterprise-grade content delivery network
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang * [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers * [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital transformation and web solutions * [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
* [Cloudify.ro](https://cloudify.ro?ref=coolify.io) - Cloud hosting solutions * [Cloudify.ro](https://cloudify.ro?ref=coolify.io) - Cloud hosting solutions
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half * [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services * [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services

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 = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$this->commands[] = [ $docker_compose_base64 = base64_encode($docker_compose);
'transfer_file' => [ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
'content' => $docker_compose,
'destination' => "$this->configuration_dir/docker-compose.yml",
],
];
$readme = generate_readme_file($this->database->name, now()); $readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $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); $configuration_dir = database_proxy_dir($database->uuid);
$volume_configuration_dir = $configuration_dir;
if (isDev()) { 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 $nginxconf = <<<EOF
user nginx; user nginx;
@ -87,7 +86,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
'volumes' => [ 'volumes' => [
[ [
'type' => 'bind', 'type' => 'bind',
'source' => "$volume_configuration_dir/nginx.conf", 'source' => "$configuration_dir/nginx.conf",
'target' => '/etc/nginx/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(["docker rm -f $proxyContainerName"], $server, false);
instant_remote_process([ instant_remote_process([
"mkdir -p $configuration_dir", "mkdir -p $configuration_dir",
[ "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
'transfer_file' => [ "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
'content' => base64_decode($nginxconf_base64),
'destination' => "$configuration_dir/nginx.conf",
],
],
[
'transfer_file' => [
'content' => base64_decode($dockercompose_base64),
'destination' => "$configuration_dir/docker-compose.yaml",
],
],
"docker compose --project-directory {$configuration_dir} pull", "docker compose --project-directory {$configuration_dir} pull",
"docker compose --project-directory {$configuration_dir} up -d", "docker compose --project-directory {$configuration_dir} up -d",
], $server); ], $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 = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$this->commands[] = [ $docker_compose_base64 = base64_encode($docker_compose);
'transfer_file' => [ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
'content' => $docker_compose,
'destination' => "$this->configuration_dir/docker-compose.yml",
],
];
$readme = generate_readme_file($this->database->name, now()); $readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $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_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 = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$this->commands[] = [ $docker_compose_base64 = base64_encode($docker_compose);
'transfer_file' => [ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
'content' => $docker_compose,
'destination' => "$this->configuration_dir/docker-compose.yml",
],
];
$readme = generate_readme_file($this->database->name, now()); $readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $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); $docker_compose = Yaml::dump($docker_compose, 10);
$this->commands[] = [ $docker_compose_base64 = base64_encode($docker_compose);
'transfer_file' => [ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
'content' => $docker_compose,
'destination' => "$this->configuration_dir/docker-compose.yml",
],
];
$readme = generate_readme_file($this->database->name, now()); $readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
@ -288,11 +284,7 @@ private function add_custom_mysql()
} }
$filename = 'custom-config.cnf'; $filename = 'custom-config.cnf';
$content = $this->database->mariadb_conf; $content = $this->database->mariadb_conf;
$this->commands[] = [ $content_base64 = base64_encode($content);
'transfer_file' => [ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
'content' => $content,
'destination' => "$this->configuration_dir/{$filename}",
],
];
} }
} }

View file

@ -28,6 +28,9 @@ public function handle(StandaloneMongodb $database)
$container_name = $this->database->uuid; $container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name; $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 = [ $this->commands = [
"echo 'Starting database.'", "echo 'Starting database.'",
@ -251,12 +254,8 @@ public function handle(StandaloneMongodb $database)
} }
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$this->commands[] = [ $docker_compose_base64 = base64_encode($docker_compose);
'transfer_file' => [ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
'content' => $docker_compose,
'destination' => "$this->configuration_dir/docker-compose.yml",
],
];
$readme = generate_readme_file($this->database->name, now()); $readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
@ -333,22 +332,15 @@ private function add_custom_mongo_conf()
} }
$filename = 'mongod.conf'; $filename = 'mongod.conf';
$content = $this->database->mongo_conf; $content = $this->database->mongo_conf;
$this->commands[] = [ $content_base64 = base64_encode($content);
'transfer_file' => [ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
'content' => $content,
'destination' => "$this->configuration_dir/{$filename}",
],
];
} }
private function add_default_database() 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}\"}]});"; $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[] = [ $content_base64 = base64_encode($content);
'transfer_file' => [ $this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
'content' => $content, $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null";
'destination' => "$this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js",
],
];
} }
} }

View file

@ -204,12 +204,8 @@ public function handle(StandaloneMysql $database)
} }
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$this->commands[] = [ $docker_compose_base64 = base64_encode($docker_compose);
'transfer_file' => [ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
'content' => $docker_compose,
'destination' => "$this->configuration_dir/docker-compose.yml",
],
];
$readme = generate_readme_file($this->database->name, now()); $readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
@ -291,11 +287,7 @@ private function add_custom_mysql()
} }
$filename = 'custom-config.cnf'; $filename = 'custom-config.cnf';
$content = $this->database->mysql_conf; $content = $this->database->mysql_conf;
$this->commands[] = [ $content_base64 = base64_encode($content);
'transfer_file' => [ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
'content' => $content,
'destination' => "$this->configuration_dir/{$filename}",
],
];
} }
} }

View file

@ -27,6 +27,9 @@ public function handle(StandalonePostgresql $database)
$this->database = $database; $this->database = $database;
$container_name = $this->database->uuid; $container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name; $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 = [ $this->commands = [
"echo 'Starting database.'", "echo 'Starting database.'",
@ -214,12 +217,8 @@ public function handle(StandalonePostgresql $database)
} }
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$this->commands[] = [ $docker_compose_base64 = base64_encode($docker_compose);
'transfer_file' => [ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
'content' => $docker_compose,
'destination' => "$this->configuration_dir/docker-compose.yml",
],
];
$readme = generate_readme_file($this->database->name, now()); $readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
@ -305,12 +304,8 @@ private function generate_init_scripts()
foreach ($this->database->init_scripts as $init_script) { foreach ($this->database->init_scripts as $init_script) {
$filename = data_get($init_script, 'filename'); $filename = data_get($init_script, 'filename');
$content = data_get($init_script, 'content'); $content = data_get($init_script, 'content');
$this->commands[] = [ $content_base64 = base64_encode($content);
'transfer_file' => [ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/{$filename} > /dev/null";
'content' => $content,
'destination' => "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}",
],
];
$this->init_scripts[] = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}"; $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->postgres_conf = $content;
$this->database->save(); $this->database->save();
} }
$this->commands[] = [ $content_base64 = base64_encode($content);
'transfer_file' => [ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $config_file_path > /dev/null";
'content' => $content,
'destination' => $config_file_path,
],
];
} }
} }

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 = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$this->commands[] = [ $docker_compose_base64 = base64_encode($docker_compose);
'transfer_file' => [ $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
'content' => $docker_compose,
'destination' => "$this->configuration_dir/docker-compose.yml",
],
];
$readme = generate_readme_file($this->database->name, now()); $readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";

View file

@ -96,7 +96,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
} }
$containerStatus = data_get($container, 'State.Status'); $containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); $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)); $labels = Arr::undot(format_docker_labels_to_json($labels));
$applicationId = data_get($labels, 'coolify.applicationId'); $applicationId = data_get($labels, 'coolify.applicationId');
if ($applicationId) { if ($applicationId) {
@ -386,19 +390,33 @@ private function aggregateApplicationStatus($application, Collection $containerS
return null; return null;
} }
// Aggregate status: if any container is running, app is running
$hasRunning = false; $hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false; $hasUnhealthy = false;
$hasExited = false;
foreach ($relevantStatuses as $status) { foreach ($relevantStatuses as $status) {
if (str($status)->contains('running')) { if (str($status)->contains('restarting')) {
$hasRestarting = true;
} elseif (str($status)->contains('running')) {
$hasRunning = true; $hasRunning = true;
if (str($status)->contains('unhealthy')) { if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true; $hasUnhealthy = true;
} }
} elseif (str($status)->contains('exited')) {
$hasExited = true;
$hasUnhealthy = true;
} }
} }
if ($hasRestarting) {
return 'degraded (unhealthy)';
}
if ($hasRunning && $hasExited) {
return 'degraded (unhealthy)';
}
if ($hasRunning) { if ($hasRunning) {
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)'; return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
} }

View file

@ -21,12 +21,7 @@ public function handle(Server $server, string $configuration): void
// Transfer the configuration file to the server // Transfer the configuration file to the server
instant_remote_process([ instant_remote_process([
"mkdir -p $proxy_path", "mkdir -p $proxy_path",
[ "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
'transfer_file' => [
'content' => base64_decode($docker_compose_yml_base64),
'destination' => "$proxy_path/docker-compose.yml",
],
],
], $server); ], $server);
} }
} }

View file

@ -40,12 +40,7 @@ public function handle(Server $server, string $cloudflare_token, string $ssh_dom
$commands = collect([ $commands = collect([
'mkdir -p /tmp/cloudflared', 'mkdir -p /tmp/cloudflared',
'cd /tmp/cloudflared', 'cd /tmp/cloudflared',
[ "echo '$docker_compose_yml_base64' | base64 -d | tee docker-compose.yml > /dev/null",
'transfer_file' => [
'content' => base64_decode($docker_compose_yml_base64),
'destination' => '/tmp/cloudflared/docker-compose.yml',
],
],
'echo Pulling latest Cloudflare Tunnel image.', 'echo Pulling latest Cloudflare Tunnel image.',
'docker compose pull', 'docker compose pull',
'echo Stopping existing Cloudflare Tunnel container.', 'echo Stopping existing Cloudflare Tunnel container.',

View file

@ -14,7 +14,6 @@ class InstallDocker
public function handle(Server $server) public function handle(Server $server)
{ {
ray('install docker');
$dockerVersion = config('constants.docker.minimum_required_version'); $dockerVersion = config('constants.docker.minimum_required_version');
$supported_os_type = $server->validateOS(); $supported_os_type = $server->validateOS();
if (! $supported_os_type) { 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}", "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)...'", "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")"', 'test -s /etc/docker/daemon.json && cp /etc/docker/daemon.json "/etc/docker/daemon.json.original-$(date +"%Y%m%d-%H%M%S")"',
[ "test ! -s /etc/docker/daemon.json && echo '{$config}' | base64 -d | tee /etc/docker/daemon.json > /dev/null",
'transfer_file' => [ "echo '{$config}' | base64 -d | tee /etc/docker/daemon.json.coolify > /dev/null",
'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',
'jq . /etc/docker/daemon.json.coolify | tee /etc/docker/daemon.json.coolify.pretty > /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', '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", "jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null",

View file

@ -180,30 +180,10 @@ public function handle(Server $server)
$command = [ $command = [
"echo 'Saving configuration'", "echo 'Saving configuration'",
"mkdir -p $config_path", "mkdir -p $config_path",
[ "echo '{$parsers}' | base64 -d | tee $parsers_config > /dev/null",
'transfer_file' => [ "echo '{$config}' | base64 -d | tee $fluent_bit_config > /dev/null",
'content' => base64_decode($parsers), "echo '{$compose}' | base64 -d | tee $compose_path > /dev/null",
'destination' => $parsers_config, "echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
],
],
[
'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,
],
],
"test -f $config_path/.env && rm $config_path/.env", "test -f $config_path/.env && rm $config_path/.env",
]; ];
if ($type === 'newrelic') { if ($type === 'newrelic') {

View file

@ -26,22 +26,22 @@ public function handle(Application $application)
continue; 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); $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);
$container = format_docker_command_output_to_json($container); $containers = format_docker_command_output_to_json($containers);
if ($container->count() === 1) {
$container = $container->first(); if ($containers->count() > 0) {
$containerStatus = data_get($container, 'State.Status'); $statusToSet = $this->aggregateContainerStatuses($application, $containers);
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
if ($is_main_server) { if ($is_main_server) {
$statusFromDb = $application->status; $statusFromDb = $application->status;
if ($statusFromDb !== $containerStatus) { if ($statusFromDb !== $statusToSet) {
$application->update(['status' => "$containerStatus:$containerHealth"]); $application->update(['status' => $statusToSet]);
} }
} else { } else {
$additional_server = $application->additional_servers()->wherePivot('server_id', $server->id); $additional_server = $application->additional_servers()->wherePivot('server_id', $server->id);
$statusFromDb = $additional_server->first()->pivot->status; $statusFromDb = $additional_server->first()->pivot->status;
if ($statusFromDb !== $containerStatus) { if ($statusFromDb !== $statusToSet) {
$additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]); $additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]);
} }
} }
} else { } 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

@ -1,6 +1,6 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands\Cloud;
use App\Actions\Stripe\CancelSubscription; use App\Actions\Stripe\CancelSubscription;
use App\Actions\User\DeleteUserResources; use App\Actions\User\DeleteUserResources;

View file

@ -0,0 +1,879 @@
<?php
namespace App\Console\Commands\Cloud;
use App\Models\Team;
use Illuminate\Console\Command;
class CloudFixSubscription extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cloud:fix-subscription
{--fix-canceled-subs : Fix canceled subscriptions in database}
{--verify-all : Verify all active subscriptions against Stripe}
{--fix-verified : Fix discrepancies found during verification}
{--dry-run : Show what would be fixed without making changes}
{--one : Only fix the first found subscription}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fix Cloud subscriptions';
/**
* Execute the console command.
*/
public function handle()
{
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
if ($this->option('verify-all')) {
return $this->verifyAllActiveSubscriptions($stripe);
}
if ($this->option('fix-canceled-subs') || $this->option('dry-run')) {
return $this->fixCanceledSubscriptions($stripe);
}
$activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
$out = fopen('php://output', 'w');
// CSV header
fputcsv($out, [
'team_id',
'invoice_status',
'stripe_customer_url',
'stripe_subscription_id',
'subscription_status',
'subscription_url',
'note',
]);
foreach ($activeSubscribers as $team) {
$stripeSubscriptionId = $team->subscription->stripe_subscription_id;
$stripeInvoicePaid = $team->subscription->stripe_invoice_paid;
$stripeCustomerId = $team->subscription->stripe_customer_id;
if (! $stripeSubscriptionId && str($stripeInvoicePaid)->lower() != 'past_due') {
fputcsv($out, [
$team->id,
$stripeInvoicePaid,
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
null,
null,
null,
'Missing subscription ID while invoice not past_due',
]);
continue;
}
if (! $stripeSubscriptionId) {
// No subscription ID and invoice is past_due, still record for visibility
fputcsv($out, [
$team->id,
$stripeInvoicePaid,
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
null,
null,
null,
'Missing subscription ID',
]);
continue;
}
$subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId);
if ($subscription->status === 'active') {
continue;
}
fputcsv($out, [
$team->id,
$stripeInvoicePaid,
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
$stripeSubscriptionId,
$subscription->status,
"https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}",
'Subscription not active',
]);
}
fclose($out);
}
/**
* Fix canceled subscriptions in the database
*/
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
{
$isDryRun = $this->option('dry-run');
$checkOne = $this->option('one');
if ($isDryRun) {
$this->info('DRY RUN MODE - No changes will be made');
if ($checkOne) {
$this->info('Checking only the first canceled subscription...');
} else {
$this->info('Checking for canceled subscriptions...');
}
} else {
if ($checkOne) {
$this->info('Checking and fixing only the first canceled subscription...');
} else {
$this->info('Checking and fixing canceled subscriptions...');
}
}
$teamsWithSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
$toFixCount = 0;
$fixedCount = 0;
$errors = [];
$canceledSubscriptions = [];
foreach ($teamsWithSubscriptions as $team) {
$subscription = $team->subscription;
if (! $subscription->stripe_subscription_id) {
continue;
}
try {
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
if ($stripeSubscription->status === 'canceled') {
$toFixCount++;
// Get team members' emails
$memberEmails = $team->members->pluck('email')->toArray();
$canceledSubscriptions[] = [
'team_id' => $team->id,
'team_name' => $team->name,
'customer_id' => $subscription->stripe_customer_id,
'subscription_id' => $subscription->stripe_subscription_id,
'status' => 'canceled',
'member_emails' => $memberEmails,
'subscription_model' => $subscription->toArray(),
];
if ($isDryRun) {
$this->warn('Would fix canceled subscription:');
$this->line(" Team ID: {$team->id}");
$this->line(" Team Name: {$team->name}");
$this->line(' Team Members: '.implode(', ', $memberEmails));
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
$this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}");
$this->line(' Current Subscription Data:');
foreach ($subscription->getAttributes() as $key => $value) {
if (is_null($value)) {
$this->line(" - {$key}: null");
} elseif (is_bool($value)) {
$this->line(" - {$key}: ".($value ? 'true' : 'false'));
} else {
$this->line(" - {$key}: {$value}");
}
}
$this->newLine();
} else {
$this->warn("Found canceled subscription for Team ID: {$team->id}");
// Send internal notification with all details before fixing
$notificationMessage = "Fixing canceled subscription:\n";
$notificationMessage .= "Team ID: {$team->id}\n";
$notificationMessage .= "Team Name: {$team->name}\n";
$notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n";
$notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n";
$notificationMessage .= "Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}\n";
$notificationMessage .= "Subscription Data:\n";
foreach ($subscription->getAttributes() as $key => $value) {
if (is_null($value)) {
$notificationMessage .= " - {$key}: null\n";
} elseif (is_bool($value)) {
$notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n";
} else {
$notificationMessage .= " - {$key}: {$value}\n";
}
}
send_internal_notification($notificationMessage);
// Apply the same logic as customer.subscription.deleted webhook
$team->subscriptionEnded();
$fixedCount++;
$this->info(" ✓ Fixed subscription for Team ID: {$team->id}");
$this->line(' Team Members: '.implode(', ', $memberEmails));
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
$this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}");
}
// Break if --one flag is set
if ($checkOne) {
break;
}
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
if ($e->getStripeCode() === 'resource_missing') {
$toFixCount++;
// Get team members' emails
$memberEmails = $team->members->pluck('email')->toArray();
$canceledSubscriptions[] = [
'team_id' => $team->id,
'team_name' => $team->name,
'customer_id' => $subscription->stripe_customer_id,
'subscription_id' => $subscription->stripe_subscription_id,
'status' => 'missing',
'member_emails' => $memberEmails,
'subscription_model' => $subscription->toArray(),
];
if ($isDryRun) {
$this->error('Would fix missing subscription (not found in Stripe):');
$this->line(" Team ID: {$team->id}");
$this->line(" Team Name: {$team->name}");
$this->line(' Team Members: '.implode(', ', $memberEmails));
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
$this->line(" Subscription ID (missing): {$subscription->stripe_subscription_id}");
$this->line(' Current Subscription Data:');
foreach ($subscription->getAttributes() as $key => $value) {
if (is_null($value)) {
$this->line(" - {$key}: null");
} elseif (is_bool($value)) {
$this->line(" - {$key}: ".($value ? 'true' : 'false'));
} else {
$this->line(" - {$key}: {$value}");
}
}
$this->newLine();
} else {
$this->error("Subscription not found in Stripe for Team ID: {$team->id}");
// Send internal notification with all details before fixing
$notificationMessage = "Fixing missing subscription (not found in Stripe):\n";
$notificationMessage .= "Team ID: {$team->id}\n";
$notificationMessage .= "Team Name: {$team->name}\n";
$notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n";
$notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n";
$notificationMessage .= "Subscription ID (missing): {$subscription->stripe_subscription_id}\n";
$notificationMessage .= "Subscription Data:\n";
foreach ($subscription->getAttributes() as $key => $value) {
if (is_null($value)) {
$notificationMessage .= " - {$key}: null\n";
} elseif (is_bool($value)) {
$notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n";
} else {
$notificationMessage .= " - {$key}: {$value}\n";
}
}
send_internal_notification($notificationMessage);
// Apply the same logic as customer.subscription.deleted webhook
$team->subscriptionEnded();
$fixedCount++;
$this->info(" ✓ Fixed missing subscription for Team ID: {$team->id}");
$this->line(' Team Members: '.implode(', ', $memberEmails));
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
}
// Break if --one flag is set
if ($checkOne) {
break;
}
} else {
$errors[] = "Team ID {$team->id}: ".$e->getMessage();
}
} catch (\Exception $e) {
$errors[] = "Team ID {$team->id}: ".$e->getMessage();
}
}
$this->newLine();
$this->info('Summary:');
if ($isDryRun) {
$this->info(" - Found {$toFixCount} canceled/missing subscriptions that would be fixed");
if ($toFixCount > 0) {
$this->newLine();
$this->comment('Run with --fix-canceled-subs to apply these changes');
}
} else {
$this->info(" - Fixed {$fixedCount} canceled/missing subscriptions");
}
if (! empty($errors)) {
$this->newLine();
$this->error('Errors encountered:');
foreach ($errors as $error) {
$this->error(" - {$error}");
}
}
return 0;
}
/**
* Verify all active subscriptions against Stripe API
*/
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
{
$isDryRun = $this->option('dry-run');
$shouldFix = $this->option('fix-verified');
$this->info('Verifying all active subscriptions against Stripe...');
if ($isDryRun) {
$this->info('DRY RUN MODE - No changes will be made');
}
if ($shouldFix && ! $isDryRun) {
$this->warn('FIX MODE - Discrepancies will be corrected');
}
// Get all teams with active subscriptions
$teamsWithActiveSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
$totalCount = $teamsWithActiveSubscriptions->count();
$this->info("Found {$totalCount} teams with active subscriptions in database");
$this->newLine();
$out = fopen('php://output', 'w');
// CSV header
fputcsv($out, [
'team_id',
'team_name',
'customer_id',
'subscription_id',
'db_status',
'stripe_status',
'action',
'member_emails',
'customer_url',
'subscription_url',
]);
$stats = [
'total' => $totalCount,
'valid_active' => 0,
'valid_past_due' => 0,
'canceled' => 0,
'missing' => 0,
'invalid' => 0,
'fixed' => 0,
'errors' => 0,
];
$processedCount = 0;
foreach ($teamsWithActiveSubscriptions as $team) {
$subscription = $team->subscription;
$memberEmails = $team->members->pluck('email')->toArray();
// Database state
$dbStatus = 'active';
if ($subscription->stripe_past_due) {
$dbStatus = 'past_due';
}
$stripeStatus = null;
$action = 'none';
if (! $subscription->stripe_subscription_id) {
$this->line("Team {$team->id}: Missing subscription ID, searching in Stripe...");
$foundResult = null;
$searchMethod = null;
// Search by customer ID
if ($subscription->stripe_customer_id) {
$this->line(" → Searching by customer ID: {$subscription->stripe_customer_id}");
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
if ($foundResult) {
$searchMethod = $foundResult['method'];
}
} else {
$this->line(' → No customer ID available');
}
// Search by emails if not found
if (! $foundResult && count($memberEmails) > 0) {
$foundResult = $this->searchSubscriptionsByEmails($stripe, $memberEmails);
if ($foundResult) {
$searchMethod = $foundResult['method'];
// Update customer ID if different
if (isset($foundResult['customer_id']) && $subscription->stripe_customer_id !== $foundResult['customer_id']) {
if ($isDryRun) {
$this->warn(" ⚠ Would update customer ID from {$subscription->stripe_customer_id} to {$foundResult['customer_id']}");
} elseif ($shouldFix) {
$subscription->update(['stripe_customer_id' => $foundResult['customer_id']]);
$this->info(" ✓ Updated customer ID to {$foundResult['customer_id']}");
}
}
}
}
if ($foundResult && isset($foundResult['subscription'])) {
// Check if it's an active/past_due subscription
if (in_array($foundResult['status'], ['active', 'past_due'])) {
// Found an active subscription, handle update
$result = $this->handleFoundSubscription(
$team,
$subscription,
$foundResult['subscription'],
$searchMethod,
$isDryRun,
$shouldFix,
$stats
);
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$result['id'],
$dbStatus,
$result['status'],
$result['action'],
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
$result['url'],
]);
} else {
// Found subscription but it's canceled/expired - needs to be deactivated
$this->warn(" → Found {$foundResult['status']} subscription {$foundResult['subscription']->id} - needs deactivation");
$result = $this->handleMissingSubscription($team, $subscription, $foundResult['status'], $isDryRun, $shouldFix, $stats);
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$foundResult['subscription']->id,
$dbStatus,
$foundResult['status'],
'needs_fix',
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
"https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}",
]);
}
} else {
// No subscription found at all
$this->line(' → No subscription found');
$stripeStatus = 'not_found';
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
'N/A',
$dbStatus,
$result['status'],
$result['action'],
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
'N/A',
]);
}
} else {
// First validate the subscription ID format
if (! str_starts_with($subscription->stripe_subscription_id, 'sub_')) {
$this->warn(" ⚠ Invalid subscription ID format (doesn't start with 'sub_')");
}
try {
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
$stripeStatus = $stripeSubscription->status;
// Determine if action is needed
switch ($stripeStatus) {
case 'active':
$stats['valid_active']++;
$action = 'valid';
break;
case 'past_due':
$stats['valid_past_due']++;
$action = 'valid';
// Ensure past_due flag is set
if (! $subscription->stripe_past_due) {
if ($isDryRun) {
$this->info("Would set stripe_past_due=true for Team {$team->id}");
} elseif ($shouldFix) {
$subscription->update(['stripe_past_due' => true]);
}
}
break;
case 'canceled':
case 'incomplete_expired':
case 'unpaid':
case 'incomplete':
$stats['canceled']++;
$action = 'needs_fix';
// Only output problematic subscriptions
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
$stripeStatus,
$action,
implode(', ', $memberEmails),
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
"https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}",
]);
if ($isDryRun) {
$this->info("Would deactivate subscription for Team {$team->id} - status: {$stripeStatus}");
} elseif ($shouldFix) {
$this->fixSubscription($team, $subscription, $stripeStatus);
$stats['fixed']++;
}
break;
default:
$stats['invalid']++;
$action = 'unknown';
// Only output problematic subscriptions
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
$stripeStatus,
$action,
implode(', ', $memberEmails),
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
"https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}",
]);
break;
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
$this->error(' → Error: '.$e->getMessage());
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
// Subscription doesn't exist, try to find by customer ID
$this->warn(" → Subscription not found, checking customer's subscriptions...");
$foundResult = null;
if ($subscription->stripe_customer_id) {
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
}
if ($foundResult && isset($foundResult['subscription']) && in_array($foundResult['status'], ['active', 'past_due'])) {
// Found an active subscription with different ID
$this->warn(" → ID mismatch! DB: {$subscription->stripe_subscription_id}, Stripe: {$foundResult['subscription']->id}");
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
"WRONG ID: {$subscription->stripe_subscription_id}{$foundResult['subscription']->id}",
$dbStatus,
$foundResult['status'],
'id_mismatch',
implode(', ', $memberEmails),
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
"https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}",
]);
if ($isDryRun) {
$this->warn(" → Would update subscription ID to {$foundResult['subscription']->id}");
} elseif ($shouldFix) {
$subscription->update([
'stripe_subscription_id' => $foundResult['subscription']->id,
'stripe_invoice_paid' => true,
'stripe_past_due' => $foundResult['status'] === 'past_due',
]);
$stats['fixed']++;
$this->info(' → Updated subscription ID');
}
$stats[$foundResult['status'] === 'active' ? 'valid_active' : 'valid_past_due']++;
} else {
// No active subscription found
$stripeStatus = $foundResult ? $foundResult['status'] : 'not_found';
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
$result['status'],
$result['action'],
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
$foundResult && isset($foundResult['subscription']) ? "https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}" : 'N/A',
]);
}
} else {
// Other API error
$stats['errors']++;
$this->error(' → API Error - not marking as deleted');
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
'error: '.$e->getStripeCode(),
'error',
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
$subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A',
]);
}
} catch (\Exception $e) {
$this->error(' → Unexpected error: '.$e->getMessage());
$stats['errors']++;
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
'error',
'error',
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
$subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A',
]);
}
}
$processedCount++;
if ($processedCount % 100 === 0) {
$this->info("Processed {$processedCount}/{$totalCount} subscriptions...");
}
}
fclose($out);
// Print summary
$this->newLine(2);
$this->info('=== Verification Summary ===');
$this->info("Total subscriptions checked: {$stats['total']}");
$this->newLine();
$this->info('Valid subscriptions in Stripe:');
$this->line(" - Active: {$stats['valid_active']}");
$this->line(" - Past Due: {$stats['valid_past_due']}");
$validTotal = $stats['valid_active'] + $stats['valid_past_due'];
$this->info(" Total valid: {$validTotal}");
$this->newLine();
$this->warn('Invalid subscriptions:');
$this->line(" - Canceled/Expired: {$stats['canceled']}");
$this->line(" - Missing/Not Found: {$stats['missing']}");
$this->line(" - Unknown status: {$stats['invalid']}");
$invalidTotal = $stats['canceled'] + $stats['missing'] + $stats['invalid'];
$this->warn(" Total invalid: {$invalidTotal}");
if ($stats['errors'] > 0) {
$this->newLine();
$this->error("Errors encountered: {$stats['errors']}");
}
if ($shouldFix && ! $isDryRun) {
$this->newLine();
$this->info("Fixed subscriptions: {$stats['fixed']}");
} elseif ($invalidTotal > 0 && ! $shouldFix) {
$this->newLine();
$this->comment('Run with --fix-verified to fix the discrepancies');
}
return 0;
}
/**
* Fix a subscription based on its status
*/
private function fixSubscription($team, $subscription, $status)
{
$message = "Fixing subscription for Team ID: {$team->id} (Status: {$status})\n";
$message .= "Team Name: {$team->name}\n";
$message .= "Customer ID: {$subscription->stripe_customer_id}\n";
$message .= "Subscription ID: {$subscription->stripe_subscription_id}\n";
send_internal_notification($message);
// Call the team's subscription ended method which properly cleans up
$team->subscriptionEnded();
}
/**
* Search for subscriptions by customer ID
*/
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false)
{
try {
$subscriptions = $stripe->subscriptions->all([
'customer' => $customerId,
'limit' => 10,
'status' => 'all',
]);
$this->line(' → Found '.count($subscriptions->data).' subscription(s) for customer');
// Look for active/past_due first
foreach ($subscriptions->data as $sub) {
$this->line(" - Subscription {$sub->id}: status={$sub->status}");
if (in_array($sub->status, ['active', 'past_due'])) {
$this->info(" ✓ Found active/past_due subscription: {$sub->id}");
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id'];
}
}
// If not requiring active and there are subscriptions, return first one
if (! $requireActive && count($subscriptions->data) > 0) {
$sub = $subscriptions->data[0];
$this->warn(" ⚠ Only found {$sub->status} subscription: {$sub->id}");
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id_first'];
}
return null;
} catch (\Exception $e) {
$this->error(' → Error searching by customer ID: '.$e->getMessage());
return null;
}
}
/**
* Search for subscriptions by team member emails
*/
private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails)
{
$this->line(' → Searching by team member emails...');
foreach ($emails as $email) {
$this->line(" → Checking email: {$email}");
try {
$customers = $stripe->customers->all([
'email' => $email,
'limit' => 5,
]);
if (count($customers->data) === 0) {
$this->line(' - No customers found');
continue;
}
$this->line(' - Found '.count($customers->data).' customer(s)');
foreach ($customers->data as $customer) {
$this->line(" - Checking customer {$customer->id}");
$result = $this->searchSubscriptionsByCustomer($stripe, $customer->id, true);
if ($result) {
$result['method'] = "email:{$email}";
$result['customer_id'] = $customer->id;
return $result;
}
}
} catch (\Exception $e) {
$this->error(" - Error searching for email {$email}: ".$e->getMessage());
}
}
return null;
}
/**
* Handle found subscription update (only for active/past_due subscriptions)
*/
private function handleFoundSubscription($team, $subscription, $foundSub, $searchMethod, $isDryRun, $shouldFix, &$stats)
{
$stripeStatus = $foundSub->status;
$this->info(" ✓ FOUND active/past_due subscription {$foundSub->id} (status: {$stripeStatus})");
// Only update if it's active or past_due
if (! in_array($stripeStatus, ['active', 'past_due'])) {
$this->error(" ERROR: handleFoundSubscription called with {$stripeStatus} subscription!");
return [
'id' => $foundSub->id,
'status' => $stripeStatus,
'action' => 'error',
'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}",
];
}
if ($isDryRun) {
$this->warn(" → Would update subscription ID to {$foundSub->id} (status: {$stripeStatus})");
} elseif ($shouldFix) {
$subscription->update([
'stripe_subscription_id' => $foundSub->id,
'stripe_invoice_paid' => true,
'stripe_past_due' => $stripeStatus === 'past_due',
]);
$stats['fixed']++;
$this->info(" → Updated subscription ID to {$foundSub->id}");
}
// Update stats
$stats[$stripeStatus === 'active' ? 'valid_active' : 'valid_past_due']++;
return [
'id' => "FOUND: {$foundSub->id}",
'status' => $stripeStatus,
'action' => "will_update (via {$searchMethod})",
'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}",
];
}
/**
* Handle missing subscription
*/
private function handleMissingSubscription($team, $subscription, $status, $isDryRun, $shouldFix, &$stats)
{
$stats['missing']++;
if ($isDryRun) {
$statusMsg = $status !== 'not_found' ? "status: {$status}" : 'no subscription found in Stripe';
$this->warn(" → Would deactivate subscription - {$statusMsg}");
} elseif ($shouldFix) {
$this->fixSubscription($team, $subscription, $status);
$stats['fixed']++;
$this->info(' → Deactivated subscription');
}
return [
'id' => 'N/A',
'status' => $status,
'action' => 'needs_fix',
'url' => 'N/A',
];
}
}

View file

@ -1,49 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Team;
use Illuminate\Console\Command;
class CloudCheckSubscription extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cloud:check-subscription';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Check Cloud subscriptions';
/**
* Execute the console command.
*/
public function handle()
{
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
foreach ($activeSubscribers as $team) {
$stripeSubscriptionId = $team->subscription->stripe_subscription_id;
$stripeInvoicePaid = $team->subscription->stripe_invoice_paid;
$stripeCustomerId = $team->subscription->stripe_customer_id;
if (! $stripeSubscriptionId) {
echo "Team {$team->id} has no subscription, but invoice status is: {$stripeInvoicePaid}\n";
echo "Link on Stripe: https://dashboard.stripe.com/customers/{$stripeCustomerId}\n";
continue;
}
$subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId);
if ($subscription->status === 'active') {
continue;
}
echo "Subscription {$stripeSubscriptionId} is not active ({$subscription->status})\n";
echo "Link on Stripe: https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}\n";
}
}
}

View file

@ -1,101 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Events\ServerReachabilityChanged;
use App\Models\Team;
use Illuminate\Console\Command;
class CloudCleanupSubscriptions extends Command
{
protected $signature = 'cloud:cleanup-subs';
protected $description = 'Cleanup subcriptions teams';
public function handle()
{
try {
if (! isCloud()) {
$this->error('This command can only be run on cloud');
return;
}
$this->info('Cleaning up subcriptions teams');
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$teams = Team::all()->filter(function ($team) {
return $team->id !== 0;
})->sortBy('id');
foreach ($teams as $team) {
if ($team) {
$this->info("Checking team {$team->id}");
}
if (! data_get($team, 'subscription')) {
$this->disableServers($team);
continue;
}
// If the team has no subscription id and the invoice is paid, we need to reset the invoice paid status
if (! (data_get($team, 'subscription.stripe_subscription_id'))) {
$this->info("Resetting invoice paid status for team {$team->id}");
$team->subscription->update([
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_subscription_id' => null,
]);
$this->disableServers($team);
continue;
} else {
$subscription = $stripe->subscriptions->retrieve(data_get($team, 'subscription.stripe_subscription_id'), []);
$status = data_get($subscription, 'status');
if ($status === 'active') {
$team->subscription->update([
'stripe_invoice_paid' => true,
'stripe_trial_already_ended' => false,
]);
continue;
}
$this->info('Subscription status: '.$status);
$this->info('Subscription id: '.data_get($team, 'subscription.stripe_subscription_id'));
$confirm = $this->confirm('Do you want to cancel the subscription?', true);
if (! $confirm) {
$this->info("Skipping team {$team->id}");
} else {
$this->info("Cancelling subscription for team {$team->id}");
$team->subscription->update([
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_subscription_id' => null,
]);
$this->disableServers($team);
}
}
}
} catch (\Exception $e) {
$this->error($e->getMessage());
return;
}
}
private function disableServers(Team $team)
{
foreach ($team->servers as $server) {
if ($server->settings->is_usable === true || $server->settings->is_reachable === true || $server->ip !== '1.2.3.4') {
$this->info("Disabling server {$server->id} {$server->name}");
$server->settings()->update([
'is_usable' => false,
'is_reachable' => false,
]);
$server->update([
'ip' => '1.2.3.4',
]);
ServerReachabilityChanged::dispatch($server);
}
}
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ApplicationConfigurationChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public ?int $teamId = null;
public function __construct($teamId = null)
{
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
$teamId = auth()->user()->currentTeam()->id;
}
$this->teamId = $teamId;
}
public function broadcastOn(): array
{
if (is_null($this->teamId)) {
return [];
}
return [
new PrivateChannel("team.{$this->teamId}"),
];
}
}

View file

@ -2532,8 +2532,11 @@ public function update_env_by_uuid(Request $request)
if ($env->is_shown_once != $request->is_shown_once) { if ($env->is_shown_once != $request->is_shown_once) {
$env->is_shown_once = $request->is_shown_once; $env->is_shown_once = $request->is_shown_once;
} }
if ($request->has('is_buildtime_only') && $env->is_buildtime_only != $request->is_buildtime_only) { if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) {
$env->is_buildtime_only = $request->is_buildtime_only; $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(); $env->save();
@ -2559,8 +2562,11 @@ public function update_env_by_uuid(Request $request)
if ($env->is_shown_once != $request->is_shown_once) { if ($env->is_shown_once != $request->is_shown_once) {
$env->is_shown_once = $request->is_shown_once; $env->is_shown_once = $request->is_shown_once;
} }
if ($request->has('is_buildtime_only') && $env->is_buildtime_only != $request->is_buildtime_only) { if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) {
$env->is_buildtime_only = $request->is_buildtime_only; $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(); $env->save();
@ -2723,8 +2729,11 @@ public function create_bulk_envs(Request $request)
if ($env->is_shown_once != $item->get('is_shown_once')) { if ($env->is_shown_once != $item->get('is_shown_once')) {
$env->is_shown_once = $item->get('is_shown_once'); $env->is_shown_once = $item->get('is_shown_once');
} }
if ($item->has('is_buildtime_only') && $env->is_buildtime_only != $item->get('is_buildtime_only')) { if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) {
$env->is_buildtime_only = $item->get('is_buildtime_only'); $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(); $env->save();
} else { } else {
@ -2735,7 +2744,8 @@ public function create_bulk_envs(Request $request)
'is_literal' => $is_literal, 'is_literal' => $is_literal,
'is_multiline' => $is_multi_line, 'is_multiline' => $is_multi_line,
'is_shown_once' => $is_shown_once, 'is_shown_once' => $is_shown_once,
'is_buildtime_only' => $item->get('is_buildtime_only', false), 'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'resourceable_type' => get_class($application), 'resourceable_type' => get_class($application),
'resourceable_id' => $application->id, 'resourceable_id' => $application->id,
]); ]);
@ -2753,8 +2763,11 @@ public function create_bulk_envs(Request $request)
if ($env->is_shown_once != $item->get('is_shown_once')) { if ($env->is_shown_once != $item->get('is_shown_once')) {
$env->is_shown_once = $item->get('is_shown_once'); $env->is_shown_once = $item->get('is_shown_once');
} }
if ($item->has('is_buildtime_only') && $env->is_buildtime_only != $item->get('is_buildtime_only')) { if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) {
$env->is_buildtime_only = $item->get('is_buildtime_only'); $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(); $env->save();
} else { } else {
@ -2765,7 +2778,8 @@ public function create_bulk_envs(Request $request)
'is_literal' => $is_literal, 'is_literal' => $is_literal,
'is_multiline' => $is_multi_line, 'is_multiline' => $is_multi_line,
'is_shown_once' => $is_shown_once, 'is_shown_once' => $is_shown_once,
'is_buildtime_only' => $item->get('is_buildtime_only', false), 'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'resourceable_type' => get_class($application), 'resourceable_type' => get_class($application),
'resourceable_id' => $application->id, 'resourceable_id' => $application->id,
]); ]);
@ -2904,7 +2918,8 @@ public function create_env(Request $request)
'is_literal' => $request->is_literal ?? false, 'is_literal' => $request->is_literal ?? false,
'is_multiline' => $request->is_multiline ?? false, 'is_multiline' => $request->is_multiline ?? false,
'is_shown_once' => $request->is_shown_once ?? false, 'is_shown_once' => $request->is_shown_once ?? false,
'is_buildtime_only' => $request->is_buildtime_only ?? false, 'is_runtime' => $request->is_runtime ?? true,
'is_buildtime' => $request->is_buildtime ?? true,
'resourceable_type' => get_class($application), 'resourceable_type' => get_class($application),
'resourceable_id' => $application->id, 'resourceable_id' => $application->id,
]); ]);
@ -2927,7 +2942,8 @@ public function create_env(Request $request)
'is_literal' => $request->is_literal ?? false, 'is_literal' => $request->is_literal ?? false,
'is_multiline' => $request->is_multiline ?? false, 'is_multiline' => $request->is_multiline ?? false,
'is_shown_once' => $request->is_shown_once ?? false, 'is_shown_once' => $request->is_shown_once ?? false,
'is_buildtime_only' => $request->is_buildtime_only ?? false, 'is_runtime' => $request->is_runtime ?? true,
'is_buildtime' => $request->is_buildtime ?? true,
'resourceable_type' => get_class($application), 'resourceable_type' => get_class($application),
'resourceable_id' => $application->id, 'resourceable_id' => $application->id,
]); ]);
@ -3364,11 +3380,12 @@ private function validateDataApplications(Request $request, Server $server)
$fqdn = str($fqdn)->replaceStart(',', '')->trim(); $fqdn = str($fqdn)->replaceStart(',', '')->trim();
$errors = []; $errors = [];
$fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) { $fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) {
$domain = trim($domain);
if (filter_var($domain, FILTER_VALIDATE_URL) === false) { if (filter_var($domain, FILTER_VALIDATE_URL) === false) {
$errors[] = 'Invalid domain: '.$domain; $errors[] = 'Invalid domain: '.$domain;
} }
return str($domain)->trim()->lower(); return str($domain)->lower();
}); });
if (count($errors) > 0) { if (count($errors) > 0) {
return response()->json([ return response()->json([

View file

@ -9,11 +9,15 @@
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Enums\NewDatabaseTypes; use App\Enums\NewDatabaseTypes;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\DeleteResourceJob; use App\Jobs\DeleteResourceJob;
use App\Models\Project; use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server; use App\Models\Server;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
class DatabasesController extends Controller class DatabasesController extends Controller
@ -79,13 +83,88 @@ public function databases(Request $request)
foreach ($projects as $project) { foreach ($projects as $project) {
$databases = $databases->merge($project->databases()); $databases = $databases->merge($project->databases());
} }
$databases = $databases->map(function ($database) {
$databaseIds = $databases->pluck('id')->toArray();
$backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('latest_log')
->whereIn('database_id', $databaseIds)
->get()
->groupBy('database_id');
$databases = $databases->map(function ($database) use ($backupConfigs) {
$database->backup_configs = $backupConfigs->get($database->id, collect())->values();
return $this->removeSensitiveData($database); return $this->removeSensitiveData($database);
}); });
return response()->json($databases); return response()->json($databases);
} }
#[OA\Get(
summary: 'Get',
description: 'Get backups details by database UUID.',
path: '/databases/{uuid}/backups',
operationId: 'get-database-backups-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all backups for a database',
content: new OA\JsonContent(
type: 'string',
example: 'Content is very complex. Will be implemented later.',
),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function database_backup_details_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->uuid) {
return response()->json(['message' => 'UUID is required.'], 404);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('view', $database);
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('executions')->where('database_id', $database->id)->get();
return response()->json($backupConfig);
}
#[OA\Get( #[OA\Get(
summary: 'Get', summary: 'Get',
description: 'Get database by UUID.', description: 'Get database by UUID.',
@ -248,6 +327,7 @@ public function update_by_uuid(Request $request)
return invalidTokenResponse(); return invalidTokenResponse();
} }
// this check if the request is a valid json
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return; return $return;
@ -499,7 +579,8 @@ public function update_by_uuid(Request $request)
$whatToDoWithDatabaseProxy = 'start'; $whatToDoWithDatabaseProxy = 'start';
} }
$database->update($request->all()); // Only update database fields, not backup configuration
$database->update($request->only($allowedFields));
if ($whatToDoWithDatabaseProxy === 'start') { if ($whatToDoWithDatabaseProxy === 'start') {
StartDatabaseProxy::dispatch($database); StartDatabaseProxy::dispatch($database);
@ -512,6 +593,197 @@ public function update_by_uuid(Request $request)
]); ]);
} }
#[OA\Patch(
summary: 'Update',
description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}',
operationId: 'update-database-backup',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
name: 'scheduled_backup_uuid',
in: 'path',
description: 'UUID of the backup configuration.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
requestBody: new OA\RequestBody(
description: 'Database backup configuration data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'],
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'],
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'],
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'],
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'],
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
],
),
)
),
responses: [
new OA\Response(
response: 200,
description: 'Database backup configuration updated',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function update_backup(Request $request)
{
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// this check if the request is a valid json
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'save_s3' => 'boolean',
'backup_now' => 'boolean|nullable',
'enabled' => 'boolean',
'dump_all' => 'boolean',
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
'databases_to_backup' => 'string|nullable',
'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'integer|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'integer|min:0',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
if (! $request->uuid) {
return response()->json(['message' => 'UUID is required.'], 404);
}
// Validate scheduled_backup_uuid is provided
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
$uuid = $request->uuid;
removeUnnecessaryFieldsFromRequest($request);
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']],
], 422);
}
if ($request->filled('s3_storage_uuid')) {
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
], 422);
}
}
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
->where('uuid', $request->scheduled_backup_uuid)
->first();
if (! $backupConfig) {
return response()->json(['message' => 'Backup config not found.'], 404);
}
$extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']);
if (! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$backupData = $request->only($backupConfigFields);
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
], 422);
}
unset($backupData['s3_storage_uuid']);
}
$backupConfig->update($backupData);
if ($request->backup_now) {
dispatch(new DatabaseBackupJob($backupConfig));
}
return response()->json([
'message' => 'Database backup configuration updated',
]);
}
#[OA\Post( #[OA\Post(
summary: 'Create (PostgreSQL)', summary: 'Create (PostgreSQL)',
description: 'Create a new PostgreSQL database.', description: 'Create a new PostgreSQL database.',
@ -1630,6 +1902,344 @@ public function delete_by_uuid(Request $request)
]); ]);
} }
#[OA\Delete(
summary: 'Delete backup configuration',
description: 'Deletes a backup configuration and all its executions.',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}',
operationId: 'delete-backup-configuration-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
required: true,
description: 'UUID of the database',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'scheduled_backup_uuid',
in: 'path',
required: true,
description: 'UUID of the backup configuration to delete',
schema: new OA\Schema(type: 'string', format: 'uuid')
),
new OA\Parameter(
name: 'delete_s3',
in: 'query',
required: false,
description: 'Whether to delete all backup files from S3',
schema: new OA\Schema(type: 'boolean', default: false)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Backup configuration deleted.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup configuration and all executions deleted.'),
]
)
),
new OA\Response(
response: 404,
description: 'Backup configuration not found.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup configuration not found.'),
]
)
),
]
)]
public function delete_backup_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// Validate scheduled_backup_uuid is provided
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
// Find the backup configuration by its UUID
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
->where('uuid', $request->scheduled_backup_uuid)
->first();
if (! $backup) {
return response()->json(['message' => 'Backup configuration not found.'], 404);
}
$deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN);
try {
DB::beginTransaction();
// Get all executions for this backup configuration
$executions = $backup->executions()->get();
// Delete all execution files (locally and optionally from S3)
foreach ($executions as $execution) {
if ($execution->filename) {
deleteBackupsLocally($execution->filename, $database->destination->server);
if ($deleteS3 && $backup->s3) {
deleteBackupsS3($execution->filename, $backup->s3);
}
}
$execution->delete();
}
// Delete the backup configuration itself
$backup->delete();
DB::commit();
return response()->json([
'message' => 'Backup configuration and all executions deleted.',
]);
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500);
}
}
#[OA\Delete(
summary: 'Delete backup execution',
description: 'Deletes a specific backup execution.',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}',
operationId: 'delete-backup-execution-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
required: true,
description: 'UUID of the database',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'scheduled_backup_uuid',
in: 'path',
required: true,
description: 'UUID of the backup configuration',
schema: new OA\Schema(type: 'string', format: 'uuid')
),
new OA\Parameter(
name: 'execution_uuid',
in: 'path',
required: true,
description: 'UUID of the backup execution to delete',
schema: new OA\Schema(type: 'string', format: 'uuid')
),
new OA\Parameter(
name: 'delete_s3',
in: 'query',
required: false,
description: 'Whether to delete the backup from S3',
schema: new OA\Schema(type: 'boolean', default: false)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Backup execution deleted.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup execution deleted.'),
]
)
),
new OA\Response(
response: 404,
description: 'Backup execution not found.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup execution not found.'),
]
)
),
]
)]
public function delete_execution_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// Validate parameters
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
if (! $request->execution_uuid) {
return response()->json(['message' => 'Execution UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
// Find the backup configuration by its UUID
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
->where('uuid', $request->scheduled_backup_uuid)
->first();
if (! $backup) {
return response()->json(['message' => 'Backup configuration not found.'], 404);
}
// Find the specific execution
$execution = $backup->executions()->where('uuid', $request->execution_uuid)->first();
if (! $execution) {
return response()->json(['message' => 'Backup execution not found.'], 404);
}
$deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN);
try {
if ($execution->filename) {
deleteBackupsLocally($execution->filename, $database->destination->server);
if ($deleteS3 && $backup->s3) {
deleteBackupsS3($execution->filename, $backup->s3);
}
}
$execution->delete();
return response()->json([
'message' => 'Backup execution deleted.',
]);
} catch (\Exception $e) {
return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500);
}
}
#[OA\Get(
summary: 'List backup executions',
description: 'Get all executions for a specific backup configuration.',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions',
operationId: 'list-backup-executions',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
required: true,
description: 'UUID of the database',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'scheduled_backup_uuid',
in: 'path',
required: true,
description: 'UUID of the backup configuration',
schema: new OA\Schema(type: 'string', format: 'uuid')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of backup executions',
content: new OA\JsonContent(
type: 'object',
properties: [
'executions' => new OA\Schema(
type: 'array',
items: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string'],
'filename' => ['type' => 'string'],
'size' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
'message' => ['type' => 'string'],
'status' => ['type' => 'string'],
]
)
),
]
)
),
new OA\Response(
response: 404,
description: 'Backup configuration not found.',
),
]
)]
public function list_backup_executions(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// Validate scheduled_backup_uuid is provided
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
// Find the backup configuration by its UUID
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
->where('uuid', $request->scheduled_backup_uuid)
->first();
if (! $backup) {
return response()->json(['message' => 'Backup configuration not found.'], 404);
}
// Get all executions for this backup configuration
$executions = $backup->executions()
->orderBy('created_at', 'desc')
->get()
->map(function ($execution) {
return [
'uuid' => $execution->uuid,
'filename' => $execution->filename,
'size' => $execution->size,
'created_at' => $execution->created_at->toIso8601String(),
'message' => $execution->message,
'status' => $execution->status,
];
});
return response()->json([
'executions' => $executions,
]);
}
#[OA\Get( #[OA\Get(
summary: 'Start', summary: 'Start',
description: 'Start database. `Post` request is also accepted.', description: 'Start database. `Post` request is also accepted.',

View file

@ -0,0 +1,661 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use OpenApi\Attributes as OA;
class GithubController extends Controller
{
#[OA\Post(
summary: 'Create GitHub App',
description: 'Create a new GitHub app.',
path: '/github-apps',
operationId: 'create-github-app',
security: [
['bearerAuth' => []],
],
tags: ['GitHub Apps'],
requestBody: new OA\RequestBody(
description: 'GitHub app creation payload.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'Name of the GitHub app.'],
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'Organization to associate the app with.'],
'api_url' => ['type' => 'string', 'description' => 'API URL for the GitHub app (e.g., https://api.github.com).'],
'html_url' => ['type' => 'string', 'description' => 'HTML URL for the GitHub app (e.g., https://github.com).'],
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'],
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'],
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID from GitHub.'],
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID.'],
'client_id' => ['type' => 'string', 'description' => 'GitHub OAuth App Client ID.'],
'client_secret' => ['type' => 'string', 'description' => 'GitHub OAuth App Client Secret.'],
'webhook_secret' => ['type' => 'string', 'description' => 'Webhook secret for GitHub webhooks.'],
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
],
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'GitHub app created successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
'organization' => ['type' => 'string', 'nullable' => true],
'api_url' => ['type' => 'string'],
'html_url' => ['type' => 'string'],
'custom_user' => ['type' => 'string'],
'custom_port' => ['type' => 'integer'],
'app_id' => ['type' => 'integer'],
'installation_id' => ['type' => 'integer'],
'client_id' => ['type' => 'string'],
'private_key_id' => ['type' => 'integer'],
'is_system_wide' => ['type' => 'boolean'],
'team_id' => ['type' => 'integer'],
]
)
),
]
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_github_app(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$allowedFields = [
'name',
'organization',
'api_url',
'html_url',
'custom_user',
'custom_port',
'app_id',
'installation_id',
'client_id',
'client_secret',
'webhook_secret',
'private_key_uuid',
'is_system_wide',
];
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'organization' => 'nullable|string|max:255',
'api_url' => 'required|string|url',
'html_url' => 'required|string|url',
'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535',
'app_id' => 'required|integer',
'installation_id' => 'required|integer',
'client_id' => 'required|string|max:255',
'client_secret' => 'required|string',
'webhook_secret' => 'required|string',
'private_key_uuid' => 'required|string',
'is_system_wide' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
try {
// Verify the private key belongs to the team
$privateKey = PrivateKey::where('uuid', $request->input('private_key_uuid'))
->where('team_id', $teamId)
->first();
if (! $privateKey) {
return response()->json([
'message' => 'Private key not found or does not belong to your team.',
], 404);
}
$payload = [
'uuid' => Str::uuid(),
'name' => $request->input('name'),
'organization' => $request->input('organization'),
'api_url' => $request->input('api_url'),
'html_url' => $request->input('html_url'),
'custom_user' => $request->input('custom_user', 'git'),
'custom_port' => $request->input('custom_port', 22),
'app_id' => $request->input('app_id'),
'installation_id' => $request->input('installation_id'),
'client_id' => $request->input('client_id'),
'client_secret' => $request->input('client_secret'),
'webhook_secret' => $request->input('webhook_secret'),
'private_key_id' => $privateKey->id,
'is_public' => false,
'team_id' => $teamId,
];
if (! isCloud()) {
$payload['is_system_wide'] = $request->input('is_system_wide', false);
}
$githubApp = GithubApp::create($payload);
return response()->json($githubApp, 201);
} catch (\Throwable $e) {
return handleError($e);
}
}
#[OA\Get(
path: '/github-apps/{github_app_id}/repositories',
summary: 'Load Repositories for a GitHub App',
description: 'Fetch repositories from GitHub for a given GitHub app.',
operationId: 'load-repositories',
tags: ['GitHub Apps'],
security: [
['bearerAuth' => []],
],
parameters: [
new OA\Parameter(
name: 'github_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitHub App ID'
),
],
responses: [
new OA\Response(
response: 200,
description: 'Repositories loaded successfully.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'repositories' => new OA\Items(
type: 'array',
items: new OA\Schema(type: 'object')
),
]
)
)
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function load_repositories($github_app_id)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
$token = generateGithubInstallationToken($githubApp);
$repositories = collect();
$page = 1;
$maxPages = 100; // Safety limit: max 10,000 repositories
while ($page <= $maxPages) {
$response = Http::GitHub($githubApp->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get('/installation/repositories', [
'per_page' => 100,
'page' => $page,
]);
if ($response->status() !== 200) {
return response()->json([
'message' => $response->json()['message'] ?? 'Failed to load repositories',
], $response->status());
}
$json = $response->json();
$repos = $json['repositories'] ?? [];
if (empty($repos)) {
break; // No more repositories to load
}
$repositories = $repositories->concat($repos);
$page++;
}
return response()->json([
'repositories' => $repositories->sortBy('name')->values(),
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
}
}
#[OA\Get(
path: '/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches',
summary: 'Load Branches for a GitHub Repository',
description: 'Fetch branches from GitHub for a given repository.',
operationId: 'load-branches',
tags: ['GitHub Apps'],
security: [
['bearerAuth' => []],
],
parameters: [
new OA\Parameter(
name: 'github_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitHub App ID'
),
new OA\Parameter(
name: 'owner',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string'),
description: 'Repository owner'
),
new OA\Parameter(
name: 'repo',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string'),
description: 'Repository name'
),
],
responses: [
new OA\Response(
response: 200,
description: 'Branches loaded successfully.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'branches' => new OA\Items(
type: 'array',
items: new OA\Schema(type: 'object')
),
]
)
)
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function load_branches($github_app_id, $owner, $repo)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
$token = generateGithubInstallationToken($githubApp);
$response = Http::GitHub($githubApp->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/repos/{$owner}/{$repo}/branches");
if ($response->status() !== 200) {
return response()->json([
'message' => 'Error loading branches from GitHub.',
'error' => $response->json('message'),
], $response->status());
}
$branches = $response->json();
return response()->json([
'branches' => $branches,
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
}
}
/**
* Update a GitHub app.
*/
#[OA\Patch(
path: '/github-apps/{github_app_id}',
operationId: 'updateGithubApp',
security: [
['bearerAuth' => []],
],
tags: ['GitHub Apps'],
summary: 'Update GitHub App',
description: 'Update an existing GitHub app.',
parameters: [
new OA\Parameter(
name: 'github_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitHub App ID'
),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'GitHub App name'],
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'GitHub organization'],
'api_url' => ['type' => 'string', 'description' => 'GitHub API URL'],
'html_url' => ['type' => 'string', 'description' => 'GitHub HTML URL'],
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'],
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'],
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID'],
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID'],
'client_id' => ['type' => 'string', 'description' => 'GitHub Client ID'],
'client_secret' => ['type' => 'string', 'description' => 'GitHub Client Secret'],
'webhook_secret' => ['type' => 'string', 'description' => 'GitHub Webhook Secret'],
'private_key_uuid' => ['type' => 'string', 'description' => 'Private key UUID'],
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'],
]
)
)
),
responses: [
new OA\Response(
response: 200,
description: 'GitHub app updated successfully',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'GitHub app updated successfully'],
'data' => ['type' => 'object', 'description' => 'Updated GitHub app data'],
]
)
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'GitHub app not found'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function update_github_app(Request $request, $github_app_id)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
// Define allowed fields for update
$allowedFields = [
'name',
'organization',
'api_url',
'html_url',
'custom_user',
'custom_port',
'app_id',
'installation_id',
'client_id',
'client_secret',
'webhook_secret',
'private_key_uuid',
];
if (! isCloud()) {
$allowedFields[] = 'is_system_wide';
}
$payload = $request->only($allowedFields);
// Validate the request
$rules = [];
if (isset($payload['name'])) {
$rules['name'] = 'string';
}
if (isset($payload['organization'])) {
$rules['organization'] = 'nullable|string';
}
if (isset($payload['api_url'])) {
$rules['api_url'] = 'url';
}
if (isset($payload['html_url'])) {
$rules['html_url'] = 'url';
}
if (isset($payload['custom_user'])) {
$rules['custom_user'] = 'string';
}
if (isset($payload['custom_port'])) {
$rules['custom_port'] = 'integer|min:1|max:65535';
}
if (isset($payload['app_id'])) {
$rules['app_id'] = 'integer';
}
if (isset($payload['installation_id'])) {
$rules['installation_id'] = 'integer';
}
if (isset($payload['client_id'])) {
$rules['client_id'] = 'string';
}
if (isset($payload['client_secret'])) {
$rules['client_secret'] = 'string';
}
if (isset($payload['webhook_secret'])) {
$rules['webhook_secret'] = 'string';
}
if (isset($payload['private_key_uuid'])) {
$rules['private_key_uuid'] = 'string|uuid';
}
if (! isCloud() && isset($payload['is_system_wide'])) {
$rules['is_system_wide'] = 'boolean';
}
$validator = customApiValidator($payload, $rules);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation error',
'errors' => $validator->errors(),
], 422);
}
// Handle private_key_uuid -> private_key_id conversion
if (isset($payload['private_key_uuid'])) {
$privateKey = PrivateKey::where('team_id', $teamId)
->where('uuid', $payload['private_key_uuid'])
->first();
if (! $privateKey) {
return response()->json([
'message' => 'Private key not found or does not belong to your team',
], 404);
}
unset($payload['private_key_uuid']);
$payload['private_key_id'] = $privateKey->id;
}
// Update the GitHub app
$githubApp->update($payload);
return response()->json([
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
}
}
/**
* Delete a GitHub app.
*/
#[OA\Delete(
path: '/github-apps/{github_app_id}',
operationId: 'deleteGithubApp',
security: [
['bearerAuth' => []],
],
tags: ['GitHub Apps'],
summary: 'Delete GitHub App',
description: 'Delete a GitHub app if it\'s not being used by any applications.',
parameters: [
new OA\Parameter(
name: 'github_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitHub App ID'
),
],
responses: [
new OA\Response(
response: 200,
description: 'GitHub app deleted successfully',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'GitHub app deleted successfully'],
]
)
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'GitHub app not found'),
new OA\Response(
response: 409,
description: 'Conflict - GitHub app is in use',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'This GitHub app is being used by 5 application(s). Please delete all applications first.'],
]
)
)
),
]
)]
public function delete_github_app($github_app_id)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
// Check if the GitHub app is being used by any applications
if ($githubApp->applications->isNotEmpty()) {
$count = $githubApp->applications->count();
return response()->json([
'message' => "This GitHub app is being used by {$count} application(s). Please delete all applications first.",
], 409);
}
$githubApp->delete();
return response()->json([
'message' => 'GitHub app deleted successfully',
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
}
}
}

View file

@ -179,6 +179,8 @@ public function members_by_id(Request $request)
$members = $team->members; $members = $team->members;
$members->makeHidden([ $members->makeHidden([
'pivot', 'pivot',
'email_change_code',
'email_change_code_expires_at',
]); ]);
return response()->json( return response()->json(
@ -264,6 +266,8 @@ public function current_team_members(Request $request)
$team = auth()->user()->currentTeam(); $team = auth()->user()->currentTeam();
$team->members->makeHidden([ $team->members->makeHidden([
'pivot', 'pivot',
'email_change_code',
'email_change_code_expires_at',
]); ]);
return response()->json( return response()->json(

File diff suppressed because it is too large Load diff

View file

@ -74,8 +74,6 @@ public function __construct(public ScheduledDatabaseBackup $backup)
{ {
$this->onQueue('high'); $this->onQueue('high');
$this->timeout = $backup->timeout; $this->timeout = $backup->timeout;
$this->backup_log_uuid = (string) new Cuid2;
} }
public function handle(): void public function handle(): void
@ -288,6 +286,17 @@ public function handle(): void
$this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip"; $this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip";
} }
foreach ($databasesToBackup as $database) { foreach ($databasesToBackup as $database) {
// Generate unique UUID for each database backup execution
$attempts = 0;
do {
$this->backup_log_uuid = (string) new Cuid2;
$exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists();
$attempts++;
if ($attempts >= 3 && $exists) {
throw new \Exception('Unable to generate unique UUID for backup execution after 3 attempts');
}
} while ($exists);
$size = 0; $size = 0;
try { try {
if (str($databaseType)->contains('postgres')) { if (str($databaseType)->contains('postgres')) {

View file

@ -78,11 +78,11 @@ public function handle()
} }
// Server is reachable, check if Docker is available // Server is reachable, check if Docker is available
// $isUsable = $this->checkDockerAvailability(); $isUsable = $this->checkDockerAvailability();
$this->server->settings->update([ $this->server->settings->update([
'is_reachable' => true, 'is_reachable' => true,
'is_usable' => true, 'is_usable' => $isUsable,
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {

View file

@ -93,20 +93,66 @@ public function handle(): void
break; break;
case 'invoice.paid': case 'invoice.paid':
$customerId = data_get($data, 'customer'); $customerId = data_get($data, 'customer');
$invoiceAmount = data_get($data, 'amount_paid', 0);
$subscriptionId = data_get($data, 'subscription');
$planId = data_get($data, 'lines.data.0.plan.id'); $planId = data_get($data, 'lines.data.0.plan.id');
if (Str::contains($excludedPlans, $planId)) { if (Str::contains($excludedPlans, $planId)) {
// send_internal_notification('Subscription excluded.'); // send_internal_notification('Subscription excluded.');
break; break;
} }
$subscription = Subscription::where('stripe_customer_id', $customerId)->first(); $subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if ($subscription) { if (! $subscription) {
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
} else {
throw new \RuntimeException("No subscription found for customer: {$customerId}"); throw new \RuntimeException("No subscription found for customer: {$customerId}");
} }
if ($subscription->stripe_subscription_id) {
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
switch ($stripeSubscription->status) {
case 'active':
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
break;
case 'past_due':
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => true,
]);
break;
case 'canceled':
case 'incomplete_expired':
case 'unpaid':
send_internal_notification(
"Invoice paid for {$stripeSubscription->status} subscription. ".
"Customer: {$customerId}, Amount: \${$invoiceAmount}"
);
break;
default:
VerifyStripeSubscriptionStatusJob::dispatch($subscription)
->delay(now()->addSeconds(20));
break;
}
} catch (\Exception $e) {
VerifyStripeSubscriptionStatusJob::dispatch($subscription)
->delay(now()->addSeconds(20));
send_internal_notification(
'Failed to verify subscription status in invoice.paid: '.$e->getMessage()
);
}
} else {
VerifyStripeSubscriptionStatusJob::dispatch($subscription)
->delay(now()->addSeconds(20));
}
break; break;
case 'invoice.payment_failed': case 'invoice.payment_failed':
$customerId = data_get($data, 'customer'); $customerId = data_get($data, 'customer');

View file

@ -0,0 +1,106 @@
<?php
namespace App\Jobs;
use App\Models\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class VerifyStripeSubscriptionStatusJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [10, 30, 60];
public function __construct(public Subscription $subscription)
{
$this->onQueue('high');
}
public function handle(): void
{
// If no subscription ID yet, try to find it via customer
if (! $this->subscription->stripe_subscription_id &&
$this->subscription->stripe_customer_id) {
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$subscriptions = $stripe->subscriptions->all([
'customer' => $this->subscription->stripe_customer_id,
'limit' => 1,
]);
if ($subscriptions->data) {
$this->subscription->update([
'stripe_subscription_id' => $subscriptions->data[0]->id,
]);
}
} catch (\Exception $e) {
// Continue without subscription ID
}
}
if (! $this->subscription->stripe_subscription_id) {
return;
}
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripeSubscription = $stripe->subscriptions->retrieve(
$this->subscription->stripe_subscription_id
);
switch ($stripeSubscription->status) {
case 'active':
$this->subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
'stripe_cancel_at_period_end' => $stripeSubscription->cancel_at_period_end,
]);
break;
case 'past_due':
// Keep subscription active but mark as past_due
$this->subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => true,
'stripe_cancel_at_period_end' => $stripeSubscription->cancel_at_period_end,
]);
break;
case 'canceled':
case 'incomplete_expired':
case 'unpaid':
// Ensure subscription is marked as inactive
$this->subscription->update([
'stripe_invoice_paid' => false,
'stripe_past_due' => false,
]);
// Trigger subscription ended logic if canceled
if ($stripeSubscription->status === 'canceled') {
$team = $this->subscription->team;
if ($team) {
$team->subscriptionEnded();
}
}
break;
default:
send_internal_notification(
'Unknown subscription status in VerifyStripeSubscriptionStatusJob: '.$stripeSubscription->status.
' for customer: '.$this->subscription->stripe_customer_id
);
break;
}
} catch (\Exception $e) {
send_internal_notification(
'VerifyStripeSubscriptionStatusJob failed for subscription ID '.$this->subscription->id.': '.$e->getMessage()
);
}
}
}

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

@ -52,15 +52,24 @@ public function force_start()
public function cancel() 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; $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; $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 { try {
if ($this->application->settings->is_build_server_enabled) { if ($this->application->settings->is_build_server_enabled) {
$server = Server::ownedByCurrentTeam()->find($build_server_id); $server = Server::ownedByCurrentTeam()->find($build_server_id);
} else { } else {
$server = Server::ownedByCurrentTeam()->find($server_id); $server = Server::ownedByCurrentTeam()->find($server_id);
} }
// Add cancellation log entry
if ($this->application_deployment_queue->logs) { if ($this->application_deployment_queue->logs) {
$previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); $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), '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) { } catch (\Throwable $e) {
// Still mark as cancelled even if cleanup fails
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
$this->application_deployment_queue->update([ $this->application_deployment_queue->update([
'current_process_id' => null, 'current_process_id' => null,
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]); ]);
next_after_cancel($server); next_after_cancel($server);
} }

View file

@ -210,10 +210,10 @@ public function mount()
} }
} }
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots to use underscores for HTML form binding // Convert service names with dots and dashes to use underscores for HTML form binding
$sanitizedDomains = []; $sanitizedDomains = [];
foreach ($this->parsedServiceDomains as $serviceName => $domain) { foreach ($this->parsedServiceDomains as $serviceName => $domain) {
$sanitizedKey = str($serviceName)->slug('_')->toString(); $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$sanitizedDomains[$sanitizedKey] = $domain; $sanitizedDomains[$sanitizedKey] = $domain;
} }
$this->parsedServiceDomains = $sanitizedDomains; $this->parsedServiceDomains = $sanitizedDomains;
@ -305,10 +305,10 @@ public function loadComposeFile($isInit = false, $showToast = true)
// Refresh parsedServiceDomains to reflect any changes in docker_compose_domains // Refresh parsedServiceDomains to reflect any changes in docker_compose_domains
$this->application->refresh(); $this->application->refresh();
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots to use underscores for HTML form binding // Convert service names with dots and dashes to use underscores for HTML form binding
$sanitizedDomains = []; $sanitizedDomains = [];
foreach ($this->parsedServiceDomains as $serviceName => $domain) { foreach ($this->parsedServiceDomains as $serviceName => $domain) {
$sanitizedKey = str($serviceName)->slug('_')->toString(); $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$sanitizedDomains[$sanitizedKey] = $domain; $sanitizedDomains[$sanitizedKey] = $domain;
} }
$this->parsedServiceDomains = $sanitizedDomains; $this->parsedServiceDomains = $sanitizedDomains;
@ -334,7 +334,7 @@ public function generateDomain(string $serviceName)
$uuid = new Cuid2; $uuid = new Cuid2;
$domain = generateUrl(server: $this->application->destination->server, random: $uuid); $domain = generateUrl(server: $this->application->destination->server, random: $uuid);
$sanitizedKey = str($serviceName)->slug('_')->toString(); $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain; $this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
// Convert back to original service names for storage // Convert back to original service names for storage
@ -344,7 +344,7 @@ public function generateDomain(string $serviceName)
$originalServiceName = $key; $originalServiceName = $key;
if (isset($this->parsedServices['services'])) { if (isset($this->parsedServices['services'])) {
foreach ($this->parsedServices['services'] as $originalName => $service) { foreach ($this->parsedServices['services'] as $originalName => $service) {
if (str($originalName)->slug('_')->toString() === $key) { if (str($originalName)->replace('-', '_')->replace('.', '_')->toString() === $key) {
$originalServiceName = $originalName; $originalServiceName = $originalName;
break; break;
} }
@ -547,9 +547,10 @@ public function submit($showToaster = true)
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']); Url::fromString($domain, ['http', 'https']);
return str($domain)->trim()->lower(); return str($domain)->lower();
}); });
$this->application->fqdn = $this->application->fqdn->unique()->implode(','); $this->application->fqdn = $this->application->fqdn->unique()->implode(',');

View file

@ -72,10 +72,13 @@ public function generate()
$template = $this->preview->application->preview_url_template; $template = $this->preview->application->preview_url_template;
$host = $url->getHost(); $host = $url->getHost();
$schema = $url->getScheme(); $schema = $url->getScheme();
$portInt = $url->getPort();
$port = $portInt !== null ? ':' . $portInt : '';
$random = new Cuid2; $random = new Cuid2;
$preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn);
$preview_fqdn = "$schema://$preview_fqdn"; $preview_fqdn = "$schema://$preview_fqdn";
} }

View file

@ -127,7 +127,7 @@ public function clone(string $type)
$databases = $this->environment->databases(); $databases = $this->environment->databases();
$services = $this->environment->services; $services = $this->environment->services;
foreach ($applications as $application) { 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, [ clone_application($application, $selectedDestination, [
'environment_id' => $environment->id, 'environment_id' => $environment->id,
], $this->cloneVolumeData); ], $this->cloneVolumeData);

View file

@ -232,12 +232,8 @@ public function runImport()
break; break;
} }
$this->importCommands[] = [ $restoreCommandBase64 = base64_encode($restoreCommand);
'transfer_file' => [ $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
'content' => $restoreCommand,
'destination' => $scriptPath,
],
];
$this->importCommands[] = "chmod +x {$scriptPath}"; $this->importCommands[] = "chmod +x {$scriptPath}";
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}"; $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";

View file

@ -143,7 +143,13 @@ public function loadBranches()
protected function loadBranchByPage() protected function loadBranchByPage()
{ {
$response = Http::withToken($this->token)->get("{$this->github_app->api_url}/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches?per_page=100&page={$this->page}"); $response = Http::GitHub($this->github_app->api_url, $this->token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [
'per_page' => 100,
'page' => $this->page,
]);
$json = $response->json(); $json = $response->json();
if ($response->status() !== 200) { if ($response->status() !== 200) {
return $this->dispatch('error', $json['message']); return $this->dispatch('error', $json['message']);

View file

@ -41,9 +41,10 @@ public function submit()
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']); Url::fromString($domain, ['http', 'https']);
return str($domain)->trim()->lower(); return str($domain)->lower();
}); });
$this->application->fqdn = $this->application->fqdn->unique()->implode(','); $this->application->fqdn = $this->application->fqdn->unique()->implode(',');
$warning = sslipDomainWarning($this->application->fqdn); $warning = sslipDomainWarning($this->application->fqdn);

View file

@ -149,9 +149,10 @@ public function submit()
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']); Url::fromString($domain, ['http', 'https']);
return str($domain)->trim()->lower(); return str($domain)->lower();
}); });
$this->application->fqdn = $this->application->fqdn->unique()->implode(','); $this->application->fqdn = $this->application->fqdn->unique()->implode(',');
$warning = sslipDomainWarning($this->application->fqdn); $warning = sslipDomainWarning($this->application->fqdn);

View file

@ -20,7 +20,15 @@ class ConfigurationChecker extends Component
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
protected $listeners = ['configurationChanged']; public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged',
'configurationChanged' => 'configurationChanged',
];
}
public function mount() public function mount()
{ {

View file

@ -2,12 +2,13 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable; namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Traits\EnvironmentVariableAnalyzer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
class Add extends Component class Add extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests, EnvironmentVariableAnalyzer;
public $parameters; public $parameters;
@ -23,7 +24,11 @@ class Add extends Component
public bool $is_literal = false; public bool $is_literal = false;
public bool $is_buildtime_only = false; public bool $is_runtime = true;
public bool $is_buildtime = true;
public array $problematicVariables = [];
protected $listeners = ['clearAddEnv' => 'clear']; protected $listeners = ['clearAddEnv' => 'clear'];
@ -32,7 +37,8 @@ class Add extends Component
'value' => 'nullable', 'value' => 'nullable',
'is_multiline' => 'required|boolean', 'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean', 'is_literal' => 'required|boolean',
'is_buildtime_only' => 'required|boolean', 'is_runtime' => 'required|boolean',
'is_buildtime' => 'required|boolean',
]; ];
protected $validationAttributes = [ protected $validationAttributes = [
@ -40,12 +46,14 @@ class Add extends Component
'value' => 'value', 'value' => 'value',
'is_multiline' => 'multiline', 'is_multiline' => 'multiline',
'is_literal' => 'literal', 'is_literal' => 'literal',
'is_buildtime_only' => 'buildtime only', 'is_runtime' => 'runtime',
'is_buildtime' => 'buildtime',
]; ];
public function mount() public function mount()
{ {
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->problematicVariables = self::getProblematicVariablesForFrontend();
} }
public function submit() public function submit()
@ -56,7 +64,8 @@ public function submit()
'value' => $this->value, 'value' => $this->value,
'is_multiline' => $this->is_multiline, 'is_multiline' => $this->is_multiline,
'is_literal' => $this->is_literal, 'is_literal' => $this->is_literal,
'is_buildtime_only' => $this->is_buildtime_only, 'is_runtime' => $this->is_runtime,
'is_buildtime' => $this->is_buildtime,
'is_preview' => $this->is_preview, 'is_preview' => $this->is_preview,
]); ]);
$this->clear(); $this->clear();
@ -68,6 +77,7 @@ public function clear()
$this->value = ''; $this->value = '';
$this->is_multiline = false; $this->is_multiline = false;
$this->is_literal = false; $this->is_literal = false;
$this->is_buildtime_only = 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 $is_env_sorting_enabled = false;
public bool $use_build_secrets = false;
protected $listeners = [ protected $listeners = [
'saveKey' => 'submit', 'saveKey' => 'submit',
'refreshEnvs', 'refreshEnvs',
@ -34,6 +36,7 @@ class All extends Component
public function mount() public function mount()
{ {
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false); $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); $this->resourceClass = get_class($this->resource);
$resourceWithPreviews = [\App\Models\Application::class]; $resourceWithPreviews = [\App\Models\Application::class];
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile')); $simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
@ -49,6 +52,7 @@ public function instantSave()
$this->authorize('manageEnvironment', $this->resource); $this->authorize('manageEnvironment', $this->resource);
$this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled; $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->resource->settings->save();
$this->getDevView(); $this->getDevView();
$this->dispatch('success', 'Environment variable settings updated.'); $this->dispatch('success', 'Environment variable settings updated.');
@ -217,7 +221,8 @@ private function createEnvironmentVariable($data)
$environment->value = $data['value']; $environment->value = $data['value'];
$environment->is_multiline = $data['is_multiline'] ?? false; $environment->is_multiline = $data['is_multiline'] ?? false;
$environment->is_literal = $data['is_literal'] ?? false; $environment->is_literal = $data['is_literal'] ?? false;
$environment->is_buildtime_only = $data['is_buildtime_only'] ?? false; $environment->is_runtime = $data['is_runtime'] ?? true;
$environment->is_buildtime = $data['is_buildtime'] ?? true;
$environment->is_preview = $data['is_preview'] ?? false; $environment->is_preview = $data['is_preview'] ?? false;
$environment->resourceable_id = $this->resource->id; $environment->resourceable_id = $this->resource->id;
$environment->resourceable_type = $this->resource->getMorphClass(); $environment->resourceable_type = $this->resource->getMorphClass();

View file

@ -4,13 +4,14 @@
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Models\SharedEnvironmentVariable; use App\Models\SharedEnvironmentVariable;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection; use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
class Show extends Component class Show extends Component
{ {
use AuthorizesRequests, EnvironmentVariableProtection; use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
public $parameters; public $parameters;
@ -38,7 +39,9 @@ class Show extends Component
public bool $is_shown_once = false; public bool $is_shown_once = false;
public bool $is_buildtime_only = false; public bool $is_runtime = true;
public bool $is_buildtime = true;
public bool $is_required = false; public bool $is_required = false;
@ -46,6 +49,8 @@ class Show extends Component
public bool $is_redis_credential = false; public bool $is_redis_credential = false;
public array $problematicVariables = [];
protected $listeners = [ protected $listeners = [
'refreshEnvs' => 'refresh', 'refreshEnvs' => 'refresh',
'refresh', 'refresh',
@ -58,7 +63,8 @@ class Show extends Component
'is_multiline' => 'required|boolean', 'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean', 'is_literal' => 'required|boolean',
'is_shown_once' => 'required|boolean', 'is_shown_once' => 'required|boolean',
'is_buildtime_only' => 'required|boolean', 'is_runtime' => 'required|boolean',
'is_buildtime' => 'required|boolean',
'real_value' => 'nullable', 'real_value' => 'nullable',
'is_required' => 'required|boolean', 'is_required' => 'required|boolean',
]; ];
@ -74,6 +80,7 @@ public function mount()
if ($this->type === 'standalone-redis' && ($this->env->key === 'REDIS_PASSWORD' || $this->env->key === 'REDIS_USERNAME')) { if ($this->type === 'standalone-redis' && ($this->env->key === 'REDIS_PASSWORD' || $this->env->key === 'REDIS_USERNAME')) {
$this->is_redis_credential = true; $this->is_redis_credential = true;
} }
$this->problematicVariables = self::getProblematicVariablesForFrontend();
} }
public function getResourceProperty() public function getResourceProperty()
@ -102,7 +109,8 @@ public function syncData(bool $toModel = false)
} else { } else {
$this->validate(); $this->validate();
$this->env->is_required = $this->is_required; $this->env->is_required = $this->is_required;
$this->env->is_buildtime_only = $this->is_buildtime_only; $this->env->is_runtime = $this->is_runtime;
$this->env->is_buildtime = $this->is_buildtime;
$this->env->is_shared = $this->is_shared; $this->env->is_shared = $this->is_shared;
} }
$this->env->key = $this->key; $this->env->key = $this->key;
@ -117,7 +125,8 @@ public function syncData(bool $toModel = false)
$this->is_multiline = $this->env->is_multiline; $this->is_multiline = $this->env->is_multiline;
$this->is_literal = $this->env->is_literal; $this->is_literal = $this->env->is_literal;
$this->is_shown_once = $this->env->is_shown_once; $this->is_shown_once = $this->env->is_shown_once;
$this->is_buildtime_only = $this->env->is_buildtime_only ?? false; $this->is_runtime = $this->env->is_runtime ?? true;
$this->is_buildtime = $this->env->is_buildtime ?? true;
$this->is_required = $this->env->is_required ?? false; $this->is_required = $this->env->is_required ?? false;
$this->is_really_required = $this->env->is_really_required ?? false; $this->is_really_required = $this->env->is_really_required ?? false;
$this->is_shared = $this->env->is_shared ?? false; $this->is_shared = $this->env->is_shared ?? false;

View file

@ -8,7 +8,7 @@ class Metrics extends Component
{ {
public $resource; public $resource;
public $chartId = 'container-cpu'; public $chartId = 'metrics';
public $data; public $data;

View file

@ -105,6 +105,19 @@ public function loadMoreLogs()
$this->currentPage++; $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() public function getLogLinesProperty()
{ {
if (! $this->selectedExecution) { if (! $this->selectedExecution) {

View file

@ -27,9 +27,6 @@ class Advanced extends Component
#[Validate(['integer', 'min:1'])] #[Validate(['integer', 'min:1'])]
public int $dynamicTimeout = 1; public int $dynamicTimeout = 1;
#[Validate(['boolean'])]
public bool $isTerminalEnabled = false;
public function mount(string $server_uuid) public function mount(string $server_uuid)
{ {
try { try {
@ -42,36 +39,7 @@ public function mount(string $server_uuid)
} }
} }
public function toggleTerminal($password)
{
try {
// Check if user is admin or owner
if (! auth()->user()->isAdmin()) {
throw new \Exception('Only team administrators and owners can modify terminal access.');
}
// Verify password unless two-step confirmation is disabled
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
}
// Toggle the terminal setting
$this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled;
$this->server->settings->save();
// Update the local property
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
$status = $this->isTerminalEnabled ? 'enabled' : 'disabled';
$this->dispatch('success', "Terminal access has been {$status}.");
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false) public function syncData(bool $toModel = false)
{ {
@ -88,7 +56,6 @@ public function syncData(bool $toModel = false)
$this->dynamicTimeout = $this->server->settings->dynamic_timeout; $this->dynamicTimeout = $this->server->settings->dynamic_timeout;
$this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold; $this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold;
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency; $this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
} }
} }

View file

@ -32,7 +32,7 @@ public function getListeners()
$teamId = auth()->user()->currentTeam()->id; $teamId = auth()->user()->currentTeam()->id;
return [ return [
'refreshServerShow' => '$refresh', 'refreshServerShow' => 'refreshServer',
"echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification', "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() public function render()
{ {
return view('livewire.server.navbar'); return view('livewire.server.navbar');

View file

@ -5,6 +5,7 @@
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Server; use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
class Show extends Component class Show extends Component
@ -35,19 +36,20 @@ public function setPrivateKey($privateKeyId)
return; return;
} }
$originalPrivateKeyId = $this->server->getOriginal('private_key_id');
try { try {
$this->authorize('update', $this->server); $this->authorize('update', $this->server);
$this->server->update(['private_key_id' => $privateKeyId]); DB::transaction(function () use ($ownedPrivateKey) {
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true); $this->server->privateKey()->associate($ownedPrivateKey);
if ($uptime) { $this->server->save();
$this->dispatch('success', 'Private key updated successfully.'); ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true);
} else { if (! $uptime) {
throw new \Exception($error); throw new \Exception($error);
} }
});
$this->dispatch('success', 'Private key updated successfully.');
$this->dispatch('refreshServerShow');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->server->update(['private_key_id' => $originalPrivateKeyId]); $this->server->refresh();
$this->server->validateConnection(); $this->server->validateConnection();
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
} }
@ -59,6 +61,7 @@ public function checkConnection()
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if ($uptime) { if ($uptime) {
$this->dispatch('success', 'Server is reachable.'); $this->dispatch('success', 'Server is reachable.');
$this->dispatch('refreshServerShow');
} else { } else {
$this->dispatch('error', 'Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$error); $this->dispatch('error', 'Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$error);

View file

@ -45,7 +45,7 @@ public function mount()
public function getConfigurationFilePathProperty() public function getConfigurationFilePathProperty()
{ {
return $this->server->proxyPath().'/docker-compose.yml'; return $this->server->proxyPath().'docker-compose.yml';
} }
public function changeProxy() public function changeProxy()

View file

@ -78,7 +78,10 @@ public function addDynamicConfiguration()
$yaml = Yaml::dump($yaml, 10, 2); $yaml = Yaml::dump($yaml, 10, 2);
$this->value = $yaml; $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') { if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy(); $this->server->reloadCaddy();
} }

View file

@ -0,0 +1,85 @@
<?php
namespace App\Livewire\Server\Security;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
class TerminalAccess extends Component
{
use AuthorizesRequests;
public Server $server;
public array $parameters = [];
#[Validate(['boolean'])]
public bool $isTerminalEnabled = false;
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->authorize('update', $this->server);
$this->parameters = get_route_parameters();
$this->syncData();
} catch (\Throwable) {
return redirect()->route('server.index');
}
}
public function toggleTerminal($password)
{
try {
$this->authorize('update', $this->server);
// Check if user is admin or owner
if (! auth()->user()->isAdmin()) {
throw new \Exception('Only team administrators and owners can modify terminal access.');
}
// Verify password unless two-step confirmation is disabled
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
}
// Toggle the terminal setting
$this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled;
$this->server->settings->save();
// Update the local property
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
$status = $this->isTerminalEnabled ? 'enabled' : 'disabled';
$this->dispatch('success', "Terminal access has been {$status}.");
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->authorize('update', $this->server);
$this->validate();
// No other fields to sync for terminal access
} else {
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
}
}
public function render()
{
return view('livewire.server.security.terminal-access');
}
}

View file

@ -271,7 +271,7 @@ public function restartSentinel()
$this->authorize('manageSentinel', $this->server); $this->authorize('manageSentinel', $this->server);
$customImage = isDev() ? $this->sentinelCustomDockerImage : null; $customImage = isDev() ? $this->sentinelCustomDockerImage : null;
$this->server->restartSentinel($customImage); $this->server->restartSentinel($customImage);
$this->dispatch('success', 'Restarting Sentinel.'); $this->dispatch('info', 'Restarting Sentinel.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -298,11 +298,36 @@ 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) public function updatedIsSentinelEnabled($value)
{ {
try { try {
$this->authorize('manageSentinel', $this->server); $this->authorize('manageSentinel', $this->server);
if ($value === true) { if ($value === true) {
if ($this->isBuildServer) {
$this->isSentinelEnabled = false;
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
return;
}
$customImage = isDev() ? $this->sentinelCustomDockerImage : null; $customImage = isDev() ? $this->sentinelCustomDockerImage : null;
StartSentinel::run($this->server, true, null, $customImage); StartSentinel::run($this->server, true, null, $customImage);
} else { } else {
@ -330,7 +355,7 @@ public function regenerateSentinelToken()
public function instantSave() public function instantSave()
{ {
try { try {
$this->submit(); $this->syncData(true);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -340,7 +365,7 @@ public function submit()
{ {
try { try {
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Server updated.'); $this->dispatch('success', 'Server settings updated.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View file

@ -146,7 +146,7 @@ public function validateDockerVersion()
StartProxy::dispatch($this->server); StartProxy::dispatch($this->server);
} else { } else {
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); $requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
$this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not instaled. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.'; $this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
$this->server->update([ $this->server->update([
'validation_logs' => $this->error, 'validation_logs' => $this->error,
]); ]);

View file

@ -48,6 +48,8 @@ private function generateInviteLink(bool $sendEmail = false)
if (auth()->user()->role() === 'admin' && $this->role === 'owner') { if (auth()->user()->role() === 'admin' && $this->role === 'owner') {
throw new \Exception('Admins cannot invite owners.'); throw new \Exception('Admins cannot invite owners.');
} }
$this->email = strtolower($this->email);
$member_emails = currentTeam()->members()->get()->pluck('email'); $member_emails = currentTeam()->members()->get()->pluck('email');
if ($member_emails->contains($this->email)) { if ($member_emails->contains($this->email)) {
return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.');

View file

@ -4,6 +4,7 @@
use App\Enums\ApplicationDeploymentStatus; use App\Enums\ApplicationDeploymentStatus;
use App\Services\ConfigurationGenerator; use App\Services\ConfigurationGenerator;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration; use App\Traits\HasConfiguration;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -110,7 +111,7 @@
class Application extends BaseModel class Application extends BaseModel
{ {
use HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5'; private static $parserVersion = '5';
@ -123,66 +124,6 @@ class Application extends BaseModel
'http_basic_auth_password' => 'encrypted', '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() protected static function booted()
{ {
static::addGlobalScope('withRelations', function ($builder) { 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) public static function ownedByCurrentTeamAPI(int $teamId)
{ {
return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name');
@ -932,11 +933,11 @@ public function isLogDrainEnabled()
public function isConfigurationChanged(bool $save = false) 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) { if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal'])->sort()); $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
} else { } else {
$newConfigHash .= json_encode($this->environment_variables_preview->get(['value', '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); $newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash'); $oldConfigHash = data_get($this, 'config_hash');
@ -1073,20 +1074,26 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
if (is_null($private_key)) { if (is_null($private_key)) {
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.'); 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}"; $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) { 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)); $commands->push(executeInDocker($deployment_uuid, $base_comamnd));
} else { } 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); $commands->push($base_comamnd);
} }
@ -1212,6 +1219,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
if (is_null($private_key)) { if (is_null($private_key)) {
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.'); 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); $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}"; $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) { if ($only_checkout) {
@ -1219,18 +1227,18 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else { } else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base); $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base);
} }
$commands = collect([]);
if ($exec_in_docker) { if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh')); $commands = collect([
// SSH key transfer handled by ApplicationDeploymentJob, assume key is already in container executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
$commands->push(executeInDocker($deployment_uuid, 'chmod 600 /root/.ssh/id_rsa')); 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 { } else {
$server = $this->destination->server; $commands = collect([
$commands->push('mkdir -p /root/.ssh'); 'mkdir -p /root/.ssh',
transfer_file_to_server($private_key, '/root/.ssh/id_rsa', $server); "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null",
$commands->push('chmod 600 /root/.ssh/id_rsa'); 'chmod 600 /root/.ssh/id_rsa',
]);
} }
if ($pull_request_id !== 0) { if ($pull_request_id !== 0) {
if ($git_type === 'gitlab') { if ($git_type === 'gitlab') {
@ -1471,14 +1479,14 @@ public function loadComposeFile($isInit = false)
if ($this->docker_compose_domains) { if ($this->docker_compose_domains) {
$json = collect(json_decode($this->docker_compose_domains)); $json = collect(json_decode($this->docker_compose_domains));
foreach ($json as $key => $value) { foreach ($json as $key => $value) {
if (str($key)->contains('-')) { if (str($key)->contains('-') || str($key)->contains('.')) {
$key = str($key)->replace('-', '_')->replace('.', '_'); $key = str($key)->replace('-', '_')->replace('.', '_');
} }
$json->put((string) $key, $value); $json->put((string) $key, $value);
} }
$services = collect(data_get($parsedServices, 'services', [])); $services = collect(data_get($parsedServices, 'services', []));
foreach ($services as $name => $service) { foreach ($services as $name => $service) {
if (str($name)->contains('-')) { if (str($name)->contains('-') || str($name)->contains('.')) {
$replacedName = str($name)->replace('-', '_')->replace('.', '_'); $replacedName = str($name)->replace('-', '_')->replace('.', '_');
$services->put((string) $replacedName, $service); $services->put((string) $replacedName, $service);
$services->forget((string) $name); $services->forget((string) $name);
@ -1495,6 +1503,7 @@ public function loadComposeFile($isInit = false)
} else { } else {
$this->docker_compose_domains = null; $this->docker_compose_domains = null;
} }
ray($this->docker_compose_domains);
$this->save(); $this->save();
} }
@ -1547,28 +1556,185 @@ protected function buildGitCheckoutCommand($target): string
return $command; return $command;
} }
private function parseWatchPaths($value)
{
if ($value) {
$watch_paths = collect(explode("\n", $value))
->map(function (string $path): string {
// Trim whitespace and remove leading slashes to normalize paths
$path = trim($path);
return ltrim($path, '/');
})
->filter(function (string $path): bool {
return strlen($path) > 0;
});
return trim($watch_paths->implode("\n"));
}
}
public function watchPaths(): Attribute public function watchPaths(): Attribute
{ {
return Attribute::make( return Attribute::make(
set: function ($value) { set: function ($value) {
if ($value) { if ($value) {
return trim($value); return $this->parseWatchPaths($value);
} }
} }
); );
} }
public function matchWatchPaths(Collection $modified_files, ?Collection $watch_paths): Collection
{
return self::matchPaths($modified_files, $watch_paths);
}
/**
* Static method to match paths against watch patterns with negation support
* Uses order-based matching: last matching pattern wins
*/
public static function matchPaths(Collection $modified_files, ?Collection $watch_paths): Collection
{
if (is_null($watch_paths) || $watch_paths->isEmpty()) {
return collect([]);
}
return $modified_files->filter(function ($file) use ($watch_paths) {
$shouldInclude = null; // null means no patterns matched
// Process patterns in order - last match wins
foreach ($watch_paths as $pattern) {
$pattern = trim($pattern);
if (empty($pattern)) {
continue;
}
$isExclusion = str_starts_with($pattern, '!');
$matchPattern = $isExclusion ? substr($pattern, 1) : $pattern;
if (self::globMatch($matchPattern, $file)) {
// This pattern matches - it determines the current state
$shouldInclude = ! $isExclusion;
}
}
// If no patterns matched and we only have exclusion patterns, include by default
if ($shouldInclude === null) {
// Check if we only have exclusion patterns
$hasInclusionPatterns = $watch_paths->contains(fn ($p) => ! str_starts_with(trim($p), '!'));
return ! $hasInclusionPatterns;
}
return $shouldInclude;
})->values();
}
/**
* Check if a path matches a glob pattern
* Supports: *, **, ?, [abc], [!abc]
*/
public static function globMatch(string $pattern, string $path): bool
{
$regex = self::globToRegex($pattern);
return preg_match($regex, $path) === 1;
}
/**
* Convert a glob pattern to a regular expression
*/
public static function globToRegex(string $pattern): string
{
$regex = '';
$inGroup = false;
$chars = str_split($pattern);
$len = count($chars);
for ($i = 0; $i < $len; $i++) {
$c = $chars[$i];
switch ($c) {
case '*':
// Check for **
if ($i + 1 < $len && $chars[$i + 1] === '*') {
// ** matches any number of directories
$regex .= '.*';
$i++; // Skip next *
// Skip optional /
if ($i + 1 < $len && $chars[$i + 1] === '/') {
$i++;
}
} else {
// * matches anything except /
$regex .= '[^/]*';
}
break;
case '?':
// ? matches any single character except /
$regex .= '[^/]';
break;
case '[':
// Character class
$inGroup = true;
$regex .= '[';
// Check for negation
if ($i + 1 < $len && ($chars[$i + 1] === '!' || $chars[$i + 1] === '^')) {
$regex .= '^';
$i++;
}
break;
case ']':
if ($inGroup) {
$inGroup = false;
$regex .= ']';
} else {
$regex .= preg_quote($c, '#');
}
break;
case '.':
case '(':
case ')':
case '+':
case '{':
case '}':
case '$':
case '^':
case '|':
case '\\':
// Escape regex special characters
$regex .= '\\'.$c;
break;
default:
$regex .= $c;
break;
}
}
// Wrap in delimiters and anchors
return '#^'.$regex.'$#';
}
public function isWatchPathsTriggered(Collection $modified_files): bool public function isWatchPathsTriggered(Collection $modified_files): bool
{ {
if (is_null($this->watch_paths)) { if (is_null($this->watch_paths)) {
return false; return false;
} }
$this->watch_paths = $this->parseWatchPaths($this->watch_paths);
$this->save();
$watch_paths = collect(explode("\n", $this->watch_paths)); $watch_paths = collect(explode("\n", $this->watch_paths));
$matches = $modified_files->filter(function ($file) use ($watch_paths) {
return $watch_paths->contains(function ($glob) use ($file) { // If no valid patterns after filtering, don't trigger
return fnmatch($glob, $file); if ($watch_paths->isEmpty()) {
}); return false;
}); }
$matches = $this->matchWatchPaths($modified_files, $watch_paths);
return $matches->count() > 0; return $matches->count() > 0;
} }

View file

@ -85,6 +85,47 @@ public function commitMessage()
return str($this->commit_message)->value(); return str($this->commit_message)->value();
} }
private function redactSensitiveInfo($text)
{
$text = remove_iip($text);
$app = $this->application;
if (! $app) {
return $text;
}
$lockedVars = collect([]);
if ($app->environment_variables) {
$lockedVars = $lockedVars->merge(
$app->environment_variables
->where('is_shown_once', true)
->pluck('real_value', 'key')
->filter()
);
}
if ($this->pull_request_id !== 0 && $app->environment_variables_preview) {
$lockedVars = $lockedVars->merge(
$app->environment_variables_preview
->where('is_shown_once', true)
->pluck('real_value', 'key')
->filter()
);
}
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
'/'.$escapedValue.'/',
REDACTED,
$text
);
}
return $text;
}
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false) public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false)
{ {
if ($type === 'error') { if ($type === 'error') {
@ -96,7 +137,7 @@ public function addLogEntry(string $message, string $type = 'stdout', bool $hidd
} }
$newLogEntry = [ $newLogEntry = [
'command' => null, 'command' => null,
'output' => remove_iip($message), 'output' => $this->redactSensitiveInfo($message),
'type' => $type, 'type' => $type,
'timestamp' => Carbon::now('UTC'), 'timestamp' => Carbon::now('UTC'),
'hidden' => $hidden, 'hidden' => $hidden,

View file

@ -17,7 +17,8 @@
'is_literal' => ['type' => 'boolean'], 'is_literal' => ['type' => 'boolean'],
'is_multiline' => ['type' => 'boolean'], 'is_multiline' => ['type' => 'boolean'],
'is_preview' => ['type' => 'boolean'], 'is_preview' => ['type' => 'boolean'],
'is_buildtime_only' => ['type' => 'boolean'], 'is_runtime' => ['type' => 'boolean'],
'is_buildtime' => ['type' => 'boolean'],
'is_shared' => ['type' => 'boolean'], 'is_shared' => ['type' => 'boolean'],
'is_shown_once' => ['type' => 'boolean'], 'is_shown_once' => ['type' => 'boolean'],
'key' => ['type' => 'string'], 'key' => ['type' => 'string'],
@ -37,13 +38,14 @@ class EnvironmentVariable extends BaseModel
'value' => 'encrypted', 'value' => 'encrypted',
'is_multiline' => 'boolean', 'is_multiline' => 'boolean',
'is_preview' => 'boolean', 'is_preview' => 'boolean',
'is_buildtime_only' => 'boolean', 'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
'version' => 'string', 'version' => 'string',
'resourceable_type' => 'string', 'resourceable_type' => 'string',
'resourceable_id' => 'integer', '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() protected static function booted()
{ {
@ -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 protected function isShared(): Attribute
{ {
return Attribute::make( return Attribute::make(

View file

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

View file

@ -10,6 +10,21 @@ class ScheduledDatabaseBackup extends BaseModel
{ {
protected $guarded = []; protected $guarded = [];
public static function ownedByCurrentTeam()
{
return ScheduledDatabaseBackup::whereRelation('team', 'id', currentTeam()->id)->orderBy('name');
}
public static function ownedByCurrentTeamAPI(int $teamId)
{
return ScheduledDatabaseBackup::whereRelation('team', 'id', $teamId)->orderBy('name');
}
public function team()
{
return $this->belongsTo(Team::class);
}
public function database(): MorphTo public function database(): MorphTo
{ {
return $this->morphTo(); return $this->morphTo();

View file

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

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\ProcessStatus; use App\Enums\ProcessStatus;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -41,7 +42,7 @@
)] )]
class Service extends BaseModel class Service extends BaseModel
{ {
use HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5'; private static $parserVersion = '5';
@ -1280,10 +1281,8 @@ public function saveComposeConfigs()
if ($envs->count() === 0) { if ($envs->count() === 0) {
$commands[] = 'touch .env'; $commands[] = 'touch .env';
} else { } else {
$envs_content = $envs->implode("\n"); $envs_base64 = base64_encode($envs->implode("\n"));
transfer_file_to_server($envs_content, $this->workdir().'/.env', $this->server); $commands[] = "echo '$envs_base64' | base64 -d | tee .env > /dev/null";
return;
} }
instant_remote_process($commands, $this->server); instant_remote_process($commands, $this->server);

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandaloneClickhouse extends BaseModel class StandaloneClickhouse extends BaseModel
{ {
use HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; 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 protected function serverStatus(): Attribute
{ {
return Attribute::make( return Attribute::make(

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandaloneDragonfly extends BaseModel class StandaloneDragonfly extends BaseModel
{ {
use HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; 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 protected function serverStatus(): Attribute
{ {
return Attribute::make( return Attribute::make(

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandaloneKeydb extends BaseModel class StandaloneKeydb extends BaseModel
{ {
use HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; 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 protected function serverStatus(): Attribute
{ {
return Attribute::make( return Attribute::make(

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -10,7 +11,7 @@
class StandaloneMariadb extends BaseModel class StandaloneMariadb extends BaseModel
{ {
use HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; 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 protected function serverStatus(): Attribute
{ {
return Attribute::make( return Attribute::make(

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandaloneMongodb extends BaseModel class StandaloneMongodb extends BaseModel
{ {
use HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; 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 protected function serverStatus(): Attribute
{ {
return Attribute::make( return Attribute::make(

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandaloneMysql extends BaseModel class StandaloneMysql extends BaseModel
{ {
use HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; 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 protected function serverStatus(): Attribute
{ {
return Attribute::make( return Attribute::make(

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandalonePostgresql extends BaseModel class StandalonePostgresql extends BaseModel
{ {
use HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; 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() public function workdir()
{ {
return database_configuration_dir()."/{$this->uuid}"; return database_configuration_dir()."/{$this->uuid}";

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -9,7 +10,7 @@
class StandaloneRedis extends BaseModel class StandaloneRedis extends BaseModel
{ {
use HasFactory, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
protected $guarded = []; 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 protected function serverStatus(): Attribute
{ {
return Attribute::make( return Attribute::make(

View file

@ -10,6 +10,7 @@
use App\Traits\HasNotificationSettings; use App\Traits\HasNotificationSettings;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
@ -37,7 +38,7 @@
class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack
{ {
use HasNotificationSettings, HasSafeStringAttribute, Notifiable; use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable;
protected $guarded = []; protected $guarded = [];
@ -193,6 +194,7 @@ public function isAnyNotificationEnabled()
public function subscriptionEnded() public function subscriptionEnded()
{ {
$this->subscription->update([ $this->subscription->update([
'stripe_subscription_id' => null,
'stripe_cancel_at_period_end' => false, 'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false, 'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false, 'stripe_trial_already_ended' => false,

View file

@ -15,6 +15,14 @@ class TeamInvitation extends Model
'via', 'via',
]; ];
/**
* Set the email attribute to lowercase.
*/
public function setEmailAttribute(string $value): void
{
$this->attributes['email'] = strtolower($value);
}
public function team() public function team()
{ {
return $this->belongsTo(Team::class); return $this->belongsTo(Team::class);

View file

@ -80,9 +80,23 @@ public function boot(): void
) { ) {
$user->updated_at = now(); $user->updated_at = now();
$user->save(); $user->save();
$user->currentTeam = $user->teams->firstWhere('personal_team', true);
if (! $user->currentTeam) { // Check if user has a pending invitation they haven't accepted yet
$user->currentTeam = $user->recreate_personal_team(); $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]); session(['currentTeam' => $user->currentTeam]);

View file

@ -31,7 +31,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
$dangerousChars = [ $dangerousChars = [
';', '|', '&', '$', '`', '(', ')', '{', '}', ';', '|', '&', '$', '`', '(', ')', '{', '}',
'[', ']', '<', '>', '\n', '\r', '\0', '"', "'", '[', ']', '<', '>', '\n', '\r', '\0', '"', "'",
'\\', '!', '?', '*', '~', '^', '%', '=', '+', '\\', '!', '?', '*', '^', '%', '=', '+',
'#', // Comment character that could hide commands '#', // Comment character that could hide commands
]; ];
@ -85,7 +85,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
} }
// Validate SSH URL format (git@host:user/repo.git) // Validate SSH URL format (git@host:user/repo.git)
if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.]+$/', $value)) { if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.~]+$/', $value)) {
$fail('The :attribute is not a valid SSH repository URL.'); $fail('The :attribute is not a valid SSH repository URL.');
return; return;

View file

@ -0,0 +1,86 @@
<?php
namespace App\Traits;
use App\Livewire\GlobalSearch;
use Illuminate\Database\Eloquent\Model;
trait ClearsGlobalSearchCache
{
protected static function bootClearsGlobalSearchCache()
{
static::saving(function ($model) {
// Only clear cache if searchable fields are being changed
if ($model->hasSearchableChanges()) {
$teamId = $model->getTeamIdForCache();
if (filled($teamId)) {
GlobalSearch::clearTeamCache($teamId);
}
}
});
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')) {
if ($this instanceof \App\Models\Server) {
$team = $this->team;
} else {
$team = $this->team();
}
if (filled($team)) {
return is_object($team) ? $team->id : null;
}
}
// For models with direct team_id property
if (property_exists($this, 'team_id') || isset($this->team_id)) {
return $this->team_id;
}
return null;
}
}

View file

@ -0,0 +1,221 @@
<?php
namespace App\Traits;
trait EnvironmentVariableAnalyzer
{
/**
* List of environment variables that commonly cause build issues when set to production values.
* Each entry contains the variable pattern and associated metadata.
*/
protected static function getProblematicBuildVariables(): array
{
return [
'NODE_ENV' => [
'problematic_values' => ['production', 'prod'],
'affects' => 'Node.js/npm/yarn/bun/pnpm',
'issue' => 'Skips devDependencies installation which are often required for building (webpack, typescript, etc.)',
'recommendation' => 'Uncheck "Available at Buildtime" or use "development" during build',
],
'NPM_CONFIG_PRODUCTION' => [
'problematic_values' => ['true', '1', 'yes'],
'affects' => 'npm/pnpm',
'issue' => 'Forces npm to skip devDependencies',
'recommendation' => 'Remove from build-time variables or set to false',
],
'YARN_PRODUCTION' => [
'problematic_values' => ['true', '1', 'yes'],
'affects' => 'Yarn/pnpm',
'issue' => 'Forces yarn to skip devDependencies',
'recommendation' => 'Remove from build-time variables or set to false',
],
'COMPOSER_NO_DEV' => [
'problematic_values' => ['1', 'true', 'yes'],
'affects' => 'PHP/Composer',
'issue' => 'Skips require-dev packages which may include build tools',
'recommendation' => 'Set as "Runtime only" or remove from build-time variables',
],
'MIX_ENV' => [
'problematic_values' => ['prod', 'production'],
'affects' => 'Elixir/Phoenix',
'issue' => 'Production mode may skip development dependencies needed for compilation',
'recommendation' => 'Use "dev" for build or set as "Runtime only"',
],
'RAILS_ENV' => [
'problematic_values' => ['production'],
'affects' => 'Ruby on Rails',
'issue' => 'May affect asset precompilation and dependency handling',
'recommendation' => 'Consider using "development" for build phase',
],
'RACK_ENV' => [
'problematic_values' => ['production'],
'affects' => 'Ruby/Rack',
'issue' => 'May affect dependency handling and build behavior',
'recommendation' => 'Consider using "development" for build phase',
],
'BUNDLE_WITHOUT' => [
'problematic_values' => ['development', 'test', 'development:test'],
'affects' => 'Ruby/Bundler',
'issue' => 'Excludes gem groups that may contain build dependencies',
'recommendation' => 'Remove from build-time variables or adjust groups',
],
'FLASK_ENV' => [
'problematic_values' => ['production'],
'affects' => 'Python/Flask',
'issue' => 'May affect debug mode and development tools availability',
'recommendation' => 'Usually safe, but consider "development" for complex builds',
],
'DJANGO_SETTINGS_MODULE' => [
'problematic_values' => [], // Check if contains 'production' or 'prod'
'affects' => 'Python/Django',
'issue' => 'Production settings may disable debug tools needed during build',
'recommendation' => 'Use development settings for build phase',
'check_function' => 'checkDjangoSettings',
],
'APP_ENV' => [
'problematic_values' => ['production', 'prod'],
'affects' => 'Laravel/Symfony',
'issue' => 'May affect dependency installation and build optimizations',
'recommendation' => 'Consider using "local" or "development" for build',
],
'ASPNETCORE_ENVIRONMENT' => [
'problematic_values' => ['Production'],
'affects' => '.NET/ASP.NET Core',
'issue' => 'May affect build-time configurations and optimizations',
'recommendation' => 'Usually safe, but verify build requirements',
],
'CI' => [
'problematic_values' => ['true', '1', 'yes'],
'affects' => 'Various tools',
'issue' => 'Changes behavior in many tools (disables interactivity, changes caching)',
'recommendation' => 'Usually beneficial for builds, but be aware of behavior changes',
],
];
}
/**
* Analyze an environment variable for potential build issues.
* Always returns a warning if the key is in our list, regardless of value.
*/
public static function analyzeBuildVariable(string $key, string $value): ?array
{
$problematicVars = self::getProblematicBuildVariables();
// Direct key match
if (isset($problematicVars[$key])) {
$config = $problematicVars[$key];
// Check if it has a custom check function
if (isset($config['check_function'])) {
$method = $config['check_function'];
if (method_exists(self::class, $method)) {
return self::{$method}($key, $value, $config);
}
}
// Always return warning for known problematic variables
return [
'variable' => $key,
'value' => $value,
'affects' => $config['affects'],
'issue' => $config['issue'],
'recommendation' => $config['recommendation'],
];
}
return null;
}
/**
* Analyze multiple environment variables for potential build issues.
*/
public static function analyzeBuildVariables(array $variables): array
{
$warnings = [];
foreach ($variables as $key => $value) {
$warning = self::analyzeBuildVariable($key, $value);
if ($warning) {
$warnings[] = $warning;
}
}
return $warnings;
}
/**
* Custom check for Django settings module.
*/
protected static function checkDjangoSettings(string $key, string $value, array $config): ?array
{
// Always return warning for DJANGO_SETTINGS_MODULE when it's set as build-time
return [
'variable' => $key,
'value' => $value,
'affects' => $config['affects'],
'issue' => $config['issue'],
'recommendation' => $config['recommendation'],
];
}
/**
* Generate a formatted warning message for deployment logs.
*/
public static function formatBuildWarning(array $warning): array
{
$messages = [
"⚠️ Build-time environment variable warning: {$warning['variable']}={$warning['value']}",
" Affects: {$warning['affects']}",
" Issue: {$warning['issue']}",
" Recommendation: {$warning['recommendation']}",
];
return $messages;
}
/**
* Check if a variable should show a warning in the UI.
*/
public static function shouldShowBuildWarning(string $key): bool
{
return isset(self::getProblematicBuildVariables()[$key]);
}
/**
* Get UI warning message for a specific variable.
*/
public static function getUIWarningMessage(string $key): ?string
{
$problematicVars = self::getProblematicBuildVariables();
if (! isset($problematicVars[$key])) {
return null;
}
$config = $problematicVars[$key];
$problematicValuesStr = implode(', ', $config['problematic_values']);
return "Setting {$key} to {$problematicValuesStr} as a build-time variable may cause issues. {$config['issue']} Consider: {$config['recommendation']}";
}
/**
* Get problematic variables configuration for frontend use.
*/
public static function getProblematicVariablesForFrontend(): array
{
$vars = self::getProblematicBuildVariables();
$result = [];
foreach ($vars as $key => $config) {
// Skip the check_function as it's PHP-specific
$result[$key] = [
'problematic_values' => $config['problematic_values'],
'affects' => $config['affects'],
'issue' => $config['issue'],
'recommendation' => $config['recommendation'],
];
}
return $result;
}
}

View file

@ -17,6 +17,46 @@ trait ExecuteRemoteCommand
public static int $batch_counter = 0; public static int $batch_counter = 0;
private function redact_sensitive_info($text)
{
$text = remove_iip($text);
if (! isset($this->application)) {
return $text;
}
$lockedVars = collect([]);
if (isset($this->application->environment_variables)) {
$lockedVars = $lockedVars->merge(
$this->application->environment_variables
->where('is_shown_once', true)
->pluck('real_value', 'key')
->filter()
);
}
if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) {
$lockedVars = $lockedVars->merge(
$this->application->environment_variables_preview
->where('is_shown_once', true)
->pluck('real_value', 'key')
->filter()
);
}
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
'/'.$escapedValue.'/',
REDACTED,
$text
);
}
return $text;
}
public function execute_remote_command(...$commands) public function execute_remote_command(...$commands)
{ {
static::$batch_counter++; static::$batch_counter++;
@ -46,6 +86,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'); $maxRetries = config('constants.ssh.max_retries');
$attempt = 0; $attempt = 0;
$lastError = null; $lastError = null;
@ -66,13 +114,19 @@ public function execute_remote_command(...$commands)
// Track SSH retry event in Sentry // Track SSH retry event in Sentry
$this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [ $this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [
'server' => $this->server->name ?? $this->server->ip ?? 'unknown', 'server' => $this->server->name ?? $this->server->ip ?? 'unknown',
'command' => remove_iip($command), 'command' => $this->redact_sensitive_info($command),
'trait' => 'ExecuteRemoteCommand', 'trait' => 'ExecuteRemoteCommand',
]); ]);
// Add log entry for the retry // Add log entry for the retry
if (isset($this->application_deployment_queue)) { if (isset($this->application_deployment_queue)) {
$this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage); $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); sleep($delay);
@ -85,6 +139,11 @@ public function execute_remote_command(...$commands)
// If we exhausted all retries and still failed // If we exhausted all retries and still failed
if (! $commandExecuted && $lastError) { 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; throw $lastError;
} }
}); });
@ -96,7 +155,7 @@ public function execute_remote_command(...$commands)
private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors) private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors)
{ {
$remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command); $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
$process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) { $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) {
$output = str($output)->trim(); $output = str($output)->trim();
if ($output->startsWith('╔')) { if ($output->startsWith('╔')) {
$output = "\n".$output; $output = "\n".$output;
@ -106,8 +165,8 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
$sanitized_output = sanitize_utf8_text($output); $sanitized_output = sanitize_utf8_text($output);
$new_log_entry = [ $new_log_entry = [
'command' => remove_iip($command), 'command' => $this->redact_sensitive_info($command),
'output' => remove_iip($sanitized_output), 'output' => $this->redact_sensitive_info($sanitized_output),
'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout', 'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
'timestamp' => Carbon::now('UTC'), 'timestamp' => Carbon::now('UTC'),
'hidden' => $hidden, 'hidden' => $hidden,
@ -143,13 +202,13 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
if ($this->save) { if ($this->save) {
if (data_get($this->saved_outputs, $this->save, null) === null) { if (data_get($this->saved_outputs, $this->save, null) === null) {
data_set($this->saved_outputs, $this->save, str()); $this->saved_outputs->put($this->save, str());
} }
if ($append) { if ($append) {
$this->saved_outputs[$this->save] .= str($sanitized_output)->trim(); $current_value = $this->saved_outputs->get($this->save);
$this->saved_outputs[$this->save] = str($this->saved_outputs[$this->save]); $this->saved_outputs->put($this->save, str($current_value.str($sanitized_output)->trim()));
} else { } else {
$this->saved_outputs[$this->save] = str($sanitized_output)->trim(); $this->saved_outputs->put($this->save, str($sanitized_output)->trim());
} }
} }
}); });
@ -160,8 +219,8 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
$process_result = $process->wait(); $process_result = $process->wait();
if ($process_result->exitCode() !== 0) { if ($process_result->exitCode() !== 0) {
if (! $ignore_errors) { if (! $ignore_errors) {
$this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value; // Don't immediately set to FAILED - let the retry logic handle it
$this->application_deployment_queue->save(); // This prevents premature status changes during retryable SSH errors
throw new \RuntimeException($process_result->errorOutput()); throw new \RuntimeException($process_result->errorOutput());
} }
} }
@ -175,7 +234,7 @@ private function addRetryLogEntry(int $attempt, int $maxRetries, int $delay, str
$retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}"; $retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}";
$new_log_entry = [ $new_log_entry = [
'output' => remove_iip($retryMessage), 'output' => $this->redact_sensitive_info($retryMessage),
'type' => 'stdout', 'type' => 'stdout',
'timestamp' => Carbon::now('UTC'), 'timestamp' => Carbon::now('UTC'),
'hidden' => false, 'hidden' => false,
@ -210,4 +269,4 @@ private function addRetryLogEntry(int $attempt, int $maxRetries, int $delay, str
$this->application_deployment_queue->save(); $this->application_deployment_queue->save();
} }
} }

View file

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

View file

@ -135,7 +135,13 @@ function getPermissionsPath(GithubApp $source)
function loadRepositoryByPage(GithubApp $source, string $token, int $page) function loadRepositoryByPage(GithubApp $source, string $token, int $page)
{ {
$response = Http::withToken($token)->get("{$source->api_url}/installation/repositories?per_page=100&page={$page}"); $response = Http::GitHub($source->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get('/installation/repositories', [
'per_page' => 100,
'page' => $page,
]);
$json = $response->json(); $json = $response->json();
if ($response->status() !== 200) { if ($response->status() !== 200) {
return [ return [

View file

@ -385,21 +385,34 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'is_preview' => false, 'is_preview' => false,
]); ]);
if ($resource->build_pack === 'dockercompose') { if ($resource->build_pack === 'dockercompose') {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); // Check if a service with this name actually exists
$domainExists = data_get($domains->get($fqdnFor), 'domain'); $serviceExists = false;
$envExists = $resource->environment_variables()->where('key', $key->value())->first(); foreach ($services as $serviceName => $service) {
if (str($domainExists)->replace('http://', '')->replace('https://', '')->value() !== $envExists->value) { $transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
$envExists->update([ if ($transformedServiceName === $fqdnFor) {
'value' => $url, $serviceExists = true;
]); break;
}
} }
if (is_null($domainExists)) {
// Put URL in the domains array instead of FQDN // Only add domain if the service exists
$domains->put((string) $fqdnFor, [ if ($serviceExists) {
'domain' => $url, $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
]); $domainExists = data_get($domains->get($fqdnFor), 'domain');
$resource->docker_compose_domains = $domains->toJson(); $envExists = $resource->environment_variables()->where('key', $key->value())->first();
$resource->save(); if (str($domainExists)->replace('http://', '')->replace('https://', '')->value() !== $envExists->value) {
$envExists->update([
'value' => $url,
]);
}
if (is_null($domainExists)) {
// Put URL in the domains array instead of FQDN
$domains->put((string) $fqdnFor, [
'domain' => $url,
]);
$resource->docker_compose_domains = $domains->toJson();
$resource->save();
}
} }
} }
} elseif ($command->value() === 'URL') { } elseif ($command->value() === 'URL') {
@ -418,20 +431,33 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'is_preview' => false, 'is_preview' => false,
]); ]);
if ($resource->build_pack === 'dockercompose') { if ($resource->build_pack === 'dockercompose') {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); // Check if a service with this name actually exists
$domainExists = data_get($domains->get($urlFor), 'domain'); $serviceExists = false;
$envExists = $resource->environment_variables()->where('key', $key->value())->first(); foreach ($services as $serviceName => $service) {
if ($domainExists !== $envExists->value) { $transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
$envExists->update([ if ($transformedServiceName === $urlFor) {
'value' => $url, $serviceExists = true;
]); break;
}
} }
if (is_null($domainExists)) {
$domains->put((string) $urlFor, [ // Only add domain if the service exists
'domain' => $url, if ($serviceExists) {
]); $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
$resource->docker_compose_domains = $domains->toJson(); $domainExists = data_get($domains->get($urlFor), 'domain');
$resource->save(); $envExists = $resource->environment_variables()->where('key', $key->value())->first();
if ($domainExists !== $envExists->value) {
$envExists->update([
'value' => $url,
]);
}
if (is_null($domainExists)) {
$domains->put((string) $urlFor, [
'domain' => $url,
]);
$resource->docker_compose_domains = $domains->toJson();
$resource->save();
}
} }
} }
} else { } else {
@ -910,7 +936,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$preview = $resource->previews()->find($preview_id); $preview = $resource->previews()->find($preview_id);
$docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))); $docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains')));
if ($docker_compose_domains->count() > 0) { if ($docker_compose_domains->count() > 0) {
$found_fqdn = data_get($docker_compose_domains, "$serviceName.domain"); $found_fqdn = data_get($docker_compose_domains, "$changedServiceName.domain");
if ($found_fqdn) { if ($found_fqdn) {
$fqdns = collect($found_fqdn); $fqdns = collect($found_fqdn);
} else { } else {

View file

@ -29,31 +29,11 @@ function remote_process(
$type = $type ?? ActivityTypes::INLINE->value; $type = $type ?? ActivityTypes::INLINE->value;
$command = $command instanceof Collection ? $command->toArray() : $command; $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()) { 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()) { if (Auth::check()) {
$teams = Auth::user()->teams->pluck('id'); $teams = Auth::user()->teams->pluck('id');
@ -104,64 +84,6 @@ function () use ($source, $dest, $server) {
); );
} }
function transfer_file_to_container(string $content, string $container_path, string $deployment_uuid, Server $server, bool $throwError = true): ?string
{
$temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_');
try {
// Write content to temporary file
file_put_contents($temp_file, $content);
// Generate unique filename for server transfer
$server_temp_file = '/tmp/coolify_env_'.uniqid().'_'.$deployment_uuid;
// Transfer file to server
instant_scp($temp_file, $server_temp_file, $server, $throwError);
// Ensure parent directory exists in container, then copy file
$parent_dir = dirname($container_path);
$commands = [];
if ($parent_dir !== '.' && $parent_dir !== '/') {
$commands[] = executeInDocker($deployment_uuid, "mkdir -p \"$parent_dir\"");
}
$commands[] = "docker cp $server_temp_file $deployment_uuid:$container_path";
$commands[] = "rm -f $server_temp_file"; // Cleanup server temp file
return instant_remote_process_with_timeout($commands, $server, $throwError);
} finally {
// Always cleanup local temp file
if (file_exists($temp_file)) {
unlink($temp_file);
}
}
}
function transfer_file_to_server(string $content, string $server_path, Server $server, bool $throwError = true): ?string
{
$temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_');
try {
// Write content to temporary file
file_put_contents($temp_file, $content);
// Ensure parent directory exists on server
$parent_dir = dirname($server_path);
if ($parent_dir !== '.' && $parent_dir !== '/') {
instant_remote_process_with_timeout(["mkdir -p \"$parent_dir\""], $server, $throwError);
}
// Transfer file directly to server destination
return instant_scp($temp_file, $server_path, $server, $throwError);
} finally {
// Always cleanup local temp file
if (file_exists($temp_file)) {
unlink($temp_file);
}
}
}
function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
{ {
$command = $command instanceof Collection ? $command->toArray() : $command; $command = $command instanceof Collection ? $command->toArray() : $command;
@ -200,30 +122,10 @@ function instant_remote_process(Collection|array $command, Server $server, bool
{ {
$command = $command instanceof Collection ? $command->toArray() : $command; $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) { 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( return \App\Helpers\SshRetryHandler::retry(
function () use ($server, $command_string) { function () use ($server, $command_string) {

View file

@ -69,11 +69,12 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
$fileVolume->content = $content; $fileVolume->content = $content;
$fileVolume->is_directory = false; $fileVolume->is_directory = false;
$fileVolume->save(); $fileVolume->save();
$content = base64_encode($content);
$dir = str($fileLocation)->dirname(); $dir = str($fileLocation)->dirname();
instant_remote_process([ instant_remote_process([
"mkdir -p $dir", "mkdir -p $dir",
"echo '$content' | base64 -d | tee $fileLocation",
], $server); ], $server);
transfer_file_to_server($content, $fileLocation, $server);
} elseif ($isFile === 'NOK' && $isDir === 'NOK' && $fileVolume->is_directory && $isInit) { } elseif ($isFile === 'NOK' && $isDir === 'NOK' && $fileVolume->is_directory && $isInit) {
// Does not exists (no dir or file), flagged as directory, is init // Does not exists (no dir or file), flagged as directory, is init
$fileVolume->content = null; $fileVolume->content = null;

View file

@ -634,10 +634,14 @@ function getTopLevelNetworks(Service|Application $resource)
$definedNetwork = collect([$resource->uuid]); $definedNetwork = collect([$resource->uuid]);
$services = collect($services)->map(function ($service, $_) use ($topLevelNetworks, $definedNetwork) { $services = collect($services)->map(function ($service, $_) use ($topLevelNetworks, $definedNetwork) {
$serviceNetworks = collect(data_get($service, 'networks', [])); $serviceNetworks = collect(data_get($service, 'networks', []));
$hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false; $networkMode = data_get($service, 'network_mode');
// Only add 'networks' key if 'network_mode' is not 'host' $hasValidNetworkMode =
if (! $hasHostNetworkMode) { $networkMode === 'host' ||
(is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:')));
// Only add 'networks' key if 'network_mode' is not 'host' or does not start with 'service:' or 'container:'
if (! $hasValidNetworkMode) {
// Collect/create/update networks // Collect/create/update networks
if ($serviceNetworks->count() > 0) { if ($serviceNetworks->count() > 0) {
foreach ($serviceNetworks as $networkName => $networkDetails) { foreach ($serviceNetworks as $networkName => $networkDetails) {
@ -1125,30 +1129,77 @@ function get_public_ips()
function isAnyDeploymentInprogress() function isAnyDeploymentInprogress()
{ {
$runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get(); $runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get();
$basicDetails = $runningJobs->map(function ($job) {
return [ if ($runningJobs->isEmpty()) {
'id' => $job->id, echo "No deployments in progress.\n";
'created_at' => $job->created_at, exit(0);
'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";
$horizonJobIds = []; $horizonJobIds = [];
$deploymentDetails = [];
foreach ($runningJobs as $runningJob) { foreach ($runningJobs as $runningJob) {
$horizonJobStatus = getJobStatus($runningJob->horizon_job_id); $horizonJobStatus = getJobStatus($runningJob->horizon_job_id);
if ($horizonJobStatus === 'unknown' || $horizonJobStatus === 'reserved') { if ($horizonJobStatus === 'unknown' || $horizonJobStatus === 'reserved') {
$horizonJobIds[] = $runningJob->horizon_job_id; $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) { if (count($horizonJobIds) === 0) {
echo "No deployments in progress.\n"; echo "No active deployments in progress (all jobs completed or failed).\n";
exit(0); 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); exit(1);
} }
@ -1225,7 +1276,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$serviceNetworks = collect(data_get($service, 'networks', [])); $serviceNetworks = collect(data_get($service, 'networks', []));
$serviceVariables = collect(data_get($service, 'environment', [])); $serviceVariables = collect(data_get($service, 'environment', []));
$serviceLabels = collect(data_get($service, 'labels', [])); $serviceLabels = collect(data_get($service, 'labels', []));
$hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false; $networkMode = data_get($service, 'network_mode');
$hasValidNetworkMode =
$networkMode === 'host' ||
(is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:')));
if ($serviceLabels->count() > 0) { if ($serviceLabels->count() > 0) {
$removedLabels = collect([]); $removedLabels = collect([]);
$serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) { $serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) {
@ -1336,7 +1392,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$savedService->ports = $collectedPorts->implode(','); $savedService->ports = $collectedPorts->implode(',');
$savedService->save(); $savedService->save();
if (! $hasHostNetworkMode) { if (! $hasValidNetworkMode) {
// Add Coolify specific networks // Add Coolify specific networks
$definedNetworkExists = $topLevelNetworks->contains(function ($value, $_) use ($definedNetwork) { $definedNetworkExists = $topLevelNetworks->contains(function ($value, $_) use ($definedNetwork) {
return $value == $definedNetwork; return $value == $definedNetwork;

View file

@ -62,6 +62,7 @@
"barryvdh/laravel-debugbar": "^3.15.4", "barryvdh/laravel-debugbar": "^3.15.4",
"driftingly/rector-laravel": "^2.0.5", "driftingly/rector-laravel": "^2.0.5",
"fakerphp/faker": "^1.24.1", "fakerphp/faker": "^1.24.1",
"laravel/boost": "^1.1",
"laravel/dusk": "^8.3.3", "laravel/dusk": "^8.3.3",
"laravel/pint": "^1.24", "laravel/pint": "^1.24",
"laravel/telescope": "^5.10", "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "a78cf8fdfec25eac43de77c05640dc91", "content-hash": "a993799242581bd06b5939005ee458d9",
"packages": [ "packages": [
{ {
"name": "amphp/amp", "name": "amphp/amp",
@ -12747,6 +12747,71 @@
}, },
"time": "2025-04-30T06:54:44+00:00" "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", "name": "laravel/dusk",
"version": "v8.3.3", "version": "v8.3.3",
@ -12821,6 +12886,70 @@
}, },
"time": "2025-06-10T13:59:27+00:00" "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", "name": "laravel/pint",
"version": "v1.24.0", "version": "v1.24.0",
@ -12890,6 +13019,67 @@
}, },
"time": "2025-07-10T18:09:32+00:00" "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", "name": "laravel/telescope",
"version": "v5.10.2", "version": "v5.10.2",

View file

@ -2,8 +2,8 @@
return [ return [
'coolify' => [ 'coolify' => [
'version' => '4.0.0-beta.427', 'version' => '4.0.0-beta.432',
'helper_version' => '1.0.10', 'helper_version' => '1.0.11',
'realtime_version' => '1.0.10', 'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true), 'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'), 'autoupdate' => env('AUTOUPDATE'),
@ -64,7 +64,7 @@
'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes
'connection_timeout' => 10, 'connection_timeout' => 10,
'server_interval' => 20, 'server_interval' => 20,
'command_timeout' => 7200, 'command_timeout' => 3600,
'max_retries' => env('SSH_MAX_RETRIES', 3), 'max_retries' => env('SSH_MAX_RETRIES', 3),
'retry_base_delay' => env('SSH_RETRY_BASE_DELAY', 2), // seconds 'retry_base_delay' => env('SSH_RETRY_BASE_DELAY', 2), // seconds
'retry_max_delay' => env('SSH_RETRY_MAX_DELAY', 30), // seconds 'retry_max_delay' => env('SSH_RETRY_MAX_DELAY', 30), // seconds

View file

@ -0,0 +1,40 @@
<?php
namespace Database\Factories;
use App\Models\Team;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Team>
*/
class TeamFactory extends Factory
{
protected $model = Team::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => $this->faker->company() . ' Team',
'description' => $this->faker->sentence(),
'personal_team' => false,
'show_boarding' => false,
];
}
/**
* Indicate that the team is a personal team.
*/
public function personal(): static
{
return $this->state(fn (array $attributes) => [
'personal_team' => true,
'name' => $this->faker->firstName() . "'s Team",
]);
}
}

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 # https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
FROM minio/mc:${MINIO_VERSION} AS minio-client FROM minio/mc:${MINIO_VERSION} AS minio-client
FROM ${BASE_IMAGE} AS base FROM ${BASE_IMAGE} AS base

View file

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

View file

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

View file

@ -72,6 +72,7 @@ RUN apk add --no-cache gnupg && \
curl -fSsL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /usr/share/keyrings/postgresql.gpg curl -fSsL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /usr/share/keyrings/postgresql.gpg
# Install system dependencies # Install system dependencies
RUN apk upgrade
RUN apk add --no-cache \ RUN apk add --no-cache \
postgresql${POSTGRES_VERSION}-client \ postgresql${POSTGRES_VERSION}-client \
openssh-client \ openssh-client \

View file

@ -8360,7 +8360,10 @@
"is_preview": { "is_preview": {
"type": "boolean" "type": "boolean"
}, },
"is_buildtime_only": { "is_runtime": {
"type": "boolean"
},
"is_buildtime": {
"type": "boolean" "type": "boolean"
}, },
"is_shared": { "is_shared": {

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