Merge branch 'next' into feat/update-applicationpullrequestupdatejob-documentation

This commit is contained in:
Lucas Reis 2025-10-15 00:59:02 +02:00 committed by GitHub
commit 23250d53c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
264 changed files with 18111 additions and 10189 deletions

156
.AI_INSTRUCTIONS_SYNC.md Normal file
View file

@ -0,0 +1,156 @@
# AI Instructions Synchronization Guide
This document explains how AI instructions are organized and synchronized across different AI tools used with Coolify.
## Overview
Coolify maintains AI instructions in two parallel systems:
1. **CLAUDE.md** - For Claude Code (claude.ai/code)
2. **.cursor/rules/** - For Cursor IDE and other AI assistants
Both systems share core principles but are optimized for their respective workflows.
## Structure
### CLAUDE.md
- **Purpose**: Condensed, workflow-focused guide for Claude Code
- **Format**: Single markdown file
- **Includes**:
- Quick-reference development commands
- High-level architecture overview
- Core patterns and guidelines
- Embedded Laravel Boost guidelines
- References to detailed .cursor/rules/ documentation
### .cursor/rules/
- **Purpose**: Detailed, topic-specific documentation
- **Format**: Multiple .mdc files organized by topic
- **Structure**:
- `README.mdc` - Main index and overview
- `cursor_rules.mdc` - Maintenance guidelines
- Topic-specific files (testing-patterns.mdc, security-patterns.mdc, etc.)
- **Used by**: Cursor IDE, Claude Code (for detailed reference), other AI assistants
## Cross-References
Both systems reference each other:
- **CLAUDE.md** → references `.cursor/rules/` for detailed documentation
- **.cursor/rules/README.mdc** → references `CLAUDE.md` for Claude Code workflow
- **.cursor/rules/cursor_rules.mdc** → notes that changes should sync with CLAUDE.md
## Maintaining Consistency
When updating AI instructions, follow these guidelines:
### 1. Core Principles (MUST be consistent)
- Laravel version (currently Laravel 12)
- PHP version (8.4)
- Testing execution rules (Docker for Feature tests, mocking for Unit tests)
- Security patterns and authorization requirements
- Code style requirements (Pint, PSR-12)
### 2. Where to Make Changes
**For workflow changes** (how to run commands, development setup):
- Primary: `CLAUDE.md`
- Secondary: `.cursor/rules/development-workflow.mdc`
**For architectural patterns** (how code should be structured):
- Primary: `.cursor/rules/` topic files
- Secondary: Reference in `CLAUDE.md` "Additional Documentation" section
**For testing patterns**:
- Both: Must be synchronized
- `CLAUDE.md` - Contains condensed testing execution rules
- `.cursor/rules/testing-patterns.mdc` - Contains detailed examples and patterns
### 3. Update Checklist
When making significant changes:
- [ ] Identify if change affects core principles (version numbers, critical patterns)
- [ ] Update primary location (CLAUDE.md or .cursor/rules/)
- [ ] Check if update affects cross-referenced content
- [ ] Update secondary location if needed
- [ ] Verify cross-references are still accurate
- [ ] Run: `./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc` (if applicable)
### 4. Common Inconsistencies to Watch
- **Version numbers**: Laravel, PHP, package versions
- **Testing instructions**: Docker execution requirements
- **File paths**: Ensure relative paths work from root
- **Command syntax**: Docker commands, artisan commands
- **Architecture decisions**: Laravel 10 structure vs Laravel 12+ structure
## File Organization
```
/
├── CLAUDE.md # Claude Code instructions (condensed)
├── .AI_INSTRUCTIONS_SYNC.md # This file
└── .cursor/
└── rules/
├── README.mdc # Index and overview
├── cursor_rules.mdc # Maintenance guide
├── testing-patterns.mdc # Testing details
├── development-workflow.mdc # Dev setup details
├── security-patterns.mdc # Security details
├── application-architecture.mdc
├── deployment-architecture.mdc
├── database-patterns.mdc
├── frontend-patterns.mdc
├── api-and-routing.mdc
├── form-components.mdc
├── technology-stack.mdc
├── project-overview.mdc
└── laravel-boost.mdc # Laravel-specific patterns
```
## Recent Updates
### 2025-10-07
- ✅ Added cross-references between CLAUDE.md and .cursor/rules/
- ✅ Synchronized Laravel version (12) across all files
- ✅ Added comprehensive testing execution rules (Docker for Feature tests)
- ✅ Added test design philosophy (prefer mocking over database)
- ✅ Fixed inconsistencies in testing documentation
- ✅ Created this synchronization guide
## Maintenance Commands
```bash
# Check for version inconsistencies
grep -r "Laravel [0-9]" CLAUDE.md .cursor/rules/*.mdc
# Check for PHP version consistency
grep -r "PHP [0-9]" CLAUDE.md .cursor/rules/*.mdc
# Format all documentation
./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc
# Search for specific patterns across all docs
grep -r "pattern_to_check" CLAUDE.md .cursor/rules/
```
## Contributing
When contributing documentation:
1. Check both CLAUDE.md and .cursor/rules/ for existing documentation
2. Add to appropriate location(s) based on guidelines above
3. Add cross-references if creating new patterns
4. Update this file if changing organizational structure
5. Verify consistency before submitting PR
## Questions?
If unsure about where to document something:
- **Quick reference / workflow** → CLAUDE.md
- **Detailed patterns / examples** → .cursor/rules/[topic].mdc
- **Both?** → Start with .cursor/rules/, then reference in CLAUDE.md
When in doubt, prefer detailed documentation in .cursor/rules/ and concise references in CLAUDE.md.

View file

@ -9,6 +9,10 @@ alwaysApply: false
This comprehensive set of Cursor Rules provides deep insights into **Coolify**, an open-source self-hostable alternative to Heroku/Netlify/Vercel. These rules will help you understand, navigate, and contribute to this complex Laravel-based deployment platform.
> **Cross-Reference**: This directory is for **detailed, topic-specific rules** used by Cursor IDE and other AI assistants. For Claude Code specifically, also see **[CLAUDE.md](mdc:CLAUDE.md)** which provides a condensed, workflow-focused guide. Both systems share core principles but are optimized for their respective tools.
>
> **Maintaining Rules**: When updating these rules, see **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for synchronization guidelines to keep CLAUDE.md and .cursor/rules/ consistent.
## Rule Categories
### 🏗️ Architecture & Foundation
@ -71,7 +75,7 @@ Coolify uses a **team-based multi-tenancy** model where:
- **Multi-server** support with SSH connections
### 3. Technology Stack
- **Backend**: Laravel 11 + PHP 8.4
- **Backend**: Laravel 12 + PHP 8.4
- **Frontend**: Livewire 3.5 + Alpine.js + Tailwind CSS 4.1
- **Database**: PostgreSQL 15 + Redis 7
- **Containerization**: Docker + Docker Compose

View file

@ -4,6 +4,12 @@ globs: .cursor/rules/*.mdc
alwaysApply: true
---
# Cursor Rules Maintenance Guide
> **Important**: These rules in `.cursor/rules/` are shared between Cursor IDE and other AI assistants. Changes here should be reflected in **[CLAUDE.md](mdc:CLAUDE.md)** when they affect core workflows or patterns.
>
> **Synchronization Guide**: See **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for detailed guidelines on maintaining consistency between CLAUDE.md and .cursor/rules/.
- **Required Rule Structure:**
```markdown
---

View file

@ -142,6 +142,29 @@ Schema::create('applications', function (Blueprint $table) {
- **Soft deletes** for audit trails
- **Activity logging** with Spatie package
### **CRITICAL: Mass Assignment Protection**
**When adding new database columns, you MUST update the model's `$fillable` array.** Without this, Laravel will silently ignore mass assignment operations like `Model::create()` or `$model->update()`.
**Checklist for new columns:**
1. ✅ Create migration file
2. ✅ Run migration
3. ✅ **Add column to model's `$fillable` array**
4. ✅ Update any Livewire components that sync this property
5. ✅ Test that the column can be read and written
**Example:**
```php
class Server extends BaseModel
{
protected $fillable = [
'name',
'ip',
'port',
'is_validating', // ← MUST add new columns here
];
}
```
### Relationship Patterns
```php
// Typical relationship structure in Application model

View file

@ -185,7 +185,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.

View file

@ -5,11 +5,56 @@ alwaysApply: false
---
# Coolify Testing Architecture & Patterns
> **Cross-Reference**: These detailed testing patterns align with the testing guidelines in **[CLAUDE.md](mdc:CLAUDE.md)**. Both documents share the same core principles about Docker execution and mocking preferences.
## Testing Philosophy
Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions.
!Important: Always run tests inside `coolify` container.
### Test Execution Rules
**CRITICAL**: Tests are categorized by database dependency:
#### Unit Tests (`tests/Unit/`)
- **MUST NOT** use database connections
- **MUST** use mocking for models and external dependencies
- **CAN** run outside Docker: `./vendor/bin/pest tests/Unit`
- Purpose: Test isolated logic, helper functions, and business rules
#### Feature Tests (`tests/Feature/`)
- **MAY** use database connections (factories, migrations, models)
- **MUST** run inside Docker container: `docker exec coolify php artisan test`
- **MUST** use `RefreshDatabase` trait if touching database
- Purpose: Test API endpoints, workflows, and integration scenarios
**Rule of thumb**: If your test needs `Server::factory()->create()` or any database operation, it's a Feature test and MUST run in Docker.
### Prefer Mocking Over Database
When writing tests, always prefer mocking over real database operations:
```php
// ❌ BAD: Unit test using database
it('extracts custom commands', function () {
$server = Server::factory()->create(['ip' => '1.2.3.4']);
$commands = extract_custom_proxy_commands($server, $yaml);
expect($commands)->toBeArray();
});
// ✅ GOOD: Unit test using mocking
it('extracts custom commands', function () {
$server = Mockery::mock('App\Models\Server');
$server->shouldReceive('proxyType')->andReturn('traefik');
$commands = extract_custom_proxy_commands($server, $yaml);
expect($commands)->toBeArray();
});
```
**Design principles for testable code:**
- Use dependency injection instead of global state
- Create interfaces for external dependencies (SSH, Docker, etc.)
- Separate business logic from data persistence
- Make functions accept interfaces instead of concrete models when possible
## Testing Framework Stack

View file

@ -34,13 +34,15 @@ jobs:
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Direct prompt for automated review (no @claude mention needed)
direct_prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
@ -48,11 +50,30 @@ jobs:
- Security concerns
- Test coverage
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
# use_sticky_comment: true
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
# Optional: Customize review based on file types
# direct_prompt: |
# Review this PR focusing on:
# - For TypeScript files: Type safety and proper interface usage
# - For API endpoints: Security, input validation, and error handling
# - For React components: Performance, accessibility, and best practices
# - For tests: Coverage, edge cases, and test quality
# Optional: Different prompts for different authors
# direct_prompt: |
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
# Optional: Add specific tools for running tests or linting
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
# Optional: Skip review for certain conditions
# if: |
# !contains(github.event.pull_request.title, '[skip-review]') &&
# !contains(github.event.pull_request.title, '[WIP]')

View file

@ -16,6 +16,8 @@ jobs:
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'Claude') ||
(github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'Claude') ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
@ -34,22 +36,30 @@ jobs:
id: claude
uses: anthropics/claude-code-action@v1
with:
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
anthropic_api_key: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test

View file

@ -28,6 +28,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
@ -50,8 +57,8 @@ jobs:
platforms: linux/amd64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
aarch64:
runs-on: [self-hosted, arm64]
@ -61,6 +68,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
@ -83,8 +97,8 @@ jobs:
platforms: linux/aarch64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64
merge-manifest:
runs-on: ubuntu-latest
@ -95,6 +109,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
@ -114,14 +135,14 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- uses: sarisia/actions-status-discord@v1
if: always()

12958
CHANGELOG.md

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,10 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to **Claude Code** (claude.ai/code) when working with code in this repository.
> **Note for AI Assistants**: This file is specifically for Claude Code. If you're using Cursor IDE, refer to the `.cursor/rules/` directory for detailed rule files. Both systems share core principles but are optimized for their respective workflows.
>
> **Maintaining Instructions**: When updating AI instructions, see [.AI_INSTRUCTIONS_SYNC.md](.AI_INSTRUCTIONS_SYNC.md) for synchronization guidelines between CLAUDE.md and .cursor/rules/.
## Project Overview
@ -23,7 +27,14 @@ ### Backend Development
### Code Quality
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
- `./vendor/bin/pest` - Run Pest tests
- `./vendor/bin/pest` - Run Pest tests (unit tests only, without database)
### Running Tests
**IMPORTANT**: Tests that require database connections MUST be run inside the Docker container:
- **Inside Docker**: `docker exec coolify php artisan test` (for feature tests requiring database)
- **Outside Docker**: `./vendor/bin/pest tests/Unit` (for pure unit tests without database dependencies)
- Unit tests should use mocking and avoid database connections
- Feature tests that require database must be run in the `coolify` container
## Architecture Overview
@ -149,6 +160,7 @@ ### Database Patterns
- Use database transactions for critical operations
- Leverage query scopes for reusable queries
- Apply indexes for performance-critical queries
- **CRITICAL**: When adding new database columns, ALWAYS update the model's `$fillable` array to allow mass assignment
### Security Best Practices
- **Authentication**: Multi-provider auth via Laravel Fortify & Sanctum
@ -173,6 +185,21 @@ ### Testing Strategy
- **Mocking**: Use Laravel's built-in mocking for external services
- **Database**: Use RefreshDatabase trait for test isolation
#### Test Execution Environment
**CRITICAL**: Database-dependent tests MUST run inside Docker container:
- **Unit Tests** (`tests/Unit/`): Should NOT use database. Use mocking. Run with `./vendor/bin/pest tests/Unit`
- **Feature Tests** (`tests/Feature/`): May use database. MUST run inside Docker with `docker exec coolify php artisan test`
- If a test needs database (factories, migrations, etc.), it belongs in `tests/Feature/`
- Always mock external services and SSH connections in tests
#### Test Design Philosophy
**PREFER MOCKING**: When designing features and writing tests:
- **Design for testability**: Structure code so it can be tested without database (use dependency injection, interfaces)
- **Mock by default**: Unit tests should mock models and external dependencies using Mockery
- **Avoid database when possible**: If you can test the logic without database, write it as a Unit test
- **Only use database when necessary**: Feature tests should test integration points, not isolated logic
- **Example**: Instead of `Server::factory()->create()`, use `Mockery::mock('App\Models\Server')` in unit tests
### Routing Conventions
- Group routes by middleware and prefix
- Use route model binding for cleaner controllers
@ -228,7 +255,9 @@ ## Important Reminders
## Additional Documentation
For more detailed guidelines and patterns, refer to the `.cursor/rules/` directory:
This file contains high-level guidelines for Claude Code. For **more detailed, topic-specific documentation**, refer to the `.cursor/rules/` directory (also accessible by Cursor IDE and other AI assistants):
> **Cross-Reference**: The `.cursor/rules/` directory contains comprehensive, detailed documentation organized by topic. Start with [.cursor/rules/README.mdc](.cursor/rules/README.mdc) for an overview, then explore specific topics below.
### Architecture & Patterns
- [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure
@ -434,7 +463,7 @@ ### Laravel 10 Structure
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
@ -543,6 +572,10 @@ ### Pest Tests
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- **Unit tests** MUST use mocking and avoid database. They can run outside Docker.
- **Feature tests** can use database but MUST run inside Docker container.
- **Design for testability**: Structure code to be testable without database when possible. Use dependency injection and interfaces.
- **Mock by default**: Prefer `Mockery::mock()` over `Model::factory()->create()` in unit tests.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
@ -551,11 +584,23 @@ ### Pest Tests
</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.
**IMPORTANT**: Always run tests in the correct environment based on database dependencies:
**Unit Tests (no database):**
- Run outside Docker: `./vendor/bin/pest tests/Unit`
- Run specific file: `./vendor/bin/pest tests/Unit/ProxyCustomCommandsTest.php`
- These tests use mocking and don't require PostgreSQL
**Feature Tests (with database):**
- Run inside Docker: `docker exec coolify php artisan test`
- Run specific file: `docker exec coolify php artisan test tests/Feature/ExampleTest.php`
- Filter by name: `docker exec coolify php artisan test --filter=testName`
- These tests require PostgreSQL and use factories/migrations
**General Guidelines:**
- Run the minimal number of tests using an appropriate filter before finalizing code edits
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite
- If you get database connection errors, you're running a Feature test outside Docker - move it inside
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
@ -650,7 +695,12 @@ ### Replaced Utilities
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
- Run the minimum number of tests needed to ensure code quality and speed.
- **For Unit tests**: Use `./vendor/bin/pest tests/Unit/YourTest.php` (runs outside Docker)
- **For Feature tests**: Use `docker exec coolify php artisan test --filter=YourTest` (runs inside Docker)
- Choose the correct test type based on database dependency:
- No database needed? → Unit test with mocking
- Database needed? → Feature test in Docker
</laravel-boost-guidelines>

View file

@ -53,40 +53,40 @@ # Donations
## Big Sponsors
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management
* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions!
* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers
* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
* [Trieve](https://trieve.ai?ref=coolify.io) - AI-powered search and analytics
* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data
* [Darweb](https://darweb.nl/?ref=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
* [COMIT](https://comit.international?ref=coolify.io) - New York Times awardwinning contractor
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
* [WZ-IT](https://wz-it.com/?ref=coolify.io) - German agency for customised cloud solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
* [QuantCDN](https://www.quantcdn.io?ref=coolify.io) - Enterprise-grade content delivery network
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
* [Cloudify.ro](https://cloudify.ro?ref=coolify.io) - Cloud hosting solutions
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services
* [MassiveGrid](https://massivegrid.com?ref=coolify.io) - Enterprise cloud hosting solutions
* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers
* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [COMIT](https://comit.international?ref=coolify.io) - New York Times awardwinning contractor
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [Darweb](https://darweb.nl/?ref=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
* [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers
* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase
* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data
* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform
* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
## Small Sponsors

View file

@ -105,6 +105,8 @@ public function handle(StandaloneClickhouse $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -55,11 +55,11 @@ public function handle(StandaloneDragonfly $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -192,6 +192,8 @@ public function handle(StandaloneDragonfly $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -56,11 +56,11 @@ public function handle(StandaloneKeydb $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -208,6 +208,8 @@ public function handle(StandaloneKeydb $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -57,11 +57,11 @@ public function handle(StandaloneMariadb $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -209,6 +209,8 @@ public function handle(StandaloneMariadb $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
if ($this->database->enable_ssl) {

View file

@ -61,11 +61,11 @@ public function handle(StandaloneMongodb $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -260,6 +260,8 @@ public function handle(StandaloneMongodb $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, 'chown mongodb:mongodb /etc/mongo/certs/server.pem');

View file

@ -57,11 +57,11 @@ public function handle(StandaloneMysql $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -210,6 +210,8 @@ public function handle(StandaloneMysql $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {

View file

@ -62,11 +62,11 @@ public function handle(StandalonePostgresql $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -223,6 +223,8 @@ public function handle(StandalonePostgresql $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");

View file

@ -56,11 +56,11 @@ public function handle(StandaloneRedis $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -205,6 +205,8 @@ public function handle(StandaloneRedis $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -49,7 +49,7 @@ private function stopContainer($database, string $containerName, int $timeout =
{
$server = $database->destination->server;
instant_remote_process(command: [
"docker stop --time=$timeout $containerName",
"docker stop --timeout=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}

View file

@ -33,7 +33,13 @@ public function handle(Server $server, bool $forceRegenerate = false): string
// 1. Force regenerate is requested
// 2. Configuration file doesn't exist or is empty
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
$proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value();
// Extract custom commands from existing config before regenerating
$custom_commands = [];
if (! empty(trim($proxy_configuration ?? ''))) {
$custom_commands = extractCustomProxyCommands($server, $proxy_configuration);
}
$proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value();
}
if (empty($proxy_configuration)) {

View file

@ -19,6 +19,11 @@ public function handle(Server $server, bool $async = true, bool $force = false):
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) {
return 'OK';
}
$server->proxy->set('status', 'starting');
$server->save();
$server->refresh();
ProxyStatusChangedUI::dispatch($server->team_id);
$commands = collect([]);
$proxy_path = $server->proxyPath();
$configuration = GetProxyConfiguration::run($server);
@ -64,14 +69,12 @@ public function handle(Server $server, bool $async = true, bool $force = false):
]);
$commands = $commands->merge(connectProxyToNetworks($server));
}
$server->proxy->set('status', 'starting');
$server->save();
ProxyStatusChangedUI::dispatch($server->team_id);
if ($async) {
return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id);
} else {
instant_remote_process($commands, $server);
$server->proxy->set('type', $proxyType);
$server->save();
ProxyStatusChanged::dispatch($server->id);

View file

@ -2,16 +2,102 @@
namespace App\Actions\Server;
use App\Models\CloudProviderToken;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Server\HetznerDeletionFailed;
use App\Services\HetznerService;
use Lorisleiva\Actions\Concerns\AsAction;
class DeleteServer
{
use AsAction;
public function handle(Server $server)
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null)
{
StopSentinel::run($server);
$server->forceDelete();
$server = Server::withTrashed()->find($serverId);
// Delete from Hetzner even if server is already gone from Coolify
if ($deleteFromHetzner && ($hetznerServerId || ($server && $server->hetzner_server_id))) {
$this->deleteFromHetznerById(
$hetznerServerId ?? $server->hetzner_server_id,
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
$teamId ?? $server->team_id
);
}
ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
// If server is already deleted from Coolify, skip this part
if (! $server) {
return; // Server already force deleted from Coolify
}
ray('force deleting server from Coolify', ['server_id' => $server->id]);
try {
$server->forceDelete();
} catch (\Throwable $e) {
ray('Failed to force delete server from Coolify', [
'error' => $e->getMessage(),
'server_id' => $server->id,
]);
logger()->error('Failed to force delete server from Coolify', [
'error' => $e->getMessage(),
'server_id' => $server->id,
]);
}
}
private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProviderTokenId, int $teamId): void
{
try {
// Use the provided token, or fallback to first available team token
$token = null;
if ($cloudProviderTokenId) {
$token = CloudProviderToken::find($cloudProviderTokenId);
}
if (! $token) {
$token = CloudProviderToken::where('team_id', $teamId)
->where('provider', 'hetzner')
->first();
}
if (! $token) {
ray('No Hetzner token found for team, skipping Hetzner deletion', [
'team_id' => $teamId,
'hetzner_server_id' => $hetznerServerId,
]);
return;
}
$hetznerService = new HetznerService($token->token);
$hetznerService->deleteServer($hetznerServerId);
ray('Deleted server from Hetzner', [
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
} catch (\Throwable $e) {
ray('Failed to delete server from Hetzner', [
'error' => $e->getMessage(),
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
// Log the error but don't prevent the server from being deleted from Coolify
logger()->error('Failed to delete server from Hetzner', [
'error' => $e->getMessage(),
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
// Notify the team about the failure
$team = Team::find($teamId);
$team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage()));
}
}
}

View file

@ -4,7 +4,6 @@
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneDocker;
use Lorisleiva\Actions\Concerns\AsAction;
@ -20,7 +19,7 @@ public function handle(Server $server)
throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://coolify.io/docs/installation#manually">documentation</a>.');
}
if (! SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->exists()) {
if (! $server->sslCertificates()->where('is_ca_certificate', true)->exists()) {
$serverCert = SslHelper::generateSslCertificate(
commonName: 'Coolify CA Certificate',
serverId: $server->id,

View file

@ -13,6 +13,7 @@
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SslCertificate;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@ -58,6 +59,15 @@ private function cleanup_stucked_resources()
} catch (\Throwable $e) {
echo "Error in cleaning stucked resources: {$e->getMessage()}\n";
}
try {
$servers = Server::onlyTrashed()->get();
foreach ($servers as $server) {
echo "Force deleting stuck server: {$server->name}\n";
$server->forceDelete();
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck servers: {$e->getMessage()}\n";
}
try {
$applicationsDeploymentQueue = ApplicationDeploymentQueue::get();
foreach ($applicationsDeploymentQueue as $applicationDeploymentQueue) {
@ -427,5 +437,18 @@ private function cleanup_stucked_resources()
} catch (\Throwable $e) {
echo "Error in ServiceDatabases: {$e->getMessage()}\n";
}
try {
$orphanedCerts = SslCertificate::whereNotIn('server_id', function ($query) {
$query->select('id')->from('servers');
})->get();
foreach ($orphanedCerts as $cert) {
echo "Deleting orphaned SSL certificate: {$cert->id} (server_id: {$cert->server_id})\n";
$cert->delete();
}
} catch (\Throwable $e) {
echo "Error in cleaning orphaned SSL certificates: {$e->getMessage()}\n";
}
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use App\Livewire\GlobalSearch;
use App\Models\Team;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class ClearGlobalSearchCache extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'search:clear {--team= : Clear cache for specific team ID} {--all : Clear cache for all teams}';
/**
* The console command description.
*/
protected $description = 'Clear the global search cache for testing or manual refresh';
/**
* Execute the console command.
*/
public function handle(): int
{
if ($this->option('all')) {
return $this->clearAllTeamsCache();
}
if ($teamId = $this->option('team')) {
return $this->clearTeamCache($teamId);
}
// If no options provided, clear cache for current user's team
if (! auth()->check()) {
$this->error('No authenticated user found. Use --team=ID or --all option.');
return Command::FAILURE;
}
$teamId = auth()->user()->currentTeam()->id;
return $this->clearTeamCache($teamId);
}
private function clearTeamCache(int $teamId): int
{
$team = Team::find($teamId);
if (! $team) {
$this->error("Team with ID {$teamId} not found.");
return Command::FAILURE;
}
GlobalSearch::clearTeamCache($teamId);
$this->info("✓ Cleared global search cache for team: {$team->name} (ID: {$teamId})");
return Command::SUCCESS;
}
private function clearAllTeamsCache(): int
{
$teams = Team::all();
if ($teams->isEmpty()) {
$this->warn('No teams found.');
return Command::SUCCESS;
}
$count = 0;
foreach ($teams as $team) {
GlobalSearch::clearTeamCache($team->id);
$count++;
}
$this->info("✓ Cleared global search cache for {$count} team(s)");
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,51 @@
<?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 ServerValidated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public ?int $teamId = null;
public ?string $serverUuid = null;
public function __construct(?int $teamId = null, ?string $serverUuid = null)
{
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
$teamId = auth()->user()->currentTeam()->id;
}
$this->teamId = $teamId;
$this->serverUuid = $serverUuid;
}
public function broadcastOn(): array
{
if (is_null($this->teamId)) {
return [];
}
return [
new PrivateChannel("team.{$this->teamId}"),
];
}
public function broadcastAs(): string
{
return 'ServerValidated';
}
public function broadcastWith(): array
{
return [
'teamId' => $this->teamId,
'serverUuid' => $this->serverUuid,
];
}
}

View file

@ -1512,9 +1512,32 @@ private function create_application(Request $request, $type)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
if (! $request->docker_registry_image_tag) {
$request->offsetSet('docker_registry_image_tag', 'latest');
// Process docker image name and tag for SHA256 digests
$dockerImageName = $request->docker_registry_image_name;
$dockerImageTag = $request->docker_registry_image_tag;
// Strip 'sha256:' prefix if user provided it in the tag
if ($dockerImageTag) {
$dockerImageTag = preg_replace('/^sha256:/i', '', trim($dockerImageTag));
}
// Remove @sha256 from image name if user added it
if ($dockerImageName) {
$dockerImageName = preg_replace('/@sha256$/i', '', trim($dockerImageName));
}
// Check if tag is a valid SHA256 hash (64 hex characters)
$isSha256Hash = $dockerImageTag && preg_match('/^[a-f0-9]{64}$/i', $dockerImageTag);
// Append @sha256 to image name if using digest and not already present
if ($isSha256Hash && ! str_ends_with($dockerImageName, '@sha256')) {
$dockerImageName .= '@sha256';
}
// Set processed values back to request
$request->offsetSet('docker_registry_image_name', $dockerImageName);
$request->offsetSet('docker_registry_image_tag', $dockerImageTag ?: 'latest');
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
@ -2469,7 +2492,7 @@ public function envs(Request $request)
)]
public function update_env_by_uuid(Request $request)
{
$allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -2497,6 +2520,8 @@ public function update_env_by_uuid(Request $request)
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -2692,7 +2717,7 @@ public function create_bulk_envs(Request $request)
], 400);
}
$bulk_data = collect($bulk_data)->map(function ($item) {
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal']);
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']);
});
$returnedEnvs = collect();
foreach ($bulk_data as $item) {
@ -2703,6 +2728,8 @@ public function create_bulk_envs(Request $request)
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
]);
if ($validator->fails()) {
return response()->json([
@ -2862,7 +2889,7 @@ public function create_bulk_envs(Request $request)
)]
public function create_env(Request $request)
{
$allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -2885,6 +2912,8 @@ public function create_env(Request $request)
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);

View file

@ -317,6 +317,10 @@ public function database_by_uuid(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_by_uuid(Request $request)
@ -666,6 +670,10 @@ public function update_by_uuid(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_backup(Request $request)
@ -844,6 +852,10 @@ public function update_backup(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_postgresql(Request $request)
@ -907,6 +919,10 @@ public function create_database_postgresql(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_clickhouse(Request $request)
@ -969,6 +985,10 @@ public function create_database_clickhouse(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_dragonfly(Request $request)
@ -1032,6 +1052,10 @@ public function create_database_dragonfly(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_redis(Request $request)
@ -1095,6 +1119,10 @@ public function create_database_redis(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_keydb(Request $request)
@ -1161,6 +1189,10 @@ public function create_database_keydb(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_mariadb(Request $request)
@ -1227,6 +1259,10 @@ public function create_database_mariadb(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_mysql(Request $request)
@ -1290,6 +1326,10 @@ public function create_database_mysql(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_mongodb(Request $request)
@ -1941,7 +1981,7 @@ public function delete_by_uuid(Request $request)
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup configuration and all executions deleted.'),
new OA\Property(property: 'message', type: 'string', example: 'Backup configuration and all executions deleted.'),
]
)
),
@ -1951,7 +1991,7 @@ public function delete_by_uuid(Request $request)
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup configuration not found.'),
new OA\Property(property: 'message', type: 'string', example: 'Backup configuration not found.'),
]
)
),
@ -2065,7 +2105,7 @@ public function delete_backup_by_uuid(Request $request)
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup execution deleted.'),
new OA\Property(property: 'message', type: 'string', example: 'Backup execution deleted.'),
]
)
),
@ -2075,7 +2115,7 @@ public function delete_backup_by_uuid(Request $request)
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup execution not found.'),
new OA\Property(property: 'message', type: 'string', example: 'Backup execution not found.'),
]
)
),
@ -2171,17 +2211,18 @@ public function delete_execution_by_uuid(Request $request)
content: new OA\JsonContent(
type: 'object',
properties: [
'executions' => new OA\Schema(
new OA\Property(
property: 'executions',
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'uuid' => ['type' => 'string'],
'filename' => ['type' => 'string'],
'size' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
'message' => ['type' => 'string'],
'status' => ['type' => 'string'],
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'filename', type: 'string'),
new OA\Property(property: 'size', type: 'integer'),
new OA\Property(property: 'created_at', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
new OA\Property(property: 'status', type: 'string'),
]
)
),

View file

@ -219,7 +219,8 @@ public function create_github_app(Request $request)
schema: new OA\Schema(
type: 'object',
properties: [
'repositories' => new OA\Schema(
new OA\Property(
property: 'repositories',
type: 'array',
items: new OA\Items(type: 'object')
),
@ -335,7 +336,8 @@ public function load_repositories($github_app_id)
schema: new OA\Schema(
type: 'object',
properties: [
'branches' => new OA\Schema(
new OA\Property(
property: 'branches',
type: 'array',
items: new OA\Items(type: 'object')
),
@ -457,7 +459,7 @@ public function load_branches($github_app_id, $owner, $repo)
),
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'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function update_github_app(Request $request, $github_app_id)

View file

@ -40,6 +40,27 @@
new OA\Property(property: 'message', type: 'string', example: 'Resource not found.'),
]
)),
new OA\Response(
response: 422,
description: 'Validation error.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Validation error.'),
new OA\Property(
property: 'errors',
type: 'object',
additionalProperties: new OA\AdditionalProperties(
type: 'array',
items: new OA\Items(type: 'string')
),
example: [
'name' => ['The name field is required.'],
'api_url' => ['The api url field is required.', 'The api url format is invalid.'],
]
),
]
)),
],
)]
class OpenApi

View file

@ -21,8 +21,9 @@ class OtherController extends Controller
new OA\Response(
response: 200,
description: 'Returns the version of the application',
content: new OA\JsonContent(
type: 'string',
content: new OA\MediaType(
mediaType: 'text/html',
schema: new OA\Schema(type: 'string'),
example: 'v4.0.0',
)),
new OA\Response(
@ -166,8 +167,9 @@ public function feedback(Request $request)
new OA\Response(
response: 200,
description: 'Healthcheck endpoint.',
content: new OA\JsonContent(
type: 'string',
content: new OA\MediaType(
mediaType: 'text/html',
schema: new OA\Schema(type: 'string'),
example: 'OK',
)),
new OA\Response(

View file

@ -134,6 +134,10 @@ public function project_by_uuid(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function environment_details(Request $request)
@ -214,6 +218,10 @@ public function environment_details(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_project(Request $request)
@ -324,6 +332,10 @@ public function create_project(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_project(Request $request)
@ -425,6 +437,10 @@ public function update_project(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function delete_project(Request $request)
@ -487,6 +503,10 @@ public function delete_project(Request $request)
response: 404,
description: 'Project not found.',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function get_environments(Request $request)
@ -566,6 +586,10 @@ public function get_environments(Request $request)
response: 409,
description: 'Environment with this name already exists.',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_environment(Request $request)
@ -663,6 +687,10 @@ public function create_environment(Request $request)
response: 404,
description: 'Project or environment not found.',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function delete_environment(Request $request)

View file

@ -163,6 +163,10 @@ public function key_by_uuid(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_key(Request $request)
@ -282,6 +286,10 @@ public function create_key(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_key(Request $request)

View file

@ -447,6 +447,10 @@ public function domains_by_server(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_server(Request $request)
@ -604,6 +608,10 @@ public function create_server(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_server(Request $request)
@ -722,6 +730,10 @@ public function update_server(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function delete_server(Request $request)
@ -746,7 +758,13 @@ public function delete_server(Request $request)
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
}
$server->delete();
DeleteServer::dispatch($server);
DeleteServer::dispatch(
$server->id,
false, // Don't delete from Hetzner via API
$server->hetzner_server_id,
$server->cloud_provider_token_id,
$server->team_id
);
return response()->json(['message' => 'Server deleted.']);
}
@ -790,6 +808,10 @@ public function delete_server(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function validate_server(Request $request)

View file

@ -235,6 +235,10 @@ public function services(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_service(Request $request)
@ -704,6 +708,10 @@ public function delete_by_uuid(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_by_uuid(Request $request)
@ -954,6 +962,10 @@ public function envs(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_env_by_uuid(Request $request)
@ -1075,6 +1087,10 @@ public function update_env_by_uuid(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_bulk_envs(Request $request)
@ -1191,6 +1207,10 @@ public function create_bulk_envs(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_env(Request $request)

View file

@ -116,16 +116,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_args;
private $environment_variables;
private $env_nixpacks_args;
private $docker_compose;
private $docker_compose_base64;
private ?string $env_filename = null;
private ?string $nixpacks_plan = null;
private Collection $nixpacks_plan_json;
@ -488,9 +484,18 @@ private function deploy_simple_dockerfile()
);
$this->generate_image_names();
$this->generate_compose_file();
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
$this->generate_build_env_variables();
$this->add_build_env_variables_to_dockerfile();
$this->build_image();
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
}
@ -503,7 +508,12 @@ private function deploy_dockerimage_buildpack()
} else {
$this->dockerImageTag = $this->application->docker_registry_image_tag;
}
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}.");
// Check if this is an image hash deployment
$isImageHash = str($this->dockerImageTag)->startsWith('sha256-');
$displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}";
$this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}.");
$this->generate_image_names();
$this->prepare_builder_image();
$this->generate_compose_file();
@ -571,7 +581,6 @@ private function deploy_docker_compose_buildpack()
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$this->application->oldRawParser();
$yaml = $composeFile = $this->application->docker_compose_raw;
$this->generate_runtime_environment_variables();
// For raw compose, we cannot automatically add secrets configuration
// User must define it manually in their docker-compose file
@ -580,16 +589,14 @@ private function deploy_docker_compose_buildpack()
}
} else {
$composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'));
$this->generate_runtime_environment_variables();
if (filled($this->env_filename)) {
$services = collect(data_get($composeFile, 'services', []));
$services = $services->map(function ($service, $name) {
$service['env_file'] = [$this->env_filename];
// Always add .env file to services
$services = collect(data_get($composeFile, 'services', []));
$services = $services->map(function ($service, $name) {
$service['env_file'] = ['.env'];
return $service;
});
$composeFile['services'] = $services->toArray();
}
return $service;
});
$composeFile['services'] = $services->toArray();
if (empty($composeFile)) {
$this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.');
$this->fail('Failed to parse docker-compose file.');
@ -615,6 +622,9 @@ private function deploy_docker_compose_buildpack()
// Build new container to limit downtime.
$this->application_deployment_queue->addLogEntry('Pulling & building required images.');
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
if ($this->docker_compose_custom_build_command) {
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
$build_command = $this->docker_compose_custom_build_command;
@ -630,9 +640,8 @@ private function deploy_docker_compose_buildpack()
if ($this->dockerBuildkitSupported) {
$command = "DOCKER_BUILDKIT=1 {$command}";
}
if (filled($this->env_filename)) {
$command .= " --env-file {$this->workdir}/{$this->env_filename}";
}
// Use build-time .env file from /artifacts (outside Docker context to prevent it from being in the image)
$command .= ' --env-file /artifacts/build-time.env';
if ($this->force_rebuild) {
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache";
} else {
@ -652,6 +661,10 @@ private function deploy_docker_compose_buildpack()
);
}
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->stop_running_container(force: true);
$this->application_deployment_queue->addLogEntry('Starting new application.');
$networkId = $this->application->uuid;
@ -685,9 +698,8 @@ private function deploy_docker_compose_buildpack()
$this->docker_compose_location = '/docker-compose.yaml';
$command = "{$this->coolify_variables} docker compose";
if (filled($this->env_filename)) {
$command .= " --env-file {$server_workdir}/{$this->env_filename}";
}
// Always use .env file
$command .= " --env-file {$server_workdir}/.env";
$command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command(
['command' => $command, 'hidden' => true],
@ -702,9 +714,8 @@ private function deploy_docker_compose_buildpack()
} else {
$command = "{$this->coolify_variables} docker compose";
if ($this->preserveRepository) {
if (filled($this->env_filename)) {
$command .= " --env-file {$server_workdir}/{$this->env_filename}";
}
// Always use .env file
$command .= " --env-file {$server_workdir}/.env";
$command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
$this->write_deployment_configurations();
@ -712,9 +723,8 @@ private function deploy_docker_compose_buildpack()
['command' => $command, 'hidden' => true],
);
} else {
if (filled($this->env_filename)) {
$command .= " --env-file {$this->workdir}/{$this->env_filename}";
}
// Always use .env file
$command .= " --env-file {$this->workdir}/.env";
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, $command), 'hidden' => true],
@ -748,9 +758,18 @@ private function deploy_dockerfile_buildpack()
}
$this->cleanup_git();
$this->generate_compose_file();
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
$this->generate_build_env_variables();
$this->add_build_env_variables_to_dockerfile();
$this->build_image();
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
}
@ -774,11 +793,15 @@ private function deploy_nixpacks_buildpack()
$this->cleanup_git();
$this->generate_nixpacks_confs();
$this->generate_compose_file();
// Save build-time .env file BEFORE the build for Nixpacks
$this->save_buildtime_environment_variables();
$this->generate_build_env_variables();
$this->build_image();
// For Nixpacks, save runtime environment variables AFTER the build
// to prevent them from being accessible during the build process
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
@ -802,7 +825,16 @@ private function deploy_static_buildpack()
$this->clone_repository();
$this->cleanup_git();
$this->generate_compose_file();
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
$this->build_static_image();
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
}
@ -934,7 +966,13 @@ private function generate_image_names()
$this->production_image_name = "{$this->application->uuid}:latest";
}
} elseif ($this->application->build_pack === 'dockerimage') {
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
// Check if this is an image hash deployment
if (str($this->dockerImageTag)->startsWith('sha256-')) {
$hash = str($this->dockerImageTag)->after('sha256-');
$this->production_image_name = "{$this->dockerImage}@sha256:{$hash}";
} else {
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
}
} elseif ($this->pull_request_id !== 0) {
if ($this->application->docker_registry_image_name) {
$this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build";
@ -976,6 +1014,10 @@ private function should_skip_build()
$this->skip_build = true;
$this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
$this->generate_compose_file();
// Save runtime environment variables even when skipping build
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
@ -985,6 +1027,10 @@ private function should_skip_build()
$this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
$this->skip_build = true;
$this->generate_compose_file();
// Save runtime environment variables even when skipping build
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
@ -1049,8 +1095,6 @@ private function generate_runtime_environment_variables()
$envs->push($key.'='.$item);
});
if ($this->pull_request_id === 0) {
$this->env_filename = '.env';
// Generate SERVICE_ variables first for dockercompose
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
@ -1109,8 +1153,6 @@ private function generate_runtime_environment_variables()
$envs->push('HOST=0.0.0.0');
}
} else {
$this->env_filename = '.env';
// Generate SERVICE_ variables first for dockercompose preview
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]);
@ -1165,99 +1207,250 @@ private function generate_runtime_environment_variables()
$envs->push('HOST=0.0.0.0');
}
}
if ($envs->isEmpty()) {
if ($this->env_filename) {
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
'hidden' => true,
'ignore_errors' => true,
]
);
$this->server = $this->build_server;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
'hidden' => true,
'ignore_errors' => true,
]
);
} else {
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
'hidden' => true,
'ignore_errors' => true,
]
);
}
}
$this->env_filename = null;
} else {
// For Nixpacks builds, we save the .env file AFTER the build to prevent
// runtime-only variables from being accessible during the build process
if ($this->application->build_pack !== 'nixpacks' || $this->skip_build) {
$envs_base64 = base64_encode($envs->implode("\n"));
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"),
],
);
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
]
);
$this->server = $this->build_server;
} else {
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
]
);
}
}
}
$this->environment_variables = $envs;
// Return the generated environment variables instead of storing them globally
return $envs;
}
private function save_runtime_environment_variables()
{
// This method saves the .env file with runtime variables
// It should be called AFTER the build for Nixpacks to prevent runtime-only variables
// from being accessible during the build process
// This method saves the .env file with ALL runtime variables
// For builds, it should be called AFTER the build to include runtime-only variables
if ($this->environment_variables && $this->environment_variables->isNotEmpty() && $this->env_filename) {
$envs_base64 = base64_encode($this->environment_variables->implode("\n"));
// Generate runtime environment variables locally
$environment_variables = $this->generate_runtime_environment_variables();
// Write .env file to workdir (for container runtime)
// Handle empty environment variables
if ($environment_variables->isEmpty()) {
// For Docker Compose, we need to create an empty .env file
// because we always reference it in the compose file
if ($this->build_pack === 'dockercompose') {
$this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).');
// Create empty .env file
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "touch $this->workdir/.env"),
]
);
// Also create in configuration directory
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
"touch $this->configuration_dir/.env",
]
);
$this->server = $this->build_server;
} else {
$this->execute_remote_command(
[
"touch $this->configuration_dir/.env",
]
);
}
} else {
// For non-Docker Compose deployments, clean up any existing .env files
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/.env",
'hidden' => true,
'ignore_errors' => true,
]
);
$this->server = $this->build_server;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/.env",
'hidden' => true,
'ignore_errors' => true,
]
);
} else {
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/.env",
'hidden' => true,
'ignore_errors' => true,
]
);
}
}
return;
}
// Write the environment variables to file
$envs_base64 = base64_encode($environment_variables->implode("\n"));
// Write .env file to workdir (for container runtime)
$this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for build phase.', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
],
[
executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"),
'hidden' => true,
]
);
// Write .env file to configuration directory
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"),
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
]
);
$this->server = $this->build_server;
} else {
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
]
);
}
}
private function generate_buildtime_environment_variables()
{
$envs = collect([]);
$coolify_envs = $this->generate_coolify_env_variables();
// Add COOLIFY variables
$coolify_envs->each(function ($item, $key) use ($envs) {
$envs->push($key.'='.$item);
});
// Add SERVICE_NAME variables for Docker Compose builds
if ($this->build_pack === 'dockercompose') {
if ($this->pull_request_id === 0) {
// Generate SERVICE_NAME for dockercompose services from processed compose
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$dockerCompose = Yaml::parse($this->application->docker_compose_raw);
} else {
$dockerCompose = Yaml::parse($this->application->docker_compose);
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName);
}
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
}
}
} else {
// Generate SERVICE_NAME for preview deployments
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
}
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]);
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
}
}
}
}
// Add build-time user variables only
if ($this->pull_request_id === 0) {
$sorted_environment_variables = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these
if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
}
foreach ($sorted_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
}
} else {
$sorted_environment_variables = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values
if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
}
foreach ($sorted_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
}
}
// Return the generated environment variables
return $envs;
}
private function save_buildtime_environment_variables()
{
// Generate build-time environment variables locally
$environment_variables = $this->generate_buildtime_environment_variables();
// Save .env file for build phase in /artifacts to prevent it from being copied into Docker images
if ($environment_variables->isNotEmpty()) {
$envs_base64 = base64_encode($environment_variables->implode("\n"));
$this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee /artifacts/build-time.env > /dev/null"),
],
[
executeInDocker($this->deployment_uuid, 'cat /artifacts/build-time.env'),
'hidden' => true,
],
);
} elseif ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerfile') {
// For Docker Compose and Dockerfile, create an empty .env file even if there are no build-time variables
// This ensures the file exists when referenced in build commands
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
// Write .env file to configuration directory
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
]
);
$this->server = $this->build_server;
} else {
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
]
);
}
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, 'touch /artifacts/build-time.env'),
]
);
}
}
@ -1472,15 +1665,18 @@ private function deploy_pull_request()
$this->generate_nixpacks_confs();
}
$this->generate_compose_file();
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
$this->generate_build_env_variables();
if ($this->application->build_pack === 'dockerfile') {
$this->add_build_env_variables_to_dockerfile();
}
$this->build_image();
// For Nixpacks, save runtime environment variables AFTER the build
if ($this->application->build_pack === 'nixpacks') {
$this->save_runtime_environment_variables();
}
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
}
@ -1515,7 +1711,7 @@ private function create_workdir()
}
}
private function prepare_builder_image()
private function prepare_builder_image(bool $firstTry = true)
{
$this->checkForCancellation();
$settings = instanceSettings();
@ -1538,7 +1734,12 @@ private function prepare_builder_image()
$runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
}
}
$this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage.");
if ($firstTry) {
$this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage");
} else {
$this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.');
}
$this->graceful_shutdown_container($this->deployment_uuid);
$this->execute_remote_command(
[
@ -1561,7 +1762,7 @@ private function restart_builder_container_with_actual_commit()
$this->env_args = null;
// Restart the helper container with updated environment variables (including actual SOURCE_COMMIT)
$this->prepare_builder_image();
$this->prepare_builder_image(firstTry: false);
}
private function deploy_to_additional_destinations()
@ -1809,6 +2010,15 @@ private function generate_nixpacks_confs()
if ($this->nixpacks_type === 'elixir') {
$this->elixir_finetunes();
}
if ($this->nixpacks_type === 'node') {
// Check if NIXPACKS_NODE_VERSION is set
$variables = data_get($parsed, 'variables', []);
if (! isset($variables['NIXPACKS_NODE_VERSION'])) {
$this->application_deployment_queue->addLogEntry('----------------------------------------');
$this->application_deployment_queue->addLogEntry('⚠️ NIXPACKS_NODE_VERSION not set. Nixpacks will use Node.js 18 by default, which is EOL.');
$this->application_deployment_queue->addLogEntry('You can override this by setting NIXPACKS_NODE_VERSION=22 in your environment variables.');
}
}
$this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT);
$this->nixpacks_plan_json = collect($parsed);
$this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true);
@ -1954,10 +2164,14 @@ private function generate_env_variables()
{
$this->env_args = collect([]);
$this->env_args->put('SOURCE_COMMIT', $this->commit);
$coolify_envs = $this->generate_coolify_env_variables();
$coolify_envs->each(function ($value, $key) {
$this->env_args->put($key, $value);
});
// For build process, include only environment variables where is_buildtime = true
if ($this->pull_request_id === 0) {
// Get environment variables that are marked as available during build
$envs = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true)
@ -1966,24 +2180,9 @@ private function generate_env_variables()
foreach ($envs as $env) {
if (! is_null($env->real_value)) {
$this->env_args->put($env->key, $env->real_value);
if (str($env->real_value)->startsWith('$')) {
$variable_key = str($env->real_value)->after('$');
if ($variable_key->startsWith('COOLIFY_')) {
$variable = $coolify_envs->get($variable_key->value());
if (filled($variable)) {
$this->env_args->prepend($variable, $variable_key->value());
}
} else {
$variable = $this->application->environment_variables()->where('key', $variable_key)->first();
if ($variable) {
$this->env_args->prepend($variable->real_value, $env->key);
}
}
}
}
}
} else {
// Get preview environment variables that are marked as available during build
$envs = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true)
@ -1992,29 +2191,9 @@ private function generate_env_variables()
foreach ($envs as $env) {
if (! is_null($env->real_value)) {
$this->env_args->put($env->key, $env->real_value);
if (str($env->real_value)->startsWith('$')) {
$variable_key = str($env->real_value)->after('$');
if ($variable_key->startsWith('COOLIFY_')) {
$variable = $coolify_envs->get($variable_key->value());
if (filled($variable)) {
$this->env_args->prepend($variable, $variable_key->value());
}
} else {
$variable = $this->application->environment_variables_preview()->where('key', $variable_key)->first();
if ($variable) {
$this->env_args->prepend($variable->real_value, $env->key);
}
}
}
}
}
}
// Merge COOLIFY_* variables into env_args for build process
// This ensures they're available for both build args and build secrets
$coolify_envs->each(function ($value, $key) {
$this->env_args->put($key, $value);
});
}
private function generate_compose_file()
@ -2025,7 +2204,6 @@ private function generate_compose_file()
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->application->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$this->generate_runtime_environment_variables();
if (data_get($this->application, 'custom_labels')) {
$this->application->parseContainerLabels();
$labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels)));
@ -2094,9 +2272,8 @@ private function generate_compose_file()
],
],
];
if (filled($this->env_filename)) {
$docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename];
}
// Always use .env file
$docker_compose['services'][$this->container_name]['env_file'] = ['.env'];
$docker_compose['services'][$this->container_name]['healthcheck'] = [
'test' => [
'CMD-SHELL',
@ -2381,6 +2558,18 @@ private function build_static_image()
$this->application_deployment_queue->addLogEntry('Building docker image completed.');
}
/**
* Wrap a docker build command with environment export from /artifacts/build-time.env
* This enables shell interpolation of variables (e.g., APP_URL=$COOLIFY_URL)
*
* @param string $build_command The docker build command to wrap
* @return string The wrapped command with export statement
*/
private function wrap_build_command_with_env_export(string $build_command): string
{
return "cd {$this->workdir} && set -a && source /artifacts/build-time.env && set +a && {$build_command}";
}
private function build_image()
{
// Add Coolify related variables to the build args/secrets
@ -2388,13 +2577,12 @@ private function build_image()
// Coolify variables are already included in the secrets from generate_build_env_variables
// build_secrets is already a string at this point
} else {
// Traditional build args approach
$this->environment_variables->filter(function ($key, $value) {
return str($key)->startsWith('COOLIFY_');
})->each(function ($key, $value) {
// Traditional build args approach - generate COOLIFY_ variables locally
// Generate COOLIFY_ variables locally for build args
$coolify_envs = $this->generate_coolify_env_variables();
$coolify_envs->each(function ($value, $key) {
$this->build_args->push("--build-arg '{$key}'");
});
$this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
? $this->build_args->implode(' ')
: (string) $this->build_args;
@ -2431,12 +2619,13 @@ private function build_image()
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
ray($build_command);
} else {
$build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@ -2446,13 +2635,18 @@ private function build_image()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported) {
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
}
@ -2479,16 +2673,25 @@ private function build_image()
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
$build_command = "docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
$build_command = "docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -2507,10 +2710,12 @@ private function build_image()
]
);
}
$publishDir = trim($this->application->publish_directory, '/');
$publishDir = $publishDir ? "/{$publishDir}" : '';
$dockerfile = base64_encode("FROM {$this->application->static_image}
WORKDIR /usr/share/nginx/html/
LABEL coolify.deploymentId={$this->deployment_uuid}
COPY --from=$this->build_image_name /app/{$this->application->publish_directory} .
COPY --from=$this->build_image_name /app{$publishDir} .
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
@ -2521,7 +2726,7 @@ private function build_image()
$nginx_config = base64_encode(defaultNginxConfiguration());
}
}
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
@ -2558,9 +2763,9 @@ private function build_image()
} else {
// Traditional build with args
if ($this->force_rebuild) {
$build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -2590,13 +2795,18 @@ private function build_image()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported) {
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@ -2606,13 +2816,18 @@ private function build_image()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported) {
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -2633,22 +2848,31 @@ private function build_image()
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
} else {
// Dockerfile buildpack
if ($this->dockerBuildkitSupported) {
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
// Use BuildKit with secrets
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
$build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -2924,6 +3148,7 @@ private function add_build_env_variables_to_dockerfile()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true,
'save' => 'dockerfile',
'ignore_errors' => true,
]);
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
if ($this->pull_request_id === 0) {
@ -2982,10 +3207,17 @@ private function add_build_env_variables_to_dockerfile()
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
'hidden' => true,
]);
$this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true,
'ignore_errors' => true,
]);
}
}
@ -3143,7 +3375,6 @@ private function modify_dockerfiles_for_compose($composeFile)
$argsToAdd->push("ARG {$key}");
}
ray($argsToAdd);
if ($argsToAdd->isEmpty()) {
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add.");

View file

@ -35,23 +35,26 @@ public function handle()
if ($this->application->is_public_repository()) {
return;
}
$serviceName = $this->application->name;
if ($this->status === ProcessStatus::CLOSED) {
$this->delete_comment();
return;
} elseif ($this->status === ProcessStatus::IN_PROGRESS) {
$this->body = "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n";
} elseif ($this->status === ProcessStatus::FINISHED) {
$this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n";
if ($this->preview->fqdn) {
$this->body .= "[Open Preview]({$this->preview->fqdn}) | ";
}
} elseif ($this->status === ProcessStatus::ERROR) {
$this->body = "The preview deployment for **{$serviceName}** failed. 🔴\n\n";
}
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/{$this->application->environment->name}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
match ($this->status) {
ProcessStatus::QUEUED => $this->body = "The preview deployment for **{$serviceName}** is queued. ⏳\n\n",
ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n",
ProcessStatus::FINISHED => $this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".($this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''),
ProcessStatus::ERROR => $this->body = "The preview deployment for **{$serviceName}** failed. 🔴\n\n",
ProcessStatus::KILLED => $this->body = "The preview deployment for **{$serviceName}** was killed. ⚫\n\n",
ProcessStatus::CANCELLED => $this->body = "The preview deployment for **{$serviceName}** was cancelled. 🚫\n\n",
ProcessStatus::CLOSED => '', // Already handled above, but included for completeness
};
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
$this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n";
$this->body .= 'Last updated at: '.now()->toDateTimeString().' CET';
if ($this->preview->pull_request_issue_comment_id) {

View file

@ -15,6 +15,7 @@
use App\Models\Team;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@ -68,7 +69,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public $timeout = 3600;
public string $backup_log_uuid;
public ?string $backup_log_uuid = null;
public function __construct(public ScheduledDatabaseBackup $backup)
{
@ -298,6 +299,10 @@ public function handle(): void
} while ($exists);
$size = 0;
$localBackupSucceeded = false;
$s3UploadError = null;
// Step 1: Create local backup
try {
if (str($databaseType)->contains('postgres')) {
$this->backup_file = "/pg-dump-$database-".Carbon::now()->timestamp.'.dmp';
@ -310,6 +315,7 @@ public function handle(): void
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_postgresql($database);
} elseif (str($databaseType)->contains('mongo')) {
@ -330,6 +336,7 @@ public function handle(): void
'database_name' => $databaseName,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mongodb($database);
} elseif (str($databaseType)->contains('mysql')) {
@ -343,6 +350,7 @@ public function handle(): void
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mysql($database);
} elseif (str($databaseType)->contains('mariadb')) {
@ -356,56 +364,77 @@ public function handle(): void
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mariadb($database);
} else {
throw new \Exception('Unsupported database type');
}
$size = $this->calculate_size();
if ($this->backup->save_s3) {
// Verify local backup succeeded
if ($size > 0) {
$localBackupSucceeded = true;
} else {
throw new \Exception('Local backup file is empty or was not created');
}
} catch (\Throwable $e) {
// Local backup failed
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'failed',
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
'size' => $size,
'filename' => null,
's3_uploaded' => null,
]);
}
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
continue;
}
// Step 2: Upload to S3 if enabled (independent of local backup)
$localStorageDeleted = false;
if ($this->backup->save_s3 && $localBackupSucceeded) {
try {
$this->upload_to_s3();
// If local backup is disabled, delete the local file immediately after S3 upload
if ($this->backup->disable_local_backup) {
deleteBackupsLocally($this->backup_location, $this->server);
$localStorageDeleted = true;
}
} catch (\Throwable $e) {
// S3 upload failed but local backup succeeded
$s3UploadError = $e->getMessage();
}
}
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
// Step 3: Update status and send notifications based on results
if ($localBackupSucceeded) {
$message = $this->backup_output;
if ($s3UploadError) {
$message = $message
? $message."\n\nWarning: S3 upload failed: ".$s3UploadError
: 'Warning: S3 upload failed: '.$s3UploadError;
}
$this->backup_log->update([
'status' => 'success',
'message' => $this->backup_output,
'message' => $message,
'size' => $size,
's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null,
'local_storage_deleted' => $localStorageDeleted,
]);
} catch (\Throwable $e) {
// Check if backup actually failed or if it's just a post-backup issue
$actualBackupFailed = ! $this->s3_uploaded && $this->backup->save_s3;
if ($actualBackupFailed || $size === 0) {
// Real backup failure
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'failed',
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
'size' => $size,
'filename' => null,
]);
}
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
// Send appropriate notification
if ($s3UploadError) {
$this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
} else {
// Backup succeeded but post-processing failed (cleanup, notification, etc.)
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'success',
'message' => $this->backup_output ? $this->backup_output."\nWarning: Post-backup cleanup encountered an issue: ".$e->getMessage() : 'Warning: '.$e->getMessage(),
'size' => $size,
]);
}
// Send success notification since the backup itself succeeded
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
// Log the post-backup issue
ray('Post-backup operation failed but backup was successful: '.$e->getMessage());
}
}
}
@ -591,24 +620,24 @@ private function upload_to_s3(): void
$fullImageName = $this->getFullImageName();
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup->uuid}"], $this->server, false);
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false);
if (filled($containerExists)) {
instant_remote_process(["docker rm -f backup-of-{$this->backup->uuid}"], $this->server, false);
instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false);
}
if (isDev()) {
if ($this->database->name === 'coolify-db') {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file;
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
} else {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file;
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
}
} else {
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
}
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
instant_remote_process($commands, $this->server);
$this->s3_uploaded = true;
@ -617,7 +646,7 @@ private function upload_to_s3(): void
$this->add_to_error_output($e->getMessage());
throw $e;
} finally {
$command = "docker rm -f backup-of-{$this->backup->uuid}";
$command = "docker rm -f backup-of-{$this->backup_log_uuid}";
instant_remote_process([$command], $this->server);
}
}

View file

@ -45,7 +45,7 @@ public function handle()
$query->cursor()->each(function ($certificate) use ($regenerated) {
try {
$caCert = SslCertificate::where('server_id', $certificate->server_id)
$caCert = $certificate->server->sslCertificates()
->where('is_ca_certificate', true)
->first();

View file

@ -0,0 +1,60 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
public $backoff = 10;
/**
* The maximum number of unhandled exceptions to allow before failing.
*/
public int $maxExceptions = 5;
public function __construct(
public array $payload,
public string $webhookUrl
) {
$this->onQueue('high');
}
/**
* Execute the job.
*/
public function handle(): void
{
if (isDev()) {
ray('Sending webhook notification', [
'url' => $this->webhookUrl,
'payload' => $this->payload,
]);
}
$response = Http::post($this->webhookUrl, $this->payload);
if (isDev()) {
ray('Webhook response', [
'status' => $response->status(),
'body' => $response->body(),
'successful' => $response->successful(),
]);
}
}
}

View file

@ -54,6 +54,11 @@ public function handle()
return;
}
// Check Hetzner server status if applicable
if ($this->server->hetzner_server_id && $this->server->cloudProviderToken) {
$this->checkHetznerStatus();
}
// Temporarily disable mux if requested
if ($this->disableMux) {
$this->disableSshMux();
@ -86,6 +91,11 @@ public function handle()
]);
} catch (\Throwable $e) {
Log::error('ServerConnectionCheckJob failed', [
'error' => $e->getMessage(),
'server_id' => $this->server->id,
]);
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
@ -95,6 +105,30 @@ public function handle()
}
}
private function checkHetznerStatus(): void
{
try {
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$status = $serverData['status'] ?? null;
} catch (\Throwable $e) {
Log::debug('ServerConnectionCheck: Hetzner status check failed', [
'server_id' => $this->server->id,
'error' => $e->getMessage(),
]);
}
if ($this->server->hetzner_server_status !== $status) {
$this->server->update(['hetzner_server_status' => $status]);
$this->server->hetzner_server_status = $status;
if ($status === 'off') {
ray('Server is powered off, marking as unreachable');
throw new \Exception('Server is powered off');
}
}
}
private function checkConnection(): bool
{
try {

View file

@ -0,0 +1,162 @@
<?php
namespace App\Jobs;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Events\ServerReachabilityChanged;
use App\Events\ServerValidated;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ValidateAndInstallServerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 600; // 10 minutes
public int $maxTries = 3;
public function __construct(
public Server $server,
public int $numberOfTries = 0
) {
$this->onQueue('high');
}
public function handle(): void
{
try {
// Mark validation as in progress
$this->server->update(['is_validating' => true]);
Log::info('ValidateAndInstallServer: Starting validation', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
'attempt' => $this->numberOfTries + 1,
]);
// Validate connection
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if (! $uptime) {
$errorMessage = 'Server is not reachable. Please validate your configuration and connection.<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->server->update([
'validation_logs' => $errorMessage,
'is_validating' => false,
]);
Log::error('ValidateAndInstallServer: Server not reachable', [
'server_id' => $this->server->id,
'error' => $error,
]);
return;
}
// Validate OS
$supportedOsType = $this->server->validateOS();
if (! $supportedOsType) {
$errorMessage = 'Server OS type is not supported. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
$this->server->update([
'validation_logs' => $errorMessage,
'is_validating' => false,
]);
Log::error('ValidateAndInstallServer: OS not supported', [
'server_id' => $this->server->id,
]);
return;
}
// Check if Docker is installed
$dockerInstalled = $this->server->validateDockerEngine();
$dockerComposeInstalled = $this->server->validateDockerCompose();
if (! $dockerInstalled || ! $dockerComposeInstalled) {
// Try to install Docker
if ($this->numberOfTries >= $this->maxTries) {
$errorMessage = 'Docker Engine could not be installed after '.$this->maxTries.' attempts. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
$this->server->update([
'validation_logs' => $errorMessage,
'is_validating' => false,
]);
Log::error('ValidateAndInstallServer: Docker installation failed after max tries', [
'server_id' => $this->server->id,
'attempts' => $this->numberOfTries,
]);
return;
}
Log::info('ValidateAndInstallServer: Installing Docker', [
'server_id' => $this->server->id,
'attempt' => $this->numberOfTries + 1,
]);
// Install Docker
$this->server->installDocker();
// Retry validation after installation
self::dispatch($this->server, $this->numberOfTries + 1)->delay(now()->addSeconds(30));
return;
}
// Validate Docker version
$dockerVersion = $this->server->validateDockerEngineVersion();
if (! $dockerVersion) {
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
$errorMessage = '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([
'validation_logs' => $errorMessage,
'is_validating' => false,
]);
Log::error('ValidateAndInstallServer: Docker version not sufficient', [
'server_id' => $this->server->id,
]);
return;
}
// Validation successful!
Log::info('ValidateAndInstallServer: Validation successful', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
// Start proxy if needed
if (! $this->server->isBuildServer()) {
$proxyShouldRun = CheckProxy::run($this->server, true);
if ($proxyShouldRun) {
StartProxy::dispatch($this->server);
}
}
// Mark validation as complete
$this->server->update(['is_validating' => false]);
// Refresh server to get latest state
$this->server->refresh();
// Broadcast events to update UI
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
ServerReachabilityChanged::dispatch($this->server);
} catch (\Throwable $e) {
Log::error('ValidateAndInstallServer: Exception occurred', [
'server_id' => $this->server->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$this->server->update([
'validation_logs' => 'An error occurred during validation: '.$e->getMessage(),
'is_validating' => false,
]);
}
}
}

View file

@ -16,14 +16,18 @@ class Index extends Component
{
protected $listeners = ['refreshBoardingIndex' => 'validateServer'];
#[\Livewire\Attributes\Url(as: 'step', history: true)]
public string $currentState = 'welcome';
#[\Livewire\Attributes\Url(keep: true)]
public ?string $selectedServerType = null;
public ?Collection $privateKeys = null;
#[\Livewire\Attributes\Url(keep: true)]
public ?int $selectedExistingPrivateKey = null;
#[\Livewire\Attributes\Url(keep: true)]
public ?string $privateKeyType = null;
public ?string $privateKey = null;
@ -38,6 +42,7 @@ class Index extends Component
public ?Collection $servers = null;
#[\Livewire\Attributes\Url(keep: true)]
public ?int $selectedExistingServer = null;
public ?string $remoteServerName = null;
@ -58,6 +63,7 @@ class Index extends Component
public Collection $projects;
#[\Livewire\Attributes\Url(keep: true)]
public ?int $selectedProject = null;
public ?Project $createdProject = null;
@ -79,17 +85,68 @@ public function mount()
$this->minDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
$this->privateKeyName = generate_random_name();
$this->remoteServerName = generate_random_name();
if (isDev()) {
$this->privateKey = '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----';
$this->privateKeyDescription = 'Created by Coolify';
$this->remoteServerDescription = 'Created by Coolify';
$this->remoteServerHost = 'coolify-testing-host';
// Initialize collections to avoid null errors
if ($this->privateKeys === null) {
$this->privateKeys = collect();
}
if ($this->servers === null) {
$this->servers = collect();
}
if (! isset($this->projects)) {
$this->projects = collect();
}
// Restore state when coming from URL with query params
if ($this->selectedServerType === 'localhost' && $this->selectedExistingServer === 0) {
$this->createdServer = Server::find(0);
if ($this->createdServer) {
$this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
}
}
if ($this->selectedServerType === 'remote') {
if ($this->privateKeys->isEmpty()) {
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
}
if ($this->servers->isEmpty()) {
$this->servers = Server::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
}
if ($this->selectedExistingServer) {
$this->createdServer = Server::find($this->selectedExistingServer);
if ($this->createdServer) {
$this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
$this->updateServerDetails();
}
}
if ($this->selectedExistingPrivateKey) {
$this->createdPrivateKey = PrivateKey::where('team_id', currentTeam()->id)
->where('id', $this->selectedExistingPrivateKey)
->first();
if ($this->createdPrivateKey) {
$this->privateKey = $this->createdPrivateKey->private_key;
$this->publicKey = $this->createdPrivateKey->getPublicKey();
}
}
// Auto-regenerate key pair for "Generate with Coolify" mode on page refresh
if ($this->privateKeyType === 'create' && empty($this->privateKey)) {
$this->createNewPrivateKey();
}
}
if ($this->selectedProject) {
$this->createdProject = Project::find($this->selectedProject);
if (! $this->createdProject) {
$this->projects = Project::ownedByCurrentTeam(['name'])->get();
}
}
// Load projects when on create-project state (for page refresh)
if ($this->currentState === 'create-project' && $this->projects->isEmpty()) {
$this->projects = Project::ownedByCurrentTeam(['name'])->get();
}
}
@ -129,41 +186,16 @@ public function setServerType(string $type)
return $this->validateServer('localhost');
} elseif ($this->selectedServerType === 'remote') {
if (isDev()) {
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->get();
} else {
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
}
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
// Auto-select first key if available for better UX
if ($this->privateKeys->count() > 0) {
$this->selectedExistingPrivateKey = $this->privateKeys->first()->id;
}
$this->servers = Server::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
if ($this->servers->count() > 0) {
$this->selectedExistingServer = $this->servers->first()->id;
$this->updateServerDetails();
$this->currentState = 'select-existing-server';
return;
}
// Onboarding always creates new servers, skip existing server selection
$this->currentState = 'private-key';
}
}
public function selectExistingServer()
{
$this->createdServer = Server::find($this->selectedExistingServer);
if (! $this->createdServer) {
$this->dispatch('error', 'Server is not found.');
$this->currentState = 'private-key';
return;
}
$this->selectedExistingPrivateKey = $this->createdServer->privateKey->id;
$this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
$this->updateServerDetails();
$this->currentState = 'validate-server';
}
private function updateServerDetails()
{
if ($this->createdServer) {
@ -181,7 +213,7 @@ public function getProxyType()
public function selectExistingPrivateKey()
{
if (is_null($this->selectedExistingPrivateKey)) {
$this->restartBoarding();
$this->dispatch('error', 'Please select a private key.');
return;
}
@ -202,6 +234,9 @@ public function setPrivateKey(string $type)
$this->privateKeyType = $type;
if ($type === 'create') {
$this->createNewPrivateKey();
} else {
$this->privateKey = null;
$this->publicKey = null;
}
$this->currentState = 'create-private-key';
}

View file

@ -16,7 +16,8 @@ public function deployments()
{
$servers = Server::ownedByCurrentTeam()->get();
return ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])
return ApplicationDeploymentQueue::with(['application.environment.project'])
->whereIn('status', ['in_progress', 'queued'])
->whereIn('server_id', $servers->pluck('id'))
->orderBy('id')
->get([

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,196 @@
<?php
namespace App\Livewire\Notifications;
use App\Models\Team;
use App\Models\WebhookNotificationSettings;
use App\Notifications\Test;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Webhook extends Component
{
use AuthorizesRequests;
public Team $team;
public WebhookNotificationSettings $settings;
#[Validate(['boolean'])]
public bool $webhookEnabled = false;
#[Validate(['url', 'nullable'])]
public ?string $webhookUrl = null;
#[Validate(['boolean'])]
public bool $deploymentSuccessWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $deploymentFailureWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $statusChangeWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $backupSuccessWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $backupFailureWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $scheduledTaskSuccessWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $scheduledTaskFailureWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $dockerCleanupSuccessWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $dockerCleanupFailureWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $serverDiskUsageWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $serverReachableWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $serverUnreachableWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $serverPatchWebhookNotifications = false;
public function mount()
{
try {
$this->team = auth()->user()->currentTeam();
$this->settings = $this->team->webhookNotificationSettings;
$this->authorize('view', $this->settings);
$this->syncData();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->settings);
$this->settings->webhook_enabled = $this->webhookEnabled;
$this->settings->webhook_url = $this->webhookUrl;
$this->settings->deployment_success_webhook_notifications = $this->deploymentSuccessWebhookNotifications;
$this->settings->deployment_failure_webhook_notifications = $this->deploymentFailureWebhookNotifications;
$this->settings->status_change_webhook_notifications = $this->statusChangeWebhookNotifications;
$this->settings->backup_success_webhook_notifications = $this->backupSuccessWebhookNotifications;
$this->settings->backup_failure_webhook_notifications = $this->backupFailureWebhookNotifications;
$this->settings->scheduled_task_success_webhook_notifications = $this->scheduledTaskSuccessWebhookNotifications;
$this->settings->scheduled_task_failure_webhook_notifications = $this->scheduledTaskFailureWebhookNotifications;
$this->settings->docker_cleanup_success_webhook_notifications = $this->dockerCleanupSuccessWebhookNotifications;
$this->settings->docker_cleanup_failure_webhook_notifications = $this->dockerCleanupFailureWebhookNotifications;
$this->settings->server_disk_usage_webhook_notifications = $this->serverDiskUsageWebhookNotifications;
$this->settings->server_reachable_webhook_notifications = $this->serverReachableWebhookNotifications;
$this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications;
$this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications;
$this->settings->save();
refreshSession();
} else {
$this->webhookEnabled = $this->settings->webhook_enabled;
$this->webhookUrl = $this->settings->webhook_url;
$this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications;
$this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications;
$this->statusChangeWebhookNotifications = $this->settings->status_change_webhook_notifications;
$this->backupSuccessWebhookNotifications = $this->settings->backup_success_webhook_notifications;
$this->backupFailureWebhookNotifications = $this->settings->backup_failure_webhook_notifications;
$this->scheduledTaskSuccessWebhookNotifications = $this->settings->scheduled_task_success_webhook_notifications;
$this->scheduledTaskFailureWebhookNotifications = $this->settings->scheduled_task_failure_webhook_notifications;
$this->dockerCleanupSuccessWebhookNotifications = $this->settings->docker_cleanup_success_webhook_notifications;
$this->dockerCleanupFailureWebhookNotifications = $this->settings->docker_cleanup_failure_webhook_notifications;
$this->serverDiskUsageWebhookNotifications = $this->settings->server_disk_usage_webhook_notifications;
$this->serverReachableWebhookNotifications = $this->settings->server_reachable_webhook_notifications;
$this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications;
$this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications;
}
}
public function instantSaveWebhookEnabled()
{
try {
$original = $this->webhookEnabled;
$this->validate([
'webhookUrl' => 'required',
], [
'webhookUrl.required' => 'Webhook URL is required.',
]);
$this->saveModel();
} catch (\Throwable $e) {
$this->webhookEnabled = $original;
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {
$this->resetErrorBag();
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function saveModel()
{
$this->syncData(true);
refreshSession();
if (isDev()) {
ray('Webhook settings saved', [
'webhook_enabled' => $this->settings->webhook_enabled,
'webhook_url' => $this->settings->webhook_url,
]);
}
$this->dispatch('success', 'Settings saved.');
}
public function sendTestNotification()
{
try {
$this->authorize('sendTest', $this->settings);
if (isDev()) {
ray('Sending test webhook notification', [
'team_id' => $this->team->id,
'webhook_url' => $this->settings->webhook_url,
]);
}
$this->team->notify(new Test(channel: 'webhook'));
$this->dispatch('success', 'Test notification sent.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.notifications.webhook');
}
}

View file

@ -37,7 +37,12 @@ public function submit()
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.show', $project->uuid);
$productionEnvironment = $project->environments()->where('name', 'production')->first();
return redirect()->route('project.resource.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $productionEnvironment->uuid,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -544,6 +544,9 @@ public function submit($showToaster = true)
{
try {
$this->authorize('update', $this->application);
$this->validate();
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
@ -584,7 +587,6 @@ public function submit($showToaster = true)
return;
}
}
$this->validate();
if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) {
$this->resetDefaultLabels();

View file

@ -85,6 +85,7 @@ class BackupEdit extends Component
public function mount()
{
try {
$this->authorize('view', $this->backup->database);
$this->parameters = get_route_parameters();
$this->syncData();
} catch (Exception $e) {
@ -208,7 +209,7 @@ private function customValidate()
// Validate that disable_local_backup can only be true when S3 backup is enabled
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
throw new \Exception('Local backup can only be disabled when S3 backup is enabled.');
$this->backup->disable_local_backup = $this->disableLocalBackup = false;
}
$isValid = validate_cron_expression($this->backup->frequency);

View file

@ -202,11 +202,6 @@ public function server()
public function render()
{
return view('livewire.project.database.backup-executions', [
'checkboxes' => [
['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'],
// ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently from SFTP Storage'],
],
]);
return view('livewire.project.database.backup-executions');
}
}

View file

@ -16,7 +16,7 @@ class General extends Component
{
use AuthorizesRequests;
public Server $server;
public ?Server $server = null;
public StandaloneClickhouse $database;
@ -56,8 +56,14 @@ public function getListeners()
public function mount()
{
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -3,10 +3,12 @@
namespace App\Livewire\Project\Database;
use Auth;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Configuration extends Component
{
use AuthorizesRequests;
public $currentRoute;
public $database;
@ -42,6 +44,8 @@ public function mount()
->where('uuid', request()->route('database_uuid'))
->firstOrFail();
$this->authorize('view', $database);
$this->database = $database;
$this->project = $project;
$this->environment = $environment;

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneDragonfly;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -19,7 +18,7 @@ class General extends Component
{
use AuthorizesRequests;
public Server $server;
public ?Server $server = null;
public StandaloneDragonfly $database;
@ -63,8 +62,14 @@ public function getListeners()
public function mount()
{
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
$existingCert = $this->database->sslCertificates()->first();
@ -249,13 +254,13 @@ public function regenerateSslCertificate()
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)
$caCert = $server->sslCertificates()
->where('is_ca_certificate', true)
->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {

View file

@ -131,6 +131,7 @@ public function getContainers()
if (is_null($resource)) {
abort(404);
}
$this->authorize('view', $resource);
$this->resource = $resource;
$this->server = $this->resource->destination->server;
$this->container = $this->resource->uuid;

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -19,7 +18,7 @@ class General extends Component
{
use AuthorizesRequests;
public Server $server;
public ?Server $server = null;
public StandaloneKeydb $database;
@ -59,15 +58,20 @@ public function getListeners()
return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
];
}
public function mount()
{
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
$existingCert = $this->database->sslCertificates()->first();
@ -255,7 +259,7 @@ public function regenerateSslCertificate()
return;
}
$caCert = SslCertificate::where('server_id', $existingCert->server_id)
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneMariadb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -19,12 +18,38 @@ class General extends Component
{
use AuthorizesRequests;
protected $listeners = ['refresh'];
public Server $server;
public ?Server $server = null;
public StandaloneMariadb $database;
public string $name;
public ?string $description = null;
public string $mariadbRootPassword;
public string $mariadbUser;
public string $mariadbPassword;
public string $mariadbDatabase;
public ?string $mariadbConf = null;
public string $image;
public ?string $portsMappings = null;
public ?bool $isPublic = null;
public ?int $publicPort = null;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $db_url = null;
public ?string $db_url_public = null;
@ -37,27 +62,26 @@ public function getListeners()
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
];
}
protected function rules(): array
{
return [
'database.name' => ValidationPatterns::nameRules(),
'database.description' => ValidationPatterns::descriptionRules(),
'database.mariadb_root_password' => 'required',
'database.mariadb_user' => 'required',
'database.mariadb_password' => 'required',
'database.mariadb_database' => 'required',
'database.mariadb_conf' => 'nullable',
'database.image' => 'required',
'database.ports_mappings' => 'nullable',
'database.is_public' => 'nullable|boolean',
'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable',
'database.enable_ssl' => 'boolean',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'mariadbRootPassword' => 'required',
'mariadbUser' => 'required',
'mariadbPassword' => 'required',
'mariadbDatabase' => 'required',
'mariadbConf' => 'nullable',
'image' => 'required',
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
];
}
@ -66,45 +90,96 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'database.name.required' => 'The Name field is required.',
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'database.mariadb_root_password.required' => 'The Root Password field is required.',
'database.mariadb_user.required' => 'The MariaDB User field is required.',
'database.mariadb_password.required' => 'The MariaDB Password field is required.',
'database.mariadb_database.required' => 'The MariaDB Database field is required.',
'database.image.required' => 'The Docker Image field is required.',
'database.public_port.integer' => 'The Public Port must be an integer.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'mariadbRootPassword.required' => 'The Root Password field is required.',
'mariadbUser.required' => 'The MariaDB User field is required.',
'mariadbPassword.required' => 'The MariaDB Password field is required.',
'mariadbDatabase.required' => 'The MariaDB Database field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
]
);
}
protected $validationAttributes = [
'database.name' => 'Name',
'database.description' => 'Description',
'database.mariadb_root_password' => 'Root Password',
'database.mariadb_user' => 'User',
'database.mariadb_password' => 'Password',
'database.mariadb_database' => 'Database',
'database.mariadb_conf' => 'MariaDB Configuration',
'database.image' => 'Image',
'database.ports_mappings' => 'Port Mapping',
'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Options',
'database.enable_ssl' => 'Enable SSL',
'name' => 'Name',
'description' => 'Description',
'mariadbRootPassword' => 'Root Password',
'mariadbUser' => 'User',
'mariadbPassword' => 'Password',
'mariadbDatabase' => 'Database',
'mariadbConf' => 'MariaDB Configuration',
'image' => 'Image',
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'customDockerRunOptions' => 'Custom Docker Options',
'enableSsl' => 'Enable SSL',
];
public function mount()
{
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
$this->server = data_get($this->database, 'destination.server');
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
$existingCert = $this->database->sslCertificates()->first();
return;
}
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->database->name = $this->name;
$this->database->description = $this->description;
$this->database->mariadb_root_password = $this->mariadbRootPassword;
$this->database->mariadb_user = $this->mariadbUser;
$this->database->mariadb_password = $this->mariadbPassword;
$this->database->mariadb_database = $this->mariadbDatabase;
$this->database->mariadb_conf = $this->mariadbConf;
$this->database->image = $this->image;
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->mariadbRootPassword = $this->database->mariadb_root_password;
$this->mariadbUser = $this->database->mariadb_user;
$this->mariadbPassword = $this->database->mariadb_password;
$this->mariadbDatabase = $this->database->mariadb_database;
$this->mariadbConf = $this->database->mariadb_conf;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@ -114,12 +189,12 @@ public function instantSaveAdvanced()
$this->authorize('update', $this->database);
if (! $this->server->isLogDrainEnabled()) {
$this->database->is_log_drain_enabled = false;
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (Exception $e) {
@ -132,11 +207,10 @@ public function submit()
try {
$this->authorize('update', $this->database);
if (str($this->database->public_port)->isEmpty()) {
$this->database->public_port = null;
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
$this->validate();
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -154,16 +228,16 @@ public function instantSave()
try {
$this->authorize('update', $this->database);
if ($this->database->is_public && ! $this->database->public_port) {
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
if ($this->database->is_public) {
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
@ -173,10 +247,9 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->db_url_public = $this->database->external_db_url;
$this->database->save();
$this->syncData(true);
} catch (\Throwable $e) {
$this->database->is_public = ! $this->database->is_public;
$this->isPublic = ! $this->isPublic;
return handleError($e, $this);
}
@ -187,7 +260,7 @@ public function instantSaveSSL()
try {
$this->authorize('update', $this->database);
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -207,7 +280,7 @@ public function regenerateSslCertificate()
return;
}
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
@ -231,6 +304,7 @@ public function regenerateSslCertificate()
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
public function render()

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneMongodb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -19,12 +18,38 @@ class General extends Component
{
use AuthorizesRequests;
protected $listeners = ['refresh'];
public Server $server;
public ?Server $server = null;
public StandaloneMongodb $database;
public string $name;
public ?string $description = null;
public ?string $mongoConf = null;
public string $mongoInitdbRootUsername;
public string $mongoInitdbRootPassword;
public string $mongoInitdbDatabase;
public string $image;
public ?string $portsMappings = null;
public ?bool $isPublic = null;
public ?int $publicPort = null;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?string $db_url = null;
public ?string $db_url_public = null;
@ -37,27 +62,26 @@ public function getListeners()
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
];
}
protected function rules(): array
{
return [
'database.name' => ValidationPatterns::nameRules(),
'database.description' => ValidationPatterns::descriptionRules(),
'database.mongo_conf' => 'nullable',
'database.mongo_initdb_root_username' => 'required',
'database.mongo_initdb_root_password' => 'required',
'database.mongo_initdb_database' => 'required',
'database.image' => 'required',
'database.ports_mappings' => 'nullable',
'database.is_public' => 'nullable|boolean',
'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable',
'database.enable_ssl' => 'boolean',
'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-full',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'mongoConf' => 'nullable',
'mongoInitdbRootUsername' => 'required',
'mongoInitdbRootPassword' => 'required',
'mongoInitdbDatabase' => 'required',
'image' => 'required',
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full',
];
}
@ -66,45 +90,96 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'database.name.required' => 'The Name field is required.',
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'database.mongo_initdb_root_username.required' => 'The Root Username field is required.',
'database.mongo_initdb_root_password.required' => 'The Root Password field is required.',
'database.mongo_initdb_database.required' => 'The MongoDB Database field is required.',
'database.image.required' => 'The Docker Image field is required.',
'database.public_port.integer' => 'The Public Port must be an integer.',
'database.ssl_mode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'mongoInitdbRootUsername.required' => 'The Root Username field is required.',
'mongoInitdbRootPassword.required' => 'The Root Password field is required.',
'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
]
);
}
protected $validationAttributes = [
'database.name' => 'Name',
'database.description' => 'Description',
'database.mongo_conf' => 'Mongo Configuration',
'database.mongo_initdb_root_username' => 'Root Username',
'database.mongo_initdb_root_password' => 'Root Password',
'database.mongo_initdb_database' => 'Database',
'database.image' => 'Image',
'database.ports_mappings' => 'Port Mapping',
'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Run Options',
'database.enable_ssl' => 'Enable SSL',
'database.ssl_mode' => 'SSL Mode',
'name' => 'Name',
'description' => 'Description',
'mongoConf' => 'Mongo Configuration',
'mongoInitdbRootUsername' => 'Root Username',
'mongoInitdbRootPassword' => 'Root Password',
'mongoInitdbDatabase' => 'Database',
'image' => 'Image',
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
{
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
$this->server = data_get($this->database, 'destination.server');
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
$existingCert = $this->database->sslCertificates()->first();
return;
}
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->database->name = $this->name;
$this->database->description = $this->description;
$this->database->mongo_conf = $this->mongoConf;
$this->database->mongo_initdb_root_username = $this->mongoInitdbRootUsername;
$this->database->mongo_initdb_root_password = $this->mongoInitdbRootPassword;
$this->database->mongo_initdb_database = $this->mongoInitdbDatabase;
$this->database->image = $this->image;
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->mongoConf = $this->database->mongo_conf;
$this->mongoInitdbRootUsername = $this->database->mongo_initdb_root_username;
$this->mongoInitdbRootPassword = $this->database->mongo_initdb_root_password;
$this->mongoInitdbDatabase = $this->database->mongo_initdb_database;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@ -114,12 +189,12 @@ public function instantSaveAdvanced()
$this->authorize('update', $this->database);
if (! $this->server->isLogDrainEnabled()) {
$this->database->is_log_drain_enabled = false;
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (Exception $e) {
@ -132,14 +207,13 @@ public function submit()
try {
$this->authorize('update', $this->database);
if (str($this->database->public_port)->isEmpty()) {
$this->database->public_port = null;
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
if (str($this->database->mongo_conf)->isEmpty()) {
$this->database->mongo_conf = null;
if (str($this->mongoConf)->isEmpty()) {
$this->mongoConf = null;
}
$this->validate();
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -157,16 +231,16 @@ public function instantSave()
try {
$this->authorize('update', $this->database);
if ($this->database->is_public && ! $this->database->public_port) {
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
if ($this->database->is_public) {
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
@ -176,16 +250,15 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->db_url_public = $this->database->external_db_url;
$this->database->save();
$this->syncData(true);
} catch (\Throwable $e) {
$this->database->is_public = ! $this->database->is_public;
$this->isPublic = ! $this->isPublic;
return handleError($e, $this);
}
}
public function updatedDatabaseSslMode()
public function updatedSslMode()
{
$this->instantSaveSSL();
}
@ -195,7 +268,7 @@ public function instantSaveSSL()
try {
$this->authorize('update', $this->database);
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -215,7 +288,7 @@ public function regenerateSslCertificate()
return;
}
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
@ -239,6 +312,7 @@ public function regenerateSslCertificate()
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
public function render()

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneMysql;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -19,11 +18,39 @@ class General extends Component
{
use AuthorizesRequests;
protected $listeners = ['refresh'];
public StandaloneMysql $database;
public Server $server;
public ?Server $server = null;
public string $name;
public ?string $description = null;
public string $mysqlRootPassword;
public string $mysqlUser;
public string $mysqlPassword;
public string $mysqlDatabase;
public ?string $mysqlConf = null;
public string $image;
public ?string $portsMappings = null;
public ?bool $isPublic = null;
public ?int $publicPort = null;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?string $db_url = null;
@ -37,28 +64,27 @@ public function getListeners()
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
];
}
protected function rules(): array
{
return [
'database.name' => ValidationPatterns::nameRules(),
'database.description' => ValidationPatterns::descriptionRules(),
'database.mysql_root_password' => 'required',
'database.mysql_user' => 'required',
'database.mysql_password' => 'required',
'database.mysql_database' => 'required',
'database.mysql_conf' => 'nullable',
'database.image' => 'required',
'database.ports_mappings' => 'nullable',
'database.is_public' => 'nullable|boolean',
'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable',
'database.enable_ssl' => 'boolean',
'database.ssl_mode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'mysqlRootPassword' => 'required',
'mysqlUser' => 'required',
'mysqlPassword' => 'required',
'mysqlDatabase' => 'required',
'mysqlConf' => 'nullable',
'image' => 'required',
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
];
}
@ -67,47 +93,100 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'database.name.required' => 'The Name field is required.',
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'database.mysql_root_password.required' => 'The Root Password field is required.',
'database.mysql_user.required' => 'The MySQL User field is required.',
'database.mysql_password.required' => 'The MySQL Password field is required.',
'database.mysql_database.required' => 'The MySQL Database field is required.',
'database.image.required' => 'The Docker Image field is required.',
'database.public_port.integer' => 'The Public Port must be an integer.',
'database.ssl_mode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'mysqlRootPassword.required' => 'The Root Password field is required.',
'mysqlUser.required' => 'The MySQL User field is required.',
'mysqlPassword.required' => 'The MySQL Password field is required.',
'mysqlDatabase.required' => 'The MySQL Database field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
]
);
}
protected $validationAttributes = [
'database.name' => 'Name',
'database.description' => 'Description',
'database.mysql_root_password' => 'Root Password',
'database.mysql_user' => 'User',
'database.mysql_password' => 'Password',
'database.mysql_database' => 'Database',
'database.mysql_conf' => 'MySQL Configuration',
'database.image' => 'Image',
'database.ports_mappings' => 'Port Mapping',
'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Run Options',
'database.enable_ssl' => 'Enable SSL',
'database.ssl_mode' => 'SSL Mode',
'name' => 'Name',
'description' => 'Description',
'mysqlRootPassword' => 'Root Password',
'mysqlUser' => 'User',
'mysqlPassword' => 'Password',
'mysqlDatabase' => 'Database',
'mysqlConf' => 'MySQL Configuration',
'image' => 'Image',
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
{
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
$this->server = data_get($this->database, 'destination.server');
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
$existingCert = $this->database->sslCertificates()->first();
return;
}
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->database->name = $this->name;
$this->database->description = $this->description;
$this->database->mysql_root_password = $this->mysqlRootPassword;
$this->database->mysql_user = $this->mysqlUser;
$this->database->mysql_password = $this->mysqlPassword;
$this->database->mysql_database = $this->mysqlDatabase;
$this->database->mysql_conf = $this->mysqlConf;
$this->database->image = $this->image;
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->mysqlRootPassword = $this->database->mysql_root_password;
$this->mysqlUser = $this->database->mysql_user;
$this->mysqlPassword = $this->database->mysql_password;
$this->mysqlDatabase = $this->database->mysql_database;
$this->mysqlConf = $this->database->mysql_conf;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@ -117,12 +196,12 @@ public function instantSaveAdvanced()
$this->authorize('update', $this->database);
if (! $this->server->isLogDrainEnabled()) {
$this->database->is_log_drain_enabled = false;
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (Exception $e) {
@ -135,11 +214,10 @@ public function submit()
try {
$this->authorize('update', $this->database);
if (str($this->database->public_port)->isEmpty()) {
$this->database->public_port = null;
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
$this->validate();
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -157,16 +235,16 @@ public function instantSave()
try {
$this->authorize('update', $this->database);
if ($this->database->is_public && ! $this->database->public_port) {
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
if ($this->database->is_public) {
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
@ -176,16 +254,15 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->db_url_public = $this->database->external_db_url;
$this->database->save();
$this->syncData(true);
} catch (\Throwable $e) {
$this->database->is_public = ! $this->database->is_public;
$this->isPublic = ! $this->isPublic;
return handleError($e, $this);
}
}
public function updatedDatabaseSslMode()
public function updatedSslMode()
{
$this->instantSaveSSL();
}
@ -195,7 +272,7 @@ public function instantSaveSSL()
try {
$this->authorize('update', $this->database);
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -215,7 +292,7 @@ public function regenerateSslCertificate()
return;
}
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
@ -239,6 +316,7 @@ public function regenerateSslCertificate()
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
public function render()

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandalonePostgresql;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -21,7 +20,41 @@ class General extends Component
public StandalonePostgresql $database;
public Server $server;
public ?Server $server = null;
public string $name;
public ?string $description = null;
public string $postgresUser;
public string $postgresPassword;
public string $postgresDb;
public ?string $postgresInitdbArgs = null;
public ?string $postgresHostAuthMethod = null;
public ?string $postgresConf = null;
public ?array $initScripts = null;
public string $image;
public ?string $portsMappings = null;
public ?bool $isPublic = null;
public ?int $publicPort = null;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public string $new_filename;
@ -39,7 +72,6 @@ public function getListeners()
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
'save_init_script',
'delete_init_script',
];
@ -48,23 +80,23 @@ public function getListeners()
protected function rules(): array
{
return [
'database.name' => ValidationPatterns::nameRules(),
'database.description' => ValidationPatterns::descriptionRules(),
'database.postgres_user' => 'required',
'database.postgres_password' => 'required',
'database.postgres_db' => 'required',
'database.postgres_initdb_args' => 'nullable',
'database.postgres_host_auth_method' => 'nullable',
'database.postgres_conf' => 'nullable',
'database.init_scripts' => 'nullable',
'database.image' => 'required',
'database.ports_mappings' => 'nullable',
'database.is_public' => 'nullable|boolean',
'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable',
'database.enable_ssl' => 'boolean',
'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'postgresUser' => 'required',
'postgresPassword' => 'required',
'postgresDb' => 'required',
'postgresInitdbArgs' => 'nullable',
'postgresHostAuthMethod' => 'nullable',
'postgresConf' => 'nullable',
'initScripts' => 'nullable',
'image' => 'required',
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
];
}
@ -73,48 +105,105 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'database.name.required' => 'The Name field is required.',
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'database.postgres_user.required' => 'The Postgres User field is required.',
'database.postgres_password.required' => 'The Postgres Password field is required.',
'database.postgres_db.required' => 'The Postgres Database field is required.',
'database.image.required' => 'The Docker Image field is required.',
'database.public_port.integer' => 'The Public Port must be an integer.',
'database.ssl_mode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'postgresUser.required' => 'The Postgres User field is required.',
'postgresPassword.required' => 'The Postgres Password field is required.',
'postgresDb.required' => 'The Postgres Database field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
]
);
}
protected $validationAttributes = [
'database.name' => 'Name',
'database.description' => 'Description',
'database.postgres_user' => 'Postgres User',
'database.postgres_password' => 'Postgres Password',
'database.postgres_db' => 'Postgres DB',
'database.postgres_initdb_args' => 'Postgres Initdb Args',
'database.postgres_host_auth_method' => 'Postgres Host Auth Method',
'database.postgres_conf' => 'Postgres Configuration',
'database.init_scripts' => 'Init Scripts',
'database.image' => 'Image',
'database.ports_mappings' => 'Port Mapping',
'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Run Options',
'database.enable_ssl' => 'Enable SSL',
'database.ssl_mode' => 'SSL Mode',
'name' => 'Name',
'description' => 'Description',
'postgresUser' => 'Postgres User',
'postgresPassword' => 'Postgres Password',
'postgresDb' => 'Postgres DB',
'postgresInitdbArgs' => 'Postgres Initdb Args',
'postgresHostAuthMethod' => 'Postgres Host Auth Method',
'postgresConf' => 'Postgres Configuration',
'initScripts' => 'Init Scripts',
'image' => 'Image',
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
{
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
$this->server = data_get($this->database, 'destination.server');
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
$existingCert = $this->database->sslCertificates()->first();
return;
}
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->database->name = $this->name;
$this->database->description = $this->description;
$this->database->postgres_user = $this->postgresUser;
$this->database->postgres_password = $this->postgresPassword;
$this->database->postgres_db = $this->postgresDb;
$this->database->postgres_initdb_args = $this->postgresInitdbArgs;
$this->database->postgres_host_auth_method = $this->postgresHostAuthMethod;
$this->database->postgres_conf = $this->postgresConf;
$this->database->init_scripts = $this->initScripts;
$this->database->image = $this->image;
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->postgresUser = $this->database->postgres_user;
$this->postgresPassword = $this->database->postgres_password;
$this->postgresDb = $this->database->postgres_db;
$this->postgresInitdbArgs = $this->database->postgres_initdb_args;
$this->postgresHostAuthMethod = $this->database->postgres_host_auth_method;
$this->postgresConf = $this->database->postgres_conf;
$this->initScripts = $this->database->init_scripts;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@ -124,12 +213,12 @@ public function instantSaveAdvanced()
$this->authorize('update', $this->database);
if (! $this->server->isLogDrainEnabled()) {
$this->database->is_log_drain_enabled = false;
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (Exception $e) {
@ -137,7 +226,7 @@ public function instantSaveAdvanced()
}
}
public function updatedDatabaseSslMode()
public function updatedSslMode()
{
$this->instantSaveSSL();
}
@ -147,10 +236,8 @@ public function instantSaveSSL()
try {
$this->authorize('update', $this->database);
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} catch (Exception $e) {
return handleError($e, $this);
}
@ -169,7 +256,7 @@ public function regenerateSslCertificate()
return;
}
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
@ -195,16 +282,16 @@ public function instantSave()
try {
$this->authorize('update', $this->database);
if ($this->database->is_public && ! $this->database->public_port) {
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
if ($this->database->is_public) {
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
@ -214,10 +301,9 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->db_url_public = $this->database->external_db_url;
$this->database->save();
$this->syncData(true);
} catch (\Throwable $e) {
$this->database->is_public = ! $this->database->is_public;
$this->isPublic = ! $this->isPublic;
return handleError($e, $this);
}
@ -227,7 +313,7 @@ public function save_init_script($script)
{
$this->authorize('update', $this->database);
$initScripts = collect($this->database->init_scripts ?? []);
$initScripts = collect($this->initScripts ?? []);
$existingScript = $initScripts->firstWhere('filename', $script['filename']);
$oldScript = $initScripts->firstWhere('index', $script['index']);
@ -263,7 +349,7 @@ public function save_init_script($script)
$initScripts->push($script);
}
$this->database->init_scripts = $initScripts->values()
$this->initScripts = $initScripts->values()
->map(function ($item, $index) {
$item['index'] = $index;
@ -271,7 +357,7 @@ public function save_init_script($script)
})
->all();
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Init script saved and updated.');
}
@ -279,7 +365,7 @@ public function delete_init_script($script)
{
$this->authorize('update', $this->database);
$collection = collect($this->database->init_scripts);
$collection = collect($this->initScripts);
$found = $collection->firstWhere('filename', $script['filename']);
if ($found) {
$container_name = $this->database->uuid;
@ -304,8 +390,8 @@ public function delete_init_script($script)
})
->all();
$this->database->init_scripts = $updatedScripts;
$this->database->save();
$this->initScripts = $updatedScripts;
$this->syncData(true);
$this->dispatch('refresh')->self();
$this->dispatch('success', 'Init script deleted from the database and the server.');
}
@ -319,23 +405,23 @@ public function save_new_init_script()
'new_filename' => 'required|string',
'new_content' => 'required|string',
]);
$found = collect($this->database->init_scripts)->firstWhere('filename', $this->new_filename);
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
if ($found) {
$this->dispatch('error', 'Filename already exists.');
return;
}
if (! isset($this->database->init_scripts)) {
$this->database->init_scripts = [];
if (! isset($this->initScripts)) {
$this->initScripts = [];
}
$this->database->init_scripts = array_merge($this->database->init_scripts, [
$this->initScripts = array_merge($this->initScripts, [
[
'index' => count($this->database->init_scripts),
'index' => count($this->initScripts),
'filename' => $this->new_filename,
'content' => $this->new_content,
],
]);
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Init script added.');
$this->new_content = '';
$this->new_filename = '';
@ -346,11 +432,10 @@ public function submit()
try {
$this->authorize('update', $this->database);
if (str($this->database->public_port)->isEmpty()) {
$this->database->public_port = null;
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
$this->validate();
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
} catch (Exception $e) {
return handleError($e, $this);

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -19,19 +18,39 @@ class General extends Component
{
use AuthorizesRequests;
public Server $server;
public ?Server $server = null;
public StandaloneRedis $database;
public string $redis_username;
public string $name;
public ?string $redis_password;
public ?string $description = null;
public string $redis_version;
public ?string $redisConf = null;
public ?string $db_url = null;
public string $image;
public ?string $db_url_public = null;
public ?string $portsMappings = null;
public ?bool $isPublic = null;
public ?int $publicPort = null;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
public string $redisUsername;
public string $redisPassword;
public string $redisVersion;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $enableSsl = false;
public ?Carbon $certificateValidUntil = null;
@ -42,25 +61,24 @@ public function getListeners()
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'envsUpdated' => 'refresh',
'refresh',
];
}
protected function rules(): array
{
return [
'database.name' => ValidationPatterns::nameRules(),
'database.description' => ValidationPatterns::descriptionRules(),
'database.redis_conf' => 'nullable',
'database.image' => 'required',
'database.ports_mappings' => 'nullable',
'database.is_public' => 'nullable|boolean',
'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable',
'redis_username' => 'required',
'redis_password' => 'required',
'database.enable_ssl' => 'boolean',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'redisConf' => 'nullable',
'image' => 'required',
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'redisUsername' => 'required',
'redisPassword' => 'required',
'enableSsl' => 'boolean',
];
}
@ -69,39 +87,87 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'database.name.required' => 'The Name field is required.',
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'database.image.required' => 'The Docker Image field is required.',
'database.public_port.integer' => 'The Public Port must be an integer.',
'redis_username.required' => 'The Redis Username field is required.',
'redis_password.required' => 'The Redis Password field is required.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'redisUsername.required' => 'The Redis Username field is required.',
'redisPassword.required' => 'The Redis Password field is required.',
]
);
}
protected $validationAttributes = [
'database.name' => 'Name',
'database.description' => 'Description',
'database.redis_conf' => 'Redis Configuration',
'database.image' => 'Image',
'database.ports_mappings' => 'Port Mapping',
'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Options',
'redis_username' => 'Redis Username',
'redis_password' => 'Redis Password',
'database.enable_ssl' => 'Enable SSL',
'name' => 'Name',
'description' => 'Description',
'redisConf' => 'Redis Configuration',
'image' => 'Image',
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'customDockerRunOptions' => 'Custom Docker Options',
'redisUsername' => 'Redis Username',
'redisPassword' => 'Redis Password',
'enableSsl' => 'Enable SSL',
];
public function mount()
{
$this->server = data_get($this->database, 'destination.server');
$this->refreshView();
$existingCert = $this->database->sslCertificates()->first();
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->database->name = $this->name;
$this->database->description = $this->description;
$this->database->redis_conf = $this->redisConf;
$this->database->image = $this->image;
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->redisConf = $this->database->redis_conf;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
$this->redisVersion = $this->database->getRedisVersion();
$this->redisUsername = $this->database->redis_username;
$this->redisPassword = $this->database->redis_password;
}
}
@ -111,12 +177,12 @@ public function instantSaveAdvanced()
$this->authorize('update', $this->database);
if (! $this->server->isLogDrainEnabled()) {
$this->database->is_log_drain_enabled = false;
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (Exception $e) {
@ -129,20 +195,19 @@ public function submit()
try {
$this->authorize('manageEnvironment', $this->database);
$this->validate();
$this->syncData(true);
if (version_compare($this->redis_version, '6.0', '>=')) {
if (version_compare($this->redisVersion, '6.0', '>=')) {
$this->database->runtime_environment_variables()->updateOrCreate(
['key' => 'REDIS_USERNAME'],
['value' => $this->redis_username, 'resourceable_id' => $this->database->id]
['value' => $this->redisUsername, 'resourceable_id' => $this->database->id]
);
}
$this->database->runtime_environment_variables()->updateOrCreate(
['key' => 'REDIS_PASSWORD'],
['value' => $this->redis_password, 'resourceable_id' => $this->database->id]
['value' => $this->redisPassword, 'resourceable_id' => $this->database->id]
);
$this->database->save();
$this->dispatch('success', 'Database updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -156,16 +221,16 @@ public function instantSave()
try {
$this->authorize('update', $this->database);
if ($this->database->is_public && ! $this->database->public_port) {
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
if ($this->database->is_public) {
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
@ -175,10 +240,11 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->db_url_public = $this->database->external_db_url;
$this->database->save();
$this->dbUrlPublic = $this->database->external_db_url;
$this->syncData(true);
} catch (\Throwable $e) {
$this->database->is_public = ! $this->database->is_public;
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
return handleError($e, $this);
}
@ -189,7 +255,7 @@ public function instantSaveSSL()
try {
$this->authorize('update', $this->database);
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -209,7 +275,7 @@ public function regenerateSslCertificate()
return;
}
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->commonName,
@ -233,16 +299,7 @@ public function regenerateSslCertificate()
public function refresh(): void
{
$this->database->refresh();
$this->refreshView();
}
private function refreshView()
{
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
$this->redis_version = $this->database->getRedisVersion();
$this->redis_username = $this->database->redis_username;
$this->redis_password = $this->database->redis_password;
$this->syncData();
}
public function render()

View file

@ -21,6 +21,14 @@ public function mount()
$this->projects = Project::ownedByCurrentTeam()->get()->map(function ($project) {
$project->settingsRoute = route('project.edit', ['project_uuid' => $project->uuid]);
$project->canUpdate = auth()->user()->can('update', $project);
$project->canCreateResource = auth()->user()->can('createAnyResource');
$firstEnvironment = $project->environments->first();
$project->addResourceRoute = $firstEnvironment
? route('project.resource.create', [
'project_uuid' => $project->uuid,
'environment_uuid' => $firstEnvironment->uuid,
])
: null;
return $project;
});

View file

@ -12,7 +12,11 @@
class DockerImage extends Component
{
public string $dockerImage = '';
public string $imageName = '';
public string $imageTag = '';
public string $imageSha256 = '';
public array $parameters;
@ -26,12 +30,41 @@ public function mount()
public function submit()
{
// Strip 'sha256:' prefix if user pasted it
if ($this->imageSha256) {
$this->imageSha256 = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
}
// Remove @sha256 from image name if user added it
if ($this->imageName) {
$this->imageName = preg_replace('/@sha256$/i', '', trim($this->imageName));
}
$this->validate([
'dockerImage' => 'required',
'imageName' => ['required', 'string'],
'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
]);
// Validate that either tag or sha256 is provided, but not both
if ($this->imageTag && $this->imageSha256) {
$this->addError('imageTag', 'Provide either a tag or SHA256 digest, not both.');
$this->addError('imageSha256', 'Provide either a tag or SHA256 digest, not both.');
return;
}
// Build the full Docker image string
if ($this->imageSha256) {
$dockerImage = $this->imageName.'@sha256:'.$this->imageSha256;
} elseif ($this->imageTag) {
$dockerImage = $this->imageName.':'.$this->imageTag;
} else {
$dockerImage = $this->imageName.':latest';
}
$parser = new DockerImageParser;
$parser->parse($this->dockerImage);
$parser->parse($dockerImage);
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
@ -45,6 +78,16 @@ public function submit()
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
// Determine the image tag based on whether it's a hash or regular tag
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
// Append @sha256 to image name if using digest and not already present
$imageName = $parser->getFullImageNameWithoutTag();
if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) {
$imageName .= '@sha256';
}
$application = Application::create([
'name' => 'docker-image-'.new Cuid2,
'repository_project_id' => 0,
@ -52,7 +95,7 @@ public function submit()
'git_branch' => 'main',
'build_pack' => 'dockerimage',
'ports_exposes' => 80,
'docker_registry_image_name' => $parser->getFullImageNameWithoutTag(),
'docker_registry_image_name' => $imageName,
'docker_registry_image_tag' => $parser->getTag(),
'environment_id' => $environment->id,
'destination_id' => $destination->id,

View file

@ -55,7 +55,7 @@ class GithubPrivateRepository extends Component
public ?string $publish_directory = null;
// In case of docker compose
public ?string $base_directory = null;
public ?string $base_directory = '/';
public ?string $docker_compose_location = '/docker-compose.yaml';
// End of docker compose
@ -198,6 +198,7 @@ public function submit()
'build_pack' => $this->build_pack,
'ports_exposes' => $this->port,
'publish_directory' => $this->publish_directory,
'base_directory' => $this->base_directory,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination_class,
@ -212,7 +213,6 @@ public function submit()
}
if ($this->build_pack === 'dockercompose') {
$application['docker_compose_location'] = $this->docker_compose_location;
$application['base_directory'] = $this->base_directory;
}
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->fqdn = $fqdn;

View file

@ -81,7 +81,7 @@ public function mount()
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
if ($oneClickServiceName === 'cloudflared') {
if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') {
data_set($service_payload, 'connect_to_docker_network', true);
}
$service = Service::create($service_payload);

View file

@ -33,6 +33,8 @@ public function getListeners()
return [
"echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
'refreshServices' => 'refreshServices',
'refresh' => 'refreshServices',
];
}

View file

@ -73,6 +73,7 @@ public function submit()
}
$this->application->service->parse();
$this->dispatch('refresh');
$this->dispatch('refreshServices');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
$originalFqdn = $this->application->getOriginal('fqdn');

View file

@ -34,6 +34,8 @@ class FileStorage extends Component
public bool $permanently_delete = true;
public bool $isReadOnly = false;
protected $rules = [
'fileStorage.is_directory' => 'required',
'fileStorage.fs_path' => 'required',
@ -52,6 +54,8 @@ public function mount()
$this->workdir = null;
$this->fs_path = $this->fileStorage->fs_path;
}
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
}
public function convertToDirectory()

View file

@ -212,6 +212,12 @@ private function handleSingleSubmit($data)
$environment = $this->createEnvironmentVariable($data);
$environment->order = $maxOrder + 1;
$environment->save();
// Clear computed property cache to force refresh
unset($this->environmentVariables);
unset($this->environmentVariablesPreview);
$this->dispatch('success', 'Environment variable added.');
}
private function createEnvironmentVariable($data)
@ -300,6 +306,9 @@ private function updateOrCreateVariables($isPreview, $variables)
public function refreshEnvs()
{
$this->resource->refresh();
// Clear computed property cache to force refresh
unset($this->environmentVariables);
unset($this->environmentVariablesPreview);
$this->getDevView();
}
}

View file

@ -37,6 +37,11 @@ class Show extends Component
'host_path' => 'host',
];
public function mount()
{
$this->isReadOnly = $this->storage->isReadOnlyVolume();
}
public function submit()
{
$this->authorize('update', $this->resource);

View file

@ -0,0 +1,101 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudInitScript;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class CloudInitScriptForm extends Component
{
use AuthorizesRequests;
public bool $modal_mode = true;
public ?int $scriptId = null;
public string $name = '';
public string $script = '';
public function mount(?int $scriptId = null)
{
if ($scriptId) {
$this->scriptId = $scriptId;
$cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
$this->authorize('update', $cloudInitScript);
$this->name = $cloudInitScript->name;
$this->script = $cloudInitScript->script;
} else {
$this->authorize('create', CloudInitScript::class);
}
}
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml],
];
}
protected function messages(): array
{
return [
'name.required' => 'Script name is required.',
'name.max' => 'Script name cannot exceed 255 characters.',
'script.required' => 'Cloud-init script content is required.',
];
}
public function save()
{
$this->validate();
try {
if ($this->scriptId) {
// Update existing script
$cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($this->scriptId);
$this->authorize('update', $cloudInitScript);
$cloudInitScript->update([
'name' => $this->name,
'script' => $this->script,
]);
$message = 'Cloud-init script updated successfully.';
} else {
// Create new script
$this->authorize('create', CloudInitScript::class);
CloudInitScript::create([
'team_id' => currentTeam()->id,
'name' => $this->name,
'script' => $this->script,
]);
$message = 'Cloud-init script created successfully.';
}
// Only reset fields if creating (not editing)
if (! $this->scriptId) {
$this->reset(['name', 'script']);
}
$this->dispatch('scriptSaved');
$this->dispatch('success', $message);
if ($this->modal_mode) {
$this->dispatch('closeModal');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-init-script-form');
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudInitScript;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class CloudInitScripts extends Component
{
use AuthorizesRequests;
public $scripts;
public function mount()
{
$this->authorize('viewAny', CloudInitScript::class);
$this->loadScripts();
}
public function getListeners()
{
return [
'scriptSaved' => 'loadScripts',
];
}
public function loadScripts()
{
$this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get();
}
public function deleteScript(int $scriptId)
{
try {
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
$this->authorize('delete', $script);
$script->delete();
$this->loadScripts();
$this->dispatch('success', 'Cloud-init script deleted successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-init-scripts');
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudProviderToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
use Livewire\Component;
class CloudProviderTokenForm extends Component
{
use AuthorizesRequests;
public bool $modal_mode = false;
public string $provider = 'hetzner';
public string $token = '';
public string $name = '';
public function mount()
{
$this->authorize('create', CloudProviderToken::class);
}
protected function rules(): array
{
return [
'provider' => 'required|string|in:hetzner,digitalocean',
'token' => 'required|string',
'name' => 'required|string|max:255',
];
}
protected function messages(): array
{
return [
'provider.required' => 'Please select a cloud provider.',
'provider.in' => 'Invalid cloud provider selected.',
'token.required' => 'API token is required.',
'name.required' => 'Token name is required.',
];
}
private function validateToken(string $provider, string $token): bool
{
try {
if ($provider === 'hetzner') {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
ray($response);
return $response->successful();
}
// Add other providers here in the future
// if ($provider === 'digitalocean') { ... }
return false;
} catch (\Throwable $e) {
return false;
}
}
public function addToken()
{
$this->validate();
try {
// Validate the token with the provider's API
if (! $this->validateToken($this->provider, $this->token)) {
return $this->dispatch('error', 'Invalid API token. Please check your token and try again.');
}
$savedToken = CloudProviderToken::create([
'team_id' => currentTeam()->id,
'provider' => $this->provider,
'token' => $this->token,
'name' => $this->name,
]);
$this->reset(['token', 'name']);
// Dispatch event with token ID so parent components can react
$this->dispatch('tokenAdded', tokenId: $savedToken->id);
$this->dispatch('success', 'Cloud provider token added successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-provider-token-form');
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudProviderToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class CloudProviderTokens extends Component
{
use AuthorizesRequests;
public $tokens;
public function mount()
{
$this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens();
}
public function getListeners()
{
return [
'tokenAdded' => 'loadTokens',
];
}
public function loadTokens()
{
$this->tokens = CloudProviderToken::ownedByCurrentTeam()->get();
}
public function deleteToken(int $tokenId)
{
try {
$token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId);
$this->authorize('delete', $token);
// Check if any servers are using this token
if ($token->hasServers()) {
$serverCount = $token->servers()->count();
$this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first.");
return;
}
$token->delete();
$this->loadTokens();
$this->dispatch('success', 'Cloud provider token deleted successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-provider-tokens');
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Security;
use Livewire\Component;
class CloudTokens extends Component
{
public function render()
{
return view('livewire.security.cloud-tokens');
}
}

View file

@ -21,6 +21,8 @@ class Create extends Component
public ?string $publicKey = null;
public bool $modal_mode = false;
protected function rules(): array
{
return [
@ -77,6 +79,14 @@ public function createPrivateKey()
'team_id' => currentTeam()->id,
]);
// If in modal mode, dispatch event and don't redirect
if ($this->modal_mode) {
$this->dispatch('privateKeyCreated', keyId: $privateKey->id);
$this->dispatch('success', 'Private key created successfully.');
return;
}
return $this->redirectAfterCreation($privateKey);
} catch (\Throwable $e) {
return handleError($e, $this);

View file

@ -39,7 +39,7 @@ public function mount(string $server_uuid)
public function loadCaCertificate()
{
$this->caCertificate = SslCertificate::where('server_id', $this->server->id)->where('is_ca_certificate', true)->first();
$this->caCertificate = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if ($this->caCertificate) {
$this->certificateContent = $this->caCertificate->ssl_certificate;

View file

@ -0,0 +1,144 @@
<?php
namespace App\Livewire\Server\CloudProviderToken;
use App\Models\CloudProviderToken;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public Server $server;
public $cloudProviderTokens = [];
public $parameters = [];
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->loadTokens();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function getListeners()
{
return [
'tokenAdded' => 'handleTokenAdded',
];
}
public function loadTokens()
{
$this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->get();
}
public function handleTokenAdded($tokenId)
{
$this->loadTokens();
}
public function setCloudProviderToken($tokenId)
{
$ownedToken = CloudProviderToken::ownedByCurrentTeam()->find($tokenId);
if (is_null($ownedToken)) {
$this->dispatch('error', 'You are not allowed to use this token.');
return;
}
try {
$this->authorize('update', $this->server);
// Validate the token works and can access this specific server
$validationResult = $this->validateTokenForServer($ownedToken);
if (! $validationResult['valid']) {
$this->dispatch('error', $validationResult['error']);
return;
}
$this->server->cloudProviderToken()->associate($ownedToken);
$this->server->save();
$this->dispatch('success', 'Hetzner token updated successfully.');
$this->dispatch('refreshServerShow');
} catch (\Exception $e) {
$this->server->refresh();
$this->dispatch('error', $e->getMessage());
}
}
private function validateTokenForServer(CloudProviderToken $token): array
{
try {
// First, validate the token itself
$response = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
if (! $response->successful()) {
return [
'valid' => false,
'error' => 'This token is invalid or has insufficient permissions.',
];
}
// Check if this token can access the specific Hetzner server
if ($this->server->hetzner_server_id) {
$serverResponse = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}");
if (! $serverResponse->successful()) {
return [
'valid' => false,
'error' => 'This token cannot access this server. It may belong to a different Hetzner project.',
];
}
}
return ['valid' => true];
} catch (\Throwable $e) {
return [
'valid' => false,
'error' => 'Failed to validate token: '.$e->getMessage(),
];
}
}
public function validateToken()
{
try {
$token = $this->server->cloudProviderToken;
if (! $token) {
$this->dispatch('error', 'No Hetzner token is associated with this server.');
return;
}
$response = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
if ($response->successful()) {
$this->dispatch('success', 'Hetzner token is valid and working.');
} else {
$this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.cloud-provider-token.show');
}
}

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Server;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Team;
use Livewire\Component;
@ -12,6 +13,8 @@ class Create extends Component
public bool $limit_reached = false;
public bool $has_hetzner_tokens = false;
public function mount()
{
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
@ -21,6 +24,11 @@ public function mount()
return;
}
$this->limit_reached = Team::serverLimitReached();
// Check if user has Hetzner tokens
$this->has_hetzner_tokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->exists();
}
public function render()

View file

@ -16,6 +16,8 @@ class Delete extends Component
public Server $server;
public bool $delete_from_hetzner = false;
public function mount(string $server_uuid)
{
try {
@ -41,8 +43,15 @@ public function delete($password)
return;
}
$this->server->delete();
DeleteServer::dispatch($this->server);
DeleteServer::dispatch(
$this->server->id,
$this->delete_from_hetzner,
$this->server->hetzner_server_id,
$this->server->cloud_provider_token_id,
$this->server->team_id
);
return redirect()->route('server.index');
} catch (\Throwable $e) {
@ -52,6 +61,18 @@ public function delete($password)
public function render()
{
return view('livewire.server.delete');
$checkboxes = [];
if ($this->server->hetzner_server_id) {
$checkboxes[] = [
'id' => 'delete_from_hetzner',
'label' => 'Also delete server from Hetzner Cloud',
'default_warning' => 'The actual server on Hetzner Cloud will NOT be deleted.',
];
}
return view('livewire.server.delete', [
'checkboxes' => $checkboxes,
]);
}
}

View file

@ -118,17 +118,31 @@ public function checkProxyStatus()
public function showNotification()
{
$this->server->refresh();
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
$forceStop = $this->server->proxy->force_stop ?? false;
switch ($this->proxyStatus) {
case 'running':
$this->loadProxyConfiguration();
$this->dispatch('success', 'Proxy is running.');
break;
case 'restarting':
$this->dispatch('info', 'Initiating proxy restart.');
break;
case 'exited':
$this->dispatch('info', 'Proxy has exited.');
break;
case 'stopping':
$this->dispatch('info', 'Proxy is stopping.');
break;
case 'starting':
$this->dispatch('info', 'Proxy is starting.');
break;
case 'unknown':
$this->dispatch('info', 'Proxy status is unknown.');
break;
default:
$this->dispatch('info', 'Proxy status updated.');
break;
}

View file

@ -0,0 +1,565 @@
<?php
namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidHostname;
use App\Services\HetznerService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ByHetzner extends Component
{
use AuthorizesRequests;
// Step tracking
public int $current_step = 1;
// Locked data
#[Locked]
public Collection $available_tokens;
#[Locked]
public $private_keys;
#[Locked]
public $limit_reached;
// Step 1: Token selection
public ?int $selected_token_id = null;
// Step 2: Server configuration
public array $locations = [];
public array $images = [];
public array $serverTypes = [];
public array $hetznerSshKeys = [];
public ?string $selected_location = null;
public ?int $selected_image = null;
public ?string $selected_server_type = null;
public array $selectedHetznerSshKeyIds = [];
public string $server_name = '';
public ?int $private_key_id = null;
public bool $loading_data = false;
public bool $enable_ipv4 = true;
public bool $enable_ipv6 = true;
public ?string $cloud_init_script = null;
public bool $save_cloud_init_script = false;
public ?string $cloud_init_script_name = null;
public ?int $selected_cloud_init_script_id = null;
#[Locked]
public Collection $saved_cloud_init_scripts;
public function mount()
{
$this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens();
$this->loadSavedCloudInitScripts();
$this->server_name = generate_random_name();
if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id;
}
}
public function loadSavedCloudInitScripts()
{
$this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get();
}
public function getListeners()
{
return [
'tokenAdded' => 'handleTokenAdded',
'privateKeyCreated' => 'handlePrivateKeyCreated',
'modalClosed' => 'resetSelection',
];
}
public function resetSelection()
{
$this->selected_token_id = null;
$this->current_step = 1;
$this->cloud_init_script = null;
$this->save_cloud_init_script = false;
$this->cloud_init_script_name = null;
$this->selected_cloud_init_script_id = null;
}
public function loadTokens()
{
$this->available_tokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->get();
}
public function handleTokenAdded($tokenId)
{
// Refresh token list
$this->loadTokens();
// Auto-select the new token
$this->selected_token_id = $tokenId;
// Automatically proceed to next step
$this->nextStep();
}
public function handlePrivateKeyCreated($keyId)
{
// Refresh private keys list
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
// Auto-select the new key
$this->private_key_id = $keyId;
// Clear validation errors for private_key_id
$this->resetErrorBag('private_key_id');
}
protected function rules(): array
{
$rules = [
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
];
if ($this->current_step === 2) {
$rules = array_merge($rules, [
'server_name' => ['required', 'string', 'max:253', new ValidHostname],
'selected_location' => 'required|string',
'selected_image' => 'required|integer',
'selected_server_type' => 'required|string',
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
'selectedHetznerSshKeyIds' => 'nullable|array',
'selectedHetznerSshKeyIds.*' => 'integer',
'enable_ipv4' => 'required|boolean',
'enable_ipv6' => 'required|boolean',
'cloud_init_script' => ['nullable', 'string', new \App\Rules\ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
]);
}
return $rules;
}
protected function messages(): array
{
return [
'selected_token_id.required' => 'Please select a Hetzner token.',
'selected_token_id.exists' => 'Selected token not found.',
];
}
public function selectToken(int $tokenId)
{
$this->selected_token_id = $tokenId;
}
private function validateHetznerToken(string $token): bool
{
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
return $response->successful();
} catch (\Throwable $e) {
return false;
}
}
private function getHetznerToken(): string
{
if ($this->selected_token_id) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
return $token ? $token->token : '';
}
return '';
}
public function nextStep()
{
// Validate step 1 - just need a token selected
$this->validate([
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
]);
try {
$hetznerToken = $this->getHetznerToken();
if (! $hetznerToken) {
return $this->dispatch('error', 'Please select a valid Hetzner token.');
}
// Load Hetzner data
$this->loadHetznerData($hetznerToken);
// Move to step 2
$this->current_step = 2;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function previousStep()
{
$this->current_step = 1;
}
private function loadHetznerData(string $token)
{
$this->loading_data = true;
try {
$hetznerService = new HetznerService($token);
$this->locations = $hetznerService->getLocations();
$this->serverTypes = $hetznerService->getServerTypes();
// Get images and sort by name
$images = $hetznerService->getImages();
ray('Raw images from Hetzner API', [
'total_count' => count($images),
'types' => collect($images)->pluck('type')->unique()->values(),
'sample' => array_slice($images, 0, 3),
]);
$this->images = collect($images)
->filter(function ($image) {
// Only system images
if (! isset($image['type']) || $image['type'] !== 'system') {
return false;
}
// Filter out deprecated images
if (isset($image['deprecated']) && $image['deprecated'] === true) {
return false;
}
return true;
})
->sortBy('name')
->values()
->toArray();
ray('Filtered images', [
'filtered_count' => count($this->images),
'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(),
]);
// Load SSH keys from Hetzner
$this->hetznerSshKeys = $hetznerService->getSshKeys();
ray('Hetzner SSH Keys', [
'total_count' => count($this->hetznerSshKeys),
'keys' => $this->hetznerSshKeys,
]);
$this->loading_data = false;
} catch (\Throwable $e) {
$this->loading_data = false;
throw $e;
}
}
public function getAvailableServerTypesProperty()
{
ray('Getting available server types', [
'selected_location' => $this->selected_location,
'total_server_types' => count($this->serverTypes),
]);
if (! $this->selected_location) {
return $this->serverTypes;
}
$filtered = collect($this->serverTypes)
->filter(function ($type) {
if (! isset($type['locations'])) {
return false;
}
$locationNames = collect($type['locations'])->pluck('name')->toArray();
return in_array($this->selected_location, $locationNames);
})
->values()
->toArray();
ray('Filtered server types', [
'selected_location' => $this->selected_location,
'filtered_count' => count($filtered),
]);
return $filtered;
}
public function getAvailableImagesProperty()
{
ray('Getting available images', [
'selected_server_type' => $this->selected_server_type,
'total_images' => count($this->images),
'images' => $this->images,
]);
if (! $this->selected_server_type) {
return $this->images;
}
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
ray('Server type data', $serverType);
if (! $serverType || ! isset($serverType['architecture'])) {
ray('No architecture in server type, returning all');
return $this->images;
}
$architecture = $serverType['architecture'];
$filtered = collect($this->images)
->filter(fn ($image) => ($image['architecture'] ?? null) === $architecture)
->values()
->toArray();
ray('Filtered images', [
'architecture' => $architecture,
'filtered_count' => count($filtered),
]);
return $filtered;
}
public function getSelectedServerPriceProperty(): ?string
{
if (! $this->selected_server_type) {
return null;
}
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) {
return null;
}
$price = $serverType['prices'][0]['price_monthly']['gross'];
return '€'.number_format($price, 2);
}
public function updatedSelectedLocation($value)
{
ray('Location selected', $value);
// Reset server type and image when location changes
$this->selected_server_type = null;
$this->selected_image = null;
}
public function updatedSelectedServerType($value)
{
ray('Server type selected', $value);
// Reset image when server type changes
$this->selected_image = null;
}
public function updatedSelectedImage($value)
{
ray('Image selected', $value);
}
public function updatedSelectedCloudInitScriptId($value)
{
if ($value) {
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value);
$this->cloud_init_script = $script->script;
$this->cloud_init_script_name = $script->name;
}
}
public function clearCloudInitScript()
{
$this->selected_cloud_init_script_id = null;
$this->cloud_init_script = '';
$this->cloud_init_script_name = '';
$this->save_cloud_init_script = false;
}
private function createHetznerServer(string $token): array
{
$hetznerService = new HetznerService($token);
// Get the private key and extract public key
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
$publicKey = $privateKey->getPublicKey();
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
ray('Private Key Info', [
'private_key_id' => $this->private_key_id,
'sha256_fingerprint' => $privateKey->fingerprint,
'md5_fingerprint' => $md5Fingerprint,
]);
// Check if SSH key already exists on Hetzner by comparing MD5 fingerprints
$existingSshKeys = $hetznerService->getSshKeys();
$existingKey = null;
ray('Existing SSH Keys on Hetzner', $existingSshKeys);
foreach ($existingSshKeys as $key) {
if ($key['fingerprint'] === $md5Fingerprint) {
$existingKey = $key;
break;
}
}
// Upload SSH key if it doesn't exist
if ($existingKey) {
$sshKeyId = $existingKey['id'];
ray('Using existing SSH key', ['ssh_key_id' => $sshKeyId]);
} else {
$sshKeyName = $privateKey->name;
$uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey);
$sshKeyId = $uploadedKey['id'];
ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]);
}
// Normalize server name to lowercase for RFC 1123 compliance
$normalizedServerName = strtolower(trim($this->server_name));
// Prepare SSH keys array: Coolify key + user-selected Hetzner keys
$sshKeys = array_merge(
[$sshKeyId], // Coolify key (always included)
$this->selectedHetznerSshKeyIds // User-selected Hetzner keys
);
// Remove duplicates in case the Coolify key was also selected
$sshKeys = array_unique($sshKeys);
$sshKeys = array_values($sshKeys); // Re-index array
// Prepare server creation parameters
$params = [
'name' => $normalizedServerName,
'server_type' => $this->selected_server_type,
'image' => $this->selected_image,
'location' => $this->selected_location,
'start_after_create' => true,
'ssh_keys' => $sshKeys,
'public_net' => [
'enable_ipv4' => $this->enable_ipv4,
'enable_ipv6' => $this->enable_ipv6,
],
];
// Add cloud-init script if provided
if (! empty($this->cloud_init_script)) {
$params['user_data'] = $this->cloud_init_script;
}
ray('Server creation parameters', $params);
// Create server on Hetzner
$hetznerServer = $hetznerService->createServer($params);
ray('Hetzner server created', $hetznerServer);
return $hetznerServer;
}
public function submit()
{
$this->validate();
try {
$this->authorize('create', Server::class);
if (Team::serverLimitReached()) {
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
}
// Save cloud-init script if requested
if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) {
$this->authorize('create', CloudInitScript::class);
CloudInitScript::create([
'team_id' => currentTeam()->id,
'name' => $this->cloud_init_script_name,
'script' => $this->cloud_init_script,
]);
}
$hetznerToken = $this->getHetznerToken();
// Create server on Hetzner
$hetznerServer = $this->createHetznerServer($hetznerToken);
// Determine IP address to use (prefer IPv4, fallback to IPv6)
$ipAddress = null;
if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
$ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
} elseif ($this->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
}
if (! $ipAddress) {
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
}
// Create server in Coolify database
$server = Server::create([
'name' => $this->server_name,
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'hetzner_server_id' => $hetznerServer['id'],
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
return redirect()->route('server.show', $server->uuid);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.new.by-hetzner');
}
}

View file

@ -67,13 +67,21 @@ class Show extends Component
public string $serverTimezone;
public ?string $hetznerServerStatus = null;
public bool $hetznerServerManuallyStarted = false;
public bool $isValidating = false;
public function getListeners()
{
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
return [
'refreshServerShow' => 'refresh',
'refreshServer' => '$refresh',
"echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted',
"echo-private:team.{$teamId},ServerValidated" => 'handleServerValidated',
];
}
@ -138,6 +146,10 @@ public function mount(string $server_uuid)
if (! $this->server->isEmpty()) {
$this->isBuildServerLocked = true;
}
// Load saved Hetzner status and validation state
$this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->isValidating = $this->server->is_validating ?? false;
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -218,6 +230,7 @@ public function syncData(bool $toModel = false)
$this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled;
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
$this->serverTimezone = $this->server->settings->server_timezone;
$this->isValidating = $this->server->is_validating ?? false;
}
}
@ -361,6 +374,87 @@ public function instantSave()
}
}
public function checkHetznerServerStatus(bool $manual = false)
{
try {
if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.');
return;
}
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = $serverData['status'] ?? null;
// Save status to database without triggering model events
if ($this->server->hetzner_server_status !== $this->hetznerServerStatus) {
$this->server->hetzner_server_status = $this->hetznerServerStatus;
$this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]);
}
if ($manual) {
$this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown'));
}
// If Hetzner server is off but Coolify thinks it's still reachable, update Coolify's state
if ($this->hetznerServerStatus === 'off' && $this->server->settings->is_reachable) {
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if ($uptime) {
$this->dispatch('success', 'Server is reachable.');
$this->server->settings->is_reachable = $this->isReachable = true;
$this->server->settings->is_usable = $this->isUsable = true;
$this->server->settings->save();
ServerReachabilityChanged::dispatch($this->server);
} else {
$this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.<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);
return;
}
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function handleServerValidated($event = null)
{
// Check if event is for this server
if ($event && isset($event['serverUuid']) && $event['serverUuid'] !== $this->server->uuid) {
return;
}
// Refresh server data
$this->server->refresh();
$this->syncData();
// Update validation state
$this->isValidating = $this->server->is_validating ?? false;
$this->dispatch('refreshServerShow');
$this->dispatch('refreshServer');
}
public function startHetznerServer()
{
try {
if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.');
return;
}
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$hetznerService->powerOnServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = 'starting';
$this->server->update(['hetzner_server_status' => 'starting']);
$this->hetznerServerManuallyStarted = true; // Set flag to trigger auto-validation when running
$this->dispatch('success', 'Hetzner server is starting...');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {

View file

@ -4,6 +4,7 @@
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Events\ServerValidated;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -63,6 +64,19 @@ public function startValidatingAfterAsking()
$this->init();
}
public function retry()
{
$this->authorize('update', $this->server);
$this->uptime = null;
$this->supported_os_type = null;
$this->docker_installed = null;
$this->docker_compose_installed = null;
$this->docker_version = null;
$this->error = null;
$this->number_of_tries = 0;
$this->init();
}
public function validateConnection()
{
$this->authorize('update', $this->server);
@ -136,8 +150,12 @@ public function validateDockerVersion()
} else {
$this->docker_version = $this->server->validateDockerEngineVersion();
if ($this->docker_version) {
// Mark validation as complete
$this->server->update(['is_validating' => false]);
$this->dispatch('refreshServerShow');
$this->dispatch('refreshBoardingIndex');
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
$this->dispatch('success', 'Server validated, proxy is starting in a moment.');
$proxyShouldRun = CheckProxy::run($this->server, true);
if (! $proxyShouldRun) {

View file

@ -120,6 +120,8 @@ public function addCoolifyDatabase()
public function submit()
{
$this->validate();
$this->database->update([
'name' => $this->name,
'description' => $this->description,

View file

@ -5,6 +5,7 @@
use App\Models\S3Storage;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Form extends Component
@ -91,9 +92,24 @@ public function submit()
try {
$this->authorize('update', $this->storage);
$this->validate();
$this->testConnection();
DB::transaction(function () {
$this->validate();
$this->storage->save();
// Test connection with new values - if this fails, transaction will rollback
$this->storage->testConnection(shouldSave: false);
// If we get here, the connection test succeeded
$this->storage->is_usable = true;
$this->storage->unusable_email_sent = false;
$this->storage->save();
});
$this->dispatch('success', 'Storage settings updated and connection verified.');
} catch (\Throwable $e) {
// Refresh the model to revert UI to database values after rollback
$this->storage->refresh();
return handleError($e, $this);
}
}

View file

@ -35,7 +35,7 @@ public function submit()
'personal_team' => false,
]);
auth()->user()->teams()->attach($team, ['role' => 'admin']);
refreshSession();
refreshSession($team);
return redirect()->route('team.index');
} catch (\Throwable $e) {

View file

@ -61,6 +61,10 @@ private function getAllActiveContainers()
public function updatedSelectedUuid()
{
if ($this->selected_uuid === 'default') {
// When cleared to default, do nothing (no error message)
return;
}
$this->connectToContainer();
}

View file

@ -182,6 +182,21 @@ protected static function booted()
]);
$application->compose_parsing_version = self::$parserVersion;
$application->save();
// Add default NIXPACKS_NODE_VERSION environment variable for Nixpacks applications
if ($application->build_pack === 'nixpacks') {
EnvironmentVariable::create([
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_multiline' => false,
'is_literal' => false,
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
}
});
static::forceDeleting(function ($application) {
$application->update(['fqdn' => null]);
@ -739,9 +754,9 @@ public function environment_variables()
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
CASE
WHEN is_required = true THEN 1
WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC
@ -767,9 +782,9 @@ public function environment_variables_preview()
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
CASE
WHEN is_required = true THEN 1
WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC

View file

@ -41,11 +41,9 @@ class ApplicationDeploymentQueue extends Model
{
protected $guarded = [];
public function application(): Attribute
public function application()
{
return Attribute::make(
get: fn () => Application::find($this->application_id),
);
return $this->belongsTo(Application::class);
}
public function server(): Attribute

View file

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CloudInitScript extends Model
{
protected $fillable = [
'team_id',
'name',
'script',
];
protected function casts(): array
{
return [
'script' => 'encrypted',
];
}
public function team()
{
return $this->belongsTo(Team::class);
}
public static function ownedByCurrentTeam(array $select = ['*'])
{
$selectArray = collect($select)->concat(['id']);
return self::whereTeamId(currentTeam()->id)->select($selectArray->all());
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CloudProviderToken extends Model
{
protected $guarded = [];
protected $casts = [
'token' => 'encrypted',
];
public function team()
{
return $this->belongsTo(Team::class);
}
public function servers()
{
return $this->hasMany(Server::class);
}
public function hasServers(): bool
{
return $this->servers()->exists();
}
public static function ownedByCurrentTeam(array $select = ['*'])
{
$selectArray = collect($select)->concat(['id']);
return self::whereTeamId(currentTeam()->id)->select($selectArray->all());
}
public function scopeForProvider($query, string $provider)
{
return $query->where('provider', $provider);
}
}

View file

@ -35,6 +35,11 @@ protected static function booted()
});
}
public static function ownedByCurrentTeam()
{
return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name');
}
public function isEmpty()
{
return $this->applications()->count() == 0 &&

View file

@ -5,6 +5,7 @@
use App\Events\FileStorageChanged;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Symfony\Component\Yaml\Yaml;
class LocalFileVolume extends BaseModel
{
@ -192,4 +193,61 @@ public function scopeWherePlainMountPath($query, $path)
{
return $query->get()->where('plain_mount_path', $path);
}
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
try {
// Only check for services
$service = $this->service;
if (! $service || ! method_exists($service, 'service')) {
return false;
}
$actualService = $service->service;
if (! $actualService || ! $actualService->docker_compose_raw) {
return false;
}
// Parse the docker-compose content
$compose = Yaml::parse($actualService->docker_compose_raw);
if (! isset($compose['services'])) {
return false;
}
// Find the service that this volume belongs to
$serviceName = $service->name;
if (! isset($compose['services'][$serviceName]['volumes'])) {
return false;
}
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
$parts = explode(':', $volume);
// Check if this volume matches our fs_path and mount_path
if (count($parts) >= 2) {
$hostPath = $parts[0];
$containerPath = $parts[1];
$options = $parts[2] ?? null;
// Match based on fs_path and mount_path
if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) {
return $options === 'ro';
}
}
}
}
return false;
} catch (\Throwable $e) {
ray($e->getMessage(), 'Error checking read-only volume');
return false;
}
}
}

View file

@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends Model
{
@ -48,4 +49,69 @@ protected function hostPath(): Attribute
}
);
}
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
try {
// Get the resource (can be application, service, or database)
$resource = $this->resource;
if (! $resource) {
return false;
}
// Only check for services
if (! method_exists($resource, 'service')) {
return false;
}
$actualService = $resource->service;
if (! $actualService || ! $actualService->docker_compose_raw) {
return false;
}
// Parse the docker-compose content
$compose = Yaml::parse($actualService->docker_compose_raw);
if (! isset($compose['services'])) {
return false;
}
// Find the service that this volume belongs to
$serviceName = $resource->name;
if (! isset($compose['services'][$serviceName]['volumes'])) {
return false;
}
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
$parts = explode(':', $volume);
// Check if this volume matches our mount_path
if (count($parts) >= 2) {
$containerPath = $parts[1];
$options = $parts[2] ?? null;
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str($this->mount_path)->ltrim('/')->toString();
$containerPathClean = str($containerPath)->ltrim('/')->toString();
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $options === 'ro';
}
}
}
}
return false;
} catch (\Throwable $e) {
ray($e->getMessage(), 'Error checking read-only persistent volume');
return false;
}
}
}

View file

@ -289,6 +289,17 @@ public static function generateFingerprint($privateKey)
}
}
public static function generateMd5Fingerprint($privateKey)
{
try {
$key = PublicKeyLoader::load($privateKey);
return $key->getPublicKey()->getFingerprint('md5');
} catch (\Throwable $e) {
return null;
}
}
public static function fingerprintExists($fingerprint, $excludeId = null)
{
$query = self::query()

View file

@ -8,6 +8,15 @@ class ScheduledDatabaseBackupExecution extends BaseModel
{
protected $guarded = [];
protected function casts(): array
{
return [
's3_uploaded' => 'boolean',
'local_storage_deleted' => 'boolean',
's3_storage_deleted' => 'boolean',
];
}
public function scheduledDatabaseBackup(): BelongsTo
{
return $this->belongsTo(ScheduledDatabaseBackup::class);

View file

@ -136,6 +136,7 @@ protected static function booted()
$destination->delete();
});
$server->settings()->delete();
$server->sslCertificates()->delete();
});
}
@ -161,7 +162,11 @@ protected static function booted()
'user',
'description',
'private_key_id',
'cloud_provider_token_id',
'team_id',
'hetzner_server_id',
'hetzner_server_status',
'is_validating',
];
protected $guarded = [];
@ -889,6 +894,16 @@ public function privateKey()
return $this->belongsTo(PrivateKey::class);
}
public function cloudProviderToken()
{
return $this->belongsTo(CloudProviderToken::class);
}
public function sslCertificates()
{
return $this->hasMany(SslCertificate::class);
}
public function muxFilename()
{
return 'mux_'.$this->uuid;
@ -1327,7 +1342,7 @@ public function generateCaCertificate()
isCaCertificate: true,
validityDays: 10 * 365
);
$caCertificate = SslCertificate::where('server_id', $this->id)->where('is_ca_certificate', true)->first();
$caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first();
ray('CA certificate generated', $caCertificate);
if ($caCertificate) {
$certificateContent = $caCertificate->ssl_certificate;

View file

@ -547,6 +547,21 @@ public function extraFields()
}
$fields->put('Grafana', $data->toArray());
break;
case $image->contains('elasticsearch'):
$data = collect([]);
$elastic_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ELASTICSEARCH')->first();
if ($elastic_password) {
$data = $data->merge([
'Password (default user: elastic)' => [
'key' => data_get($elastic_password, 'key'),
'value' => data_get($elastic_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Elasticsearch', $data->toArray());
break;
case $image->contains('directus'):
$data = collect([]);
$admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first();
@ -1231,9 +1246,9 @@ public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
CASE
WHEN is_required = true THEN 1
WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC

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