Merge branch 'coollabsio:next' into next
This commit is contained in:
commit
a02240d143
331 changed files with 24497 additions and 10900 deletions
156
.AI_INSTRUCTIONS_SYNC.md
Normal file
156
.AI_INSTRUCTIONS_SYNC.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
24
.github/workflows/cleanup-ghcr-untagged.yml
vendored
Normal file
24
.github/workflows/cleanup-ghcr-untagged.yml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
name: Cleanup Untagged GHCR Images
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # Run every 6 hours to handle large volume (16k+ images)
|
||||
|
||||
env:
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
cleanup-testing-host:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Delete untagged coolify-testing-host images
|
||||
uses: actions/delete-package-versions@v5
|
||||
with:
|
||||
package-name: 'coolify-testing-host'
|
||||
package-type: 'container'
|
||||
min-versions-to-keep: 0
|
||||
delete-only-untagged-versions: 'true'
|
||||
37
.github/workflows/coolify-staging-build.yml
vendored
37
.github/workflows/coolify-staging-build.yml
vendored
|
|
@ -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()
|
||||
|
|
|
|||
13032
CHANGELOG.md
13032
CHANGELOG.md
File diff suppressed because it is too large
Load diff
70
CLAUDE.md
70
CLAUDE.md
|
|
@ -1,6 +1,10 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to **Claude Code** (claude.ai/code) when working with code in this repository.
|
||||
|
||||
> **Note for AI Assistants**: This file is specifically for Claude Code. If you're using Cursor IDE, refer to the `.cursor/rules/` directory for detailed rule files. Both systems share core principles but are optimized for their respective workflows.
|
||||
>
|
||||
> **Maintaining Instructions**: When updating AI instructions, see [.AI_INSTRUCTIONS_SYNC.md](.AI_INSTRUCTIONS_SYNC.md) for synchronization guidelines between CLAUDE.md and .cursor/rules/.
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
|
@ -23,7 +27,14 @@ ### Backend Development
|
|||
### Code Quality
|
||||
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
|
||||
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
|
||||
- `./vendor/bin/pest` - Run Pest tests
|
||||
- `./vendor/bin/pest` - Run Pest tests (unit tests only, without database)
|
||||
|
||||
### Running Tests
|
||||
**IMPORTANT**: Tests that require database connections MUST be run inside the Docker container:
|
||||
- **Inside Docker**: `docker exec coolify php artisan test` (for feature tests requiring database)
|
||||
- **Outside Docker**: `./vendor/bin/pest tests/Unit` (for pure unit tests without database dependencies)
|
||||
- Unit tests should use mocking and avoid database connections
|
||||
- Feature tests that require database must be run in the `coolify` container
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
|
|||
56
README.md
56
README.md
|
|
@ -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 award–winning 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 award–winning 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
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
app/Console/Commands/ClearGlobalSearchCache.php
Normal file
83
app/Console/Commands/ClearGlobalSearchCache.php
Normal 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;
|
||||
}
|
||||
}
|
||||
51
app/Events/ServerValidated.php
Normal file
51
app/Events/ServerValidated.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
use App\Models\Service;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Services\DockerImageParser;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
|
@ -1512,9 +1513,33 @@ 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 using DockerImageParser
|
||||
$dockerImageName = $request->docker_registry_image_name;
|
||||
$dockerImageTag = $request->docker_registry_image_tag;
|
||||
|
||||
// Build the full Docker image string for parsing
|
||||
if ($dockerImageTag) {
|
||||
$dockerImageString = $dockerImageName.':'.$dockerImageTag;
|
||||
} else {
|
||||
$dockerImageString = $dockerImageName;
|
||||
}
|
||||
|
||||
// Parse using DockerImageParser to normalize the image reference
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse($dockerImageString);
|
||||
|
||||
// Get normalized image name and tag
|
||||
$normalizedImageName = $parser->getFullImageNameWithoutTag();
|
||||
|
||||
// Append @sha256 to image name if using digest
|
||||
if ($parser->isImageHash() && ! str_ends_with($normalizedImageName, '@sha256')) {
|
||||
$normalizedImageName .= '@sha256';
|
||||
}
|
||||
|
||||
// Set processed values back to request
|
||||
$request->offsetSet('docker_registry_image_name', $normalizedImageName);
|
||||
$request->offsetSet('docker_registry_image_tag', $parser->getTag());
|
||||
|
||||
$application = new Application;
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
|
||||
|
|
@ -2469,7 +2494,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 +2522,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 +2719,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 +2730,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 +2891,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 +2914,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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -593,6 +597,224 @@ public function update_by_uuid(Request $request)
|
|||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Backup',
|
||||
description: 'Create a new scheduled backup configuration for a database',
|
||||
path: '/databases/{uuid}/backups',
|
||||
operationId: 'create-database-backup',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Backup configuration data',
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['frequency'],
|
||||
properties: [
|
||||
'frequency' => ['type' => 'string', 'description' => 'Backup frequency (cron expression or: every_minute, hourly, daily, weekly, monthly, yearly)'],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled', 'default' => true],
|
||||
'save_s3' => ['type' => 'boolean', 'description' => 'Whether to save backups to S3', 'default' => false],
|
||||
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID (required if save_s3 is true)'],
|
||||
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
|
||||
'dump_all' => ['type' => 'boolean', 'description' => 'Whether to dump all databases', 'default' => false],
|
||||
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
|
||||
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
|
||||
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
|
||||
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'],
|
||||
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
|
||||
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Backup configuration created successfully',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string', 'format' => 'uuid', 'example' => '550e8400-e29b-41d4-a716-446655440000'],
|
||||
'message' => ['type' => 'string', 'example' => 'Backup configuration created successfully.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_backup(Request $request)
|
||||
{
|
||||
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// Validate incoming request is valid JSON
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'frequency' => 'required|string',
|
||||
'enabled' => 'boolean',
|
||||
'save_s3' => 'boolean',
|
||||
'dump_all' => 'boolean',
|
||||
'backup_now' => 'boolean|nullable',
|
||||
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
|
||||
'databases_to_backup' => 'string|nullable',
|
||||
'database_backup_retention_amount_locally' => 'integer|min:0',
|
||||
'database_backup_retention_days_locally' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_locally' => 'integer|min:0',
|
||||
'database_backup_retention_amount_s3' => 'integer|min:0',
|
||||
'database_backup_retention_days_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'integer|min:0',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 404);
|
||||
}
|
||||
|
||||
$uuid = $request->uuid;
|
||||
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('manageBackups', $database);
|
||||
|
||||
// Validate frequency is a valid cron expression
|
||||
$isValid = validate_cron_expression($request->frequency);
|
||||
if (! $isValid) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate S3 storage if save_s3 is true
|
||||
if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->filled('s3_storage_uuid')) {
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
if (! $existsInTeam) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for extra fields
|
||||
$extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']);
|
||||
if (! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$backupData = $request->only($backupConfigFields);
|
||||
|
||||
// Convert s3_storage_uuid to s3_storage_id
|
||||
if (isset($backupData['s3_storage_uuid'])) {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
if ($s3Storage) {
|
||||
$backupData['s3_storage_id'] = $s3Storage->id;
|
||||
} elseif ($request->boolean('save_s3')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
|
||||
], 422);
|
||||
}
|
||||
unset($backupData['s3_storage_uuid']);
|
||||
}
|
||||
|
||||
// Set default databases_to_backup based on database type if not provided
|
||||
if (! isset($backupData['databases_to_backup']) || empty($backupData['databases_to_backup'])) {
|
||||
if ($database->type() === 'standalone-postgresql') {
|
||||
$backupData['databases_to_backup'] = $database->postgres_db;
|
||||
} elseif ($database->type() === 'standalone-mysql') {
|
||||
$backupData['databases_to_backup'] = $database->mysql_database;
|
||||
} elseif ($database->type() === 'standalone-mariadb') {
|
||||
$backupData['databases_to_backup'] = $database->mariadb_database;
|
||||
}
|
||||
}
|
||||
|
||||
// Add required fields
|
||||
$backupData['database_id'] = $database->id;
|
||||
$backupData['database_type'] = $database->getMorphClass();
|
||||
$backupData['team_id'] = $teamId;
|
||||
|
||||
// Set defaults
|
||||
if (! isset($backupData['enabled'])) {
|
||||
$backupData['enabled'] = true;
|
||||
}
|
||||
|
||||
$backupConfig = ScheduledDatabaseBackup::create($backupData);
|
||||
|
||||
// Trigger immediate backup if requested
|
||||
if ($request->backup_now) {
|
||||
dispatch(new DatabaseBackupJob($backupConfig));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $backupConfig->uuid,
|
||||
'message' => 'Backup configuration created successfully.',
|
||||
], 201);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update',
|
||||
description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID',
|
||||
|
|
@ -666,6 +888,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 +1070,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 +1137,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 +1203,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 +1270,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 +1337,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 +1407,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 +1477,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 +1544,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 +2199,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 +2209,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 +2323,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 +2333,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 +2429,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'),
|
||||
]
|
||||
)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -131,6 +131,161 @@ public function deployment_by_uuid(Request $request)
|
|||
return response()->json($this->removeSensitiveData($deployment));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Cancel',
|
||||
description: 'Cancel a deployment by UUID.',
|
||||
path: '/deployments/{uuid}/cancel',
|
||||
operationId: 'cancel-deployment-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Deployments'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Deployment UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Deployment cancelled successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Deployment cancelled successfully.'],
|
||||
'deployment_uuid' => ['type' => 'string', 'example' => 'cm37r6cqj000008jm0veg5tkm'],
|
||||
'status' => ['type' => 'string', 'example' => 'cancelled-by-user'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
description: 'Deployment cannot be cancelled (already finished/failed/cancelled).',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Deployment cannot be cancelled. Current status: finished'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'User doesn\'t have permission to cancel this deployment.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'You do not have permission to cancel this deployment.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function cancel_deployment(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
|
||||
// Find the deployment by UUID
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', $uuid)->first();
|
||||
if (! $deployment) {
|
||||
return response()->json(['message' => 'Deployment not found.'], 404);
|
||||
}
|
||||
|
||||
// Check if the deployment belongs to the user's team
|
||||
$servers = Server::whereTeamId($teamId)->pluck('id');
|
||||
if (! $servers->contains($deployment->server_id)) {
|
||||
return response()->json(['message' => 'You do not have permission to cancel this deployment.'], 403);
|
||||
}
|
||||
|
||||
// Check if deployment can be cancelled (must be queued or in_progress)
|
||||
$cancellableStatuses = [
|
||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
];
|
||||
|
||||
if (! in_array($deployment->status, $cancellableStatuses)) {
|
||||
return response()->json([
|
||||
'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}",
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Perform the cancellation
|
||||
try {
|
||||
$deployment_uuid = $deployment->deployment_uuid;
|
||||
$kill_command = "docker rm -f {$deployment_uuid}";
|
||||
$build_server_id = $deployment->build_server_id ?? $deployment->server_id;
|
||||
|
||||
// Mark deployment as cancelled
|
||||
$deployment->update([
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Get the server
|
||||
$server = Server::find($build_server_id);
|
||||
|
||||
if ($server) {
|
||||
// Add cancellation log entry
|
||||
$deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr');
|
||||
|
||||
// Check if container exists and kill it
|
||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process([$kill_command], $server);
|
||||
$deployment->addLogEntry('Deployment container stopped.');
|
||||
} else {
|
||||
$deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.');
|
||||
}
|
||||
|
||||
// Kill running process if process ID exists
|
||||
if ($deployment->current_process_id) {
|
||||
try {
|
||||
$processKillCommand = "kill -9 {$deployment->current_process_id}";
|
||||
instant_remote_process([$processKillCommand], $server);
|
||||
} catch (\Throwable $e) {
|
||||
// Process might already be gone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Deployment cancelled successfully.',
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
'status' => $deployment->status,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Failed to cancel deployment: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Deploy',
|
||||
description: 'Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,88 @@
|
|||
|
||||
class GithubController extends Controller
|
||||
{
|
||||
private function removeSensitiveData($githubApp)
|
||||
{
|
||||
$githubApp->makeHidden([
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
]);
|
||||
|
||||
return serializeApiResponse($githubApp);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List',
|
||||
description: 'List all GitHub apps.',
|
||||
path: '/github-apps',
|
||||
operationId: 'list-github-apps',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitHub Apps'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of GitHub apps.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'organization' => ['type' => 'string', 'nullable' => true],
|
||||
'api_url' => ['type' => 'string'],
|
||||
'html_url' => ['type' => 'string'],
|
||||
'custom_user' => ['type' => 'string'],
|
||||
'custom_port' => ['type' => 'integer'],
|
||||
'app_id' => ['type' => 'integer'],
|
||||
'installation_id' => ['type' => 'integer'],
|
||||
'client_id' => ['type' => 'string'],
|
||||
'private_key_id' => ['type' => 'integer'],
|
||||
'is_system_wide' => ['type' => 'boolean'],
|
||||
'is_public' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'type' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function list_github_apps(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$githubApps = GithubApp::where(function ($query) use ($teamId) {
|
||||
$query->where('team_id', $teamId)
|
||||
->orWhere('is_system_wide', true);
|
||||
})->get();
|
||||
|
||||
$githubApps = $githubApps->map(function ($app) {
|
||||
return $this->removeSensitiveData($app);
|
||||
});
|
||||
|
||||
return response()->json($githubApps);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create GitHub App',
|
||||
description: 'Create a new GitHub app.',
|
||||
|
|
@ -219,7 +301,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 +418,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 +541,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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -324,9 +328,23 @@ public function create_service(Request $request)
|
|||
});
|
||||
}
|
||||
if ($oneClickService) {
|
||||
$service_payload = [
|
||||
$dockerComposeRaw = base64_decode($oneClickService);
|
||||
|
||||
// Validate for command injection BEFORE creating service
|
||||
try {
|
||||
validateDockerComposeForInjection($dockerComposeRaw);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'docker_compose_raw' => $e->getMessage(),
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$servicePayload = [
|
||||
'name' => "$oneClickServiceName-".str()->random(10),
|
||||
'docker_compose_raw' => base64_decode($oneClickService),
|
||||
'docker_compose_raw' => $dockerComposeRaw,
|
||||
'environment_id' => $environment->id,
|
||||
'service_type' => $oneClickServiceName,
|
||||
'server_id' => $server->id,
|
||||
|
|
@ -334,9 +352,9 @@ public function create_service(Request $request)
|
|||
'destination_type' => $destination->getMorphClass(),
|
||||
];
|
||||
if ($oneClickServiceName === 'cloudflared') {
|
||||
data_set($service_payload, 'connect_to_docker_network', true);
|
||||
data_set($servicePayload, 'connect_to_docker_network', true);
|
||||
}
|
||||
$service = Service::create($service_payload);
|
||||
$service = Service::create($servicePayload);
|
||||
$service->name = "$oneClickServiceName-".$service->uuid;
|
||||
$service->save();
|
||||
if ($oneClickDotEnvs?->count() > 0) {
|
||||
|
|
@ -458,6 +476,18 @@ public function create_service(Request $request)
|
|||
$dockerCompose = base64_decode($request->docker_compose_raw);
|
||||
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
try {
|
||||
validateDockerComposeForInjection($dockerComposeRaw);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'docker_compose_raw' => $e->getMessage(),
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$connectToDockerNetwork = $request->connect_to_docker_network ?? false;
|
||||
$instantDeploy = $request->instant_deploy ?? false;
|
||||
|
||||
|
|
@ -704,6 +734,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)
|
||||
|
|
@ -769,6 +803,19 @@ public function update_by_uuid(Request $request)
|
|||
}
|
||||
$dockerCompose = base64_decode($request->docker_compose_raw);
|
||||
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
try {
|
||||
validateDockerComposeForInjection($dockerComposeRaw);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'docker_compose_raw' => $e->getMessage(),
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$service->docker_compose_raw = $dockerComposeRaw;
|
||||
}
|
||||
|
||||
|
|
@ -954,6 +1001,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 +1126,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 +1246,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)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class Kernel extends HttpKernel
|
|||
* @var array<int, class-string|string>
|
||||
*/
|
||||
protected $middleware = [
|
||||
// \App\Http\Middleware\TrustHosts::class,
|
||||
\App\Http\Middleware\TrustHosts::class,
|
||||
\App\Http\Middleware\TrustProxies::class,
|
||||
\Illuminate\Http\Middleware\HandleCors::class,
|
||||
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Http\Middleware\TrustHosts as Middleware;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class TrustHosts extends Middleware
|
||||
{
|
||||
|
|
@ -13,8 +16,37 @@ class TrustHosts extends Middleware
|
|||
*/
|
||||
public function hosts(): array
|
||||
{
|
||||
return [
|
||||
$this->allSubdomainsOfApplicationUrl(),
|
||||
];
|
||||
$trustedHosts = [];
|
||||
|
||||
// Trust the configured FQDN from InstanceSettings (cached to avoid DB query on every request)
|
||||
// Use empty string as sentinel value instead of null so negative results are cached
|
||||
$fqdnHost = Cache::remember('instance_settings_fqdn_host', 300, function () {
|
||||
try {
|
||||
$settings = InstanceSettings::get();
|
||||
if ($settings && $settings->fqdn) {
|
||||
$url = Url::fromString($settings->fqdn);
|
||||
$host = $url->getHost();
|
||||
|
||||
return $host ?: '';
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If instance settings table doesn't exist yet (during installation),
|
||||
// return empty string (sentinel) so this result is cached
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
// Convert sentinel value back to null for consumption
|
||||
$fqdnHost = $fqdnHost !== '' ? $fqdnHost : null;
|
||||
|
||||
if ($fqdnHost) {
|
||||
$trustedHosts[] = $fqdnHost;
|
||||
}
|
||||
|
||||
// Trust all subdomains of APP_URL as fallback
|
||||
$trustedHosts[] = $this->allSubdomainsOfApplicationUrl();
|
||||
|
||||
return array_filter($trustedHosts);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -35,21 +35,25 @@ 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 is in progress. 🟡\n\n";
|
||||
} elseif ($this->status === ProcessStatus::FINISHED) {
|
||||
$this->body = "The preview deployment 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 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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
60
app/Jobs/SendWebhookJob.php
Normal file
60
app/Jobs/SendWebhookJob.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
162
app/Jobs/ValidateAndInstallServerJob.php
Normal file
162
app/Jobs/ValidateAndInstallServerJob.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
class Dashboard extends Component
|
||||
{
|
||||
public $projects = [];
|
||||
public Collection $projects;
|
||||
|
||||
public Collection $servers;
|
||||
|
||||
|
|
@ -23,11 +23,6 @@ public function mount()
|
|||
$this->projects = Project::ownedByCurrentTeam()->get();
|
||||
}
|
||||
|
||||
public function navigateToProject($projectUuid)
|
||||
{
|
||||
return $this->redirect(collect($this->projects)->firstWhere('uuid', $projectUuid)->navigateTo(), navigate: false);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard');
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ 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([
|
||||
'id',
|
||||
'application_id',
|
||||
|
|
@ -27,8 +29,7 @@ public function deployments()
|
|||
'server_name',
|
||||
'server_id',
|
||||
'status',
|
||||
])
|
||||
->sortBy('id');
|
||||
]);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -25,6 +25,7 @@ public function __construct(
|
|||
public bool $readonly,
|
||||
public bool $allowTab,
|
||||
public bool $spellcheck,
|
||||
public bool $autofocus = false,
|
||||
public ?string $helper,
|
||||
public bool $realtimeValidation,
|
||||
public bool $allowToPeak,
|
||||
|
|
|
|||
196
app/Livewire/Notifications/Webhook.php
Normal file
196
app/Livewire/Notifications/Webhook.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,28 @@ public function force_start()
|
|||
}
|
||||
}
|
||||
|
||||
public function copyLogsToClipboard(): string
|
||||
{
|
||||
$logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
if (! $logs) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$markdown = "# Deployment Logs\n\n";
|
||||
$markdown .= "```\n";
|
||||
|
||||
foreach ($logs as $log) {
|
||||
if (isset($log['output'])) {
|
||||
$markdown .= $log['output']."\n";
|
||||
}
|
||||
}
|
||||
|
||||
$markdown .= "```\n";
|
||||
|
||||
return $markdown;
|
||||
}
|
||||
|
||||
public function cancel()
|
||||
{
|
||||
$deployment_uuid = $this->application_deployment_queue->deployment_uuid;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ public function submit()
|
|||
'dockerComposeRaw' => 'required',
|
||||
]);
|
||||
$this->dockerComposeRaw = Yaml::dump(Yaml::parse($this->dockerComposeRaw), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
validateDockerComposeForInjection($this->dockerComposeRaw);
|
||||
|
||||
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@
|
|||
|
||||
class DockerImage extends Component
|
||||
{
|
||||
public string $dockerImage = '';
|
||||
public string $imageName = '';
|
||||
|
||||
public string $imageTag = '';
|
||||
|
||||
public string $imageSha256 = '';
|
||||
|
||||
public array $parameters;
|
||||
|
||||
|
|
@ -24,14 +28,88 @@ public function mount()
|
|||
$this->query = request()->query();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-parse image name when user pastes a complete Docker image reference
|
||||
* Examples:
|
||||
* - nginx:stable-alpine3.21-perl@sha256:4e272eef...
|
||||
* - ghcr.io/user/app:v1.2.3
|
||||
* - nginx@sha256:abc123...
|
||||
*/
|
||||
public function updatedImageName(): void
|
||||
{
|
||||
if (empty($this->imageName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't auto-parse if user has already manually filled tag or sha256 fields
|
||||
if (! empty($this->imageTag) || ! empty($this->imageSha256)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only auto-parse if the image name contains a tag (:) or digest (@)
|
||||
if (! str_contains($this->imageName, ':') && ! str_contains($this->imageName, '@')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse($this->imageName);
|
||||
|
||||
// Extract the base image name (without tag/digest)
|
||||
$baseImageName = $parser->getFullImageNameWithoutTag();
|
||||
|
||||
// Only update if parsing resulted in different base name
|
||||
// This prevents unnecessary updates when user types just the name
|
||||
if ($baseImageName !== $this->imageName) {
|
||||
if ($parser->isImageHash()) {
|
||||
// It's a SHA256 digest (takes priority over tag)
|
||||
$this->imageSha256 = $parser->getTag();
|
||||
$this->imageTag = '';
|
||||
} elseif ($parser->getTag() !== 'latest' || str_contains($this->imageName, ':')) {
|
||||
// It's a regular tag (only set if not default 'latest' or explicitly specified)
|
||||
$this->imageTag = $parser->getTag();
|
||||
$this->imageSha256 = '';
|
||||
}
|
||||
|
||||
// Update imageName to just the base name
|
||||
$this->imageName = $baseImageName;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If parsing fails, leave the image name as-is
|
||||
// User will see validation error on submit
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$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) {
|
||||
// Strip 'sha256:' prefix if user pasted it
|
||||
$sha256Hash = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
|
||||
$dockerImage = $this->imageName.'@sha256:'.$sha256Hash;
|
||||
} elseif ($this->imageTag) {
|
||||
$dockerImage = $this->imageName.':'.$this->imageTag;
|
||||
} else {
|
||||
$dockerImage = $this->imageName.':latest';
|
||||
}
|
||||
|
||||
// Parse using DockerImageParser to normalize the image reference
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse($this->dockerImage);
|
||||
$parser->parse($dockerImage);
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
|
|
@ -45,6 +123,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();
|
||||
|
||||
// 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';
|
||||
}
|
||||
|
||||
// Determine the image tag based on whether it's a hash or regular tag
|
||||
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
|
||||
|
||||
$application = Application::create([
|
||||
'name' => 'docker-image-'.new Cuid2,
|
||||
'repository_project_id' => 0,
|
||||
|
|
@ -52,8 +140,8 @@ public function submit()
|
|||
'git_branch' => 'main',
|
||||
'build_pack' => 'dockerimage',
|
||||
'ports_exposes' => 80,
|
||||
'docker_registry_image_name' => $parser->getFullImageNameWithoutTag(),
|
||||
'docker_registry_image_tag' => $parser->getTag(),
|
||||
'docker_registry_image_name' => $imageName,
|
||||
'docker_registry_image_tag' => $imageTag,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination_class,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ public function getListeners()
|
|||
|
||||
return [
|
||||
"echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
|
||||
'refreshServices' => 'refreshServices',
|
||||
'refresh' => 'refreshServices',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@ public function submit($notify = true)
|
|||
{
|
||||
try {
|
||||
$this->validate();
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
validateDockerComposeForInjection($this->service->docker_compose_raw);
|
||||
|
||||
$this->service->save();
|
||||
$this->service->saveExtraFields($this->fields);
|
||||
$this->service->parse();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,22 @@ class Storage extends Component
|
|||
|
||||
public $fileStorage;
|
||||
|
||||
public $isSwarm = false;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $mount_path = '';
|
||||
|
||||
public ?string $host_path = null;
|
||||
|
||||
public string $file_storage_path = '';
|
||||
|
||||
public ?string $file_storage_content = null;
|
||||
|
||||
public string $file_storage_directory_source = '';
|
||||
|
||||
public string $file_storage_directory_destination = '';
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
|
@ -27,6 +43,18 @@ public function getListeners()
|
|||
|
||||
public function mount()
|
||||
{
|
||||
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
||||
} else {
|
||||
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
|
||||
}
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
if ($this->resource->destination->server->isSwarm()) {
|
||||
$this->isSwarm = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->refreshStorages();
|
||||
}
|
||||
|
||||
|
|
@ -39,30 +67,151 @@ public function refreshStoragesFromEvent()
|
|||
public function refreshStorages()
|
||||
{
|
||||
$this->fileStorage = $this->resource->fileStorages()->get();
|
||||
$this->dispatch('$refresh');
|
||||
$this->resource->refresh();
|
||||
}
|
||||
|
||||
public function addNewVolume($data)
|
||||
public function getFilesProperty()
|
||||
{
|
||||
return $this->fileStorage->where('is_directory', false);
|
||||
}
|
||||
|
||||
public function getDirectoriesProperty()
|
||||
{
|
||||
return $this->fileStorage->where('is_directory', true);
|
||||
}
|
||||
|
||||
public function getVolumeCountProperty()
|
||||
{
|
||||
return $this->resource->persistentStorages()->count();
|
||||
}
|
||||
|
||||
public function getFileCountProperty()
|
||||
{
|
||||
return $this->files->count();
|
||||
}
|
||||
|
||||
public function getDirectoryCountProperty()
|
||||
{
|
||||
return $this->directories->count();
|
||||
}
|
||||
|
||||
public function submitPersistentVolume()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string',
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable',
|
||||
]);
|
||||
|
||||
$name = $this->resource->uuid.'-'.$this->name;
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => $data['name'],
|
||||
'mount_path' => $data['mount_path'],
|
||||
'host_path' => $data['host_path'],
|
||||
'name' => $name,
|
||||
'mount_path' => $this->mount_path,
|
||||
'host_path' => $this->host_path,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => $this->resource->getMorphClass(),
|
||||
]);
|
||||
$this->resource->refresh();
|
||||
$this->dispatch('success', 'Storage added successfully');
|
||||
$this->dispatch('clearAddStorage');
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('success', 'Volume added successfully');
|
||||
$this->dispatch('closeStorageModal', 'volume');
|
||||
$this->clearForm();
|
||||
$this->refreshStorages();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorage()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'file_storage_path' => 'required|string',
|
||||
'file_storage_content' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$this->file_storage_path = trim($this->file_storage_path);
|
||||
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} else {
|
||||
throw new \Exception('No valid resource type for file mount storage type!');
|
||||
}
|
||||
|
||||
\App\Models\LocalFileVolume::create([
|
||||
'fs_path' => $fs_path,
|
||||
'mount_path' => $this->file_storage_path,
|
||||
'content' => $this->file_storage_content,
|
||||
'is_directory' => false,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
]);
|
||||
|
||||
$this->dispatch('success', 'File mount added successfully');
|
||||
$this->dispatch('closeStorageModal', 'file');
|
||||
$this->clearForm();
|
||||
$this->refreshStorages();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorageDirectory()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'file_storage_directory_source' => 'required|string',
|
||||
'file_storage_directory_destination' => 'required|string',
|
||||
]);
|
||||
|
||||
$this->file_storage_directory_source = trim($this->file_storage_directory_source);
|
||||
$this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value();
|
||||
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
||||
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
||||
|
||||
\App\Models\LocalFileVolume::create([
|
||||
'fs_path' => $this->file_storage_directory_source,
|
||||
'mount_path' => $this->file_storage_directory_destination,
|
||||
'is_directory' => true,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
]);
|
||||
|
||||
$this->dispatch('success', 'Directory mount added successfully');
|
||||
$this->dispatch('closeStorageModal', 'directory');
|
||||
$this->clearForm();
|
||||
$this->refreshStorages();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearForm()
|
||||
{
|
||||
$this->name = '';
|
||||
$this->mount_path = '';
|
||||
$this->host_path = null;
|
||||
$this->file_storage_path = '';
|
||||
$this->file_storage_content = null;
|
||||
$this->file_storage_directory_destination = '';
|
||||
|
||||
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
||||
} else {
|
||||
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.service.storage');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared\Storages;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalFileVolume;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Add extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $resource;
|
||||
|
||||
public $uuid;
|
||||
|
||||
public $parameters;
|
||||
|
||||
public $isSwarm = false;
|
||||
|
||||
public string $name;
|
||||
|
||||
public string $mount_path;
|
||||
|
||||
public ?string $host_path = null;
|
||||
|
||||
public string $file_storage_path;
|
||||
|
||||
public ?string $file_storage_content = null;
|
||||
|
||||
public string $file_storage_directory_source;
|
||||
|
||||
public string $file_storage_directory_destination;
|
||||
|
||||
public $rules = [
|
||||
'name' => 'required|string',
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => 'string|nullable',
|
||||
'file_storage_path' => 'string',
|
||||
'file_storage_content' => 'nullable|string',
|
||||
'file_storage_directory_source' => 'string',
|
||||
'file_storage_directory_destination' => 'string',
|
||||
];
|
||||
|
||||
protected $listeners = ['clearAddStorage' => 'clear'];
|
||||
|
||||
protected $validationAttributes = [
|
||||
'name' => 'name',
|
||||
'mount_path' => 'mount',
|
||||
'host_path' => 'host',
|
||||
'file_storage_path' => 'file storage path',
|
||||
'file_storage_content' => 'file storage content',
|
||||
'file_storage_directory_source' => 'file storage directory source',
|
||||
'file_storage_directory_destination' => 'file storage directory destination',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
||||
} else {
|
||||
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
|
||||
}
|
||||
$this->uuid = $this->resource->uuid;
|
||||
$this->parameters = get_route_parameters();
|
||||
if (data_get($this->parameters, 'application_uuid')) {
|
||||
$applicationUuid = $this->parameters['application_uuid'];
|
||||
$application = Application::where('uuid', $applicationUuid)->first();
|
||||
if (! $application) {
|
||||
abort(404);
|
||||
}
|
||||
if ($application->destination->server->isSwarm()) {
|
||||
$this->isSwarm = true;
|
||||
$this->rules['host_path'] = 'required|string';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorage()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'file_storage_path' => 'string',
|
||||
'file_storage_content' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$this->file_storage_path = trim($this->file_storage_path);
|
||||
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} else {
|
||||
throw new \Exception('No valid resource type for file mount storage type!');
|
||||
}
|
||||
|
||||
LocalFileVolume::create(
|
||||
[
|
||||
'fs_path' => $fs_path,
|
||||
'mount_path' => $this->file_storage_path,
|
||||
'content' => $this->file_storage_content,
|
||||
'is_directory' => false,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
],
|
||||
);
|
||||
$this->dispatch('refreshStorages');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorageDirectory()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'file_storage_directory_source' => 'string',
|
||||
'file_storage_directory_destination' => 'string',
|
||||
]);
|
||||
|
||||
$this->file_storage_directory_source = trim($this->file_storage_directory_source);
|
||||
$this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value();
|
||||
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
||||
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
||||
|
||||
LocalFileVolume::create(
|
||||
[
|
||||
'fs_path' => $this->file_storage_directory_source,
|
||||
'mount_path' => $this->file_storage_directory_destination,
|
||||
'is_directory' => true,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
],
|
||||
);
|
||||
$this->dispatch('refreshStorages');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitPersistentVolume()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string',
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => 'string|nullable',
|
||||
]);
|
||||
$name = $this->uuid.'-'.$this->name;
|
||||
$this->dispatch('addNewVolume', [
|
||||
'name' => $name,
|
||||
'mount_path' => $this->mount_path,
|
||||
'host_path' => $this->host_path,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function clear()
|
||||
{
|
||||
$this->name = '';
|
||||
$this->mount_path = '';
|
||||
$this->host_path = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
101
app/Livewire/Security/CloudInitScriptForm.php
Normal file
101
app/Livewire/Security/CloudInitScriptForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
52
app/Livewire/Security/CloudInitScripts.php
Normal file
52
app/Livewire/Security/CloudInitScripts.php
Normal 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');
|
||||
}
|
||||
}
|
||||
99
app/Livewire/Security/CloudProviderTokenForm.php
Normal file
99
app/Livewire/Security/CloudProviderTokenForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
60
app/Livewire/Security/CloudProviderTokens.php
Normal file
60
app/Livewire/Security/CloudProviderTokens.php
Normal 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');
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Security/CloudTokens.php
Normal file
13
app/Livewire/Security/CloudTokens.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
144
app/Livewire/Server/CloudProviderToken/Show.php
Normal file
144
app/Livewire/Server/CloudProviderToken/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
587
app/Livewire/Server/New/ByHetzner.php
Normal file
587
app/Livewire/Server/New/ByHetzner.php
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
||||
private function getCpuVendorInfo(array $serverType): string|null
|
||||
{
|
||||
$name = strtolower($serverType['name'] ?? '');
|
||||
|
||||
if (str_starts_with($name, 'ccx')) {
|
||||
return 'AMD Milan EPYC™';
|
||||
} elseif (str_starts_with($name, 'cpx')) {
|
||||
return 'AMD EPYC™';
|
||||
} elseif (str_starts_with($name, 'cx')) {
|
||||
return 'Intel® Xeon®';
|
||||
} elseif (str_starts_with($name, 'cax')) {
|
||||
return 'Ampere® Altra®';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
})
|
||||
->map(function ($serverType) {
|
||||
$serverType['cpu_vendor_info'] = $this->getCpuVendorInfo($serverType);
|
||||
|
||||
return $serverType;
|
||||
})
|
||||
->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@ public function addCoolifyDatabase()
|
|||
|
||||
public function submit()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$this->database->update([
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -45,9 +45,16 @@ private function generateInviteLink(bool $sendEmail = false)
|
|||
try {
|
||||
$this->authorize('manageInvitations', currentTeam());
|
||||
$this->validate();
|
||||
if (auth()->user()->role() === 'admin' && $this->role === 'owner') {
|
||||
|
||||
// Prevent privilege escalation: users cannot invite someone with higher privileges
|
||||
$userRole = auth()->user()->role();
|
||||
if ($userRole === 'member' && in_array($this->role, ['admin', 'owner'])) {
|
||||
throw new \Exception('Members cannot invite admins or owners.');
|
||||
}
|
||||
if ($userRole === 'admin' && $this->role === 'owner') {
|
||||
throw new \Exception('Admins cannot invite owners.');
|
||||
}
|
||||
|
||||
$this->email = strtolower($this->email);
|
||||
|
||||
$member_emails = currentTeam()->members()->get()->pluck('email');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -988,29 +1003,30 @@ public function dirOnServer()
|
|||
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false)
|
||||
{
|
||||
$baseDir = $this->generateBaseDir($deployment_uuid);
|
||||
$escapedBaseDir = escapeshellarg($baseDir);
|
||||
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
|
||||
|
||||
if ($this->git_commit_sha !== 'HEAD') {
|
||||
// If shallow clone is enabled and we need a specific commit,
|
||||
// we need to fetch that specific commit with depth=1
|
||||
if ($isShallowCloneEnabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
} else {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
}
|
||||
}
|
||||
if ($this->settings->is_git_submodules_enabled) {
|
||||
// Check if .gitmodules file exists before running submodule commands
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && if [ -f .gitmodules ]; then";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && if [ -f .gitmodules ]; then";
|
||||
if ($public) {
|
||||
$git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$baseDir}/.gitmodules || true &&";
|
||||
$git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$escapedBaseDir}/.gitmodules || true &&";
|
||||
}
|
||||
// Add shallow submodules flag if shallow clone is enabled
|
||||
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
|
||||
$git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi";
|
||||
}
|
||||
if ($this->settings->is_git_lfs_enabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull";
|
||||
}
|
||||
|
||||
return $git_clone_command;
|
||||
|
|
@ -1048,18 +1064,24 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
|
|||
$source_html_url_scheme = $url['scheme'];
|
||||
|
||||
if ($this->source->getMorphClass() == 'App\Models\GithubApp') {
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
if ($this->source->is_public) {
|
||||
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
|
||||
$fullRepoUrl = "{$this->source->html_url}/{$customRepository}";
|
||||
$base_command = "{$base_command} {$this->source->html_url}/{$customRepository}";
|
||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||
} else {
|
||||
$github_access_token = generateGithubInstallationToken($this->source);
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
|
||||
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
|
||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||
$fullRepoUrl = $repoUrl;
|
||||
} else {
|
||||
$base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
|
||||
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
|
||||
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
|
||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||
$fullRepoUrl = $repoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1084,7 +1106,10 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
|
|||
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.');
|
||||
}
|
||||
$private_key = base64_encode($private_key);
|
||||
$base_comamnd = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} {$customRepository}";
|
||||
// When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context
|
||||
// Replace ' with '\'' to safely escape within single-quoted bash strings
|
||||
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
|
||||
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
|
|
@ -1101,9 +1126,9 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
|
|||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_comamnd));
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
} else {
|
||||
$commands->push($base_comamnd);
|
||||
$commands->push($base_command);
|
||||
}
|
||||
|
||||
return [
|
||||
|
|
@ -1115,7 +1140,8 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
|
|||
|
||||
if ($this->deploymentType() === 'other') {
|
||||
$fullRepoUrl = $customRepository;
|
||||
$base_command = "{$base_command} {$customRepository}";
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$base_command = "{$base_command} {$escapedCustomRepository}";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
|
|
@ -1257,7 +1283,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
|
|
@ -1265,14 +1291,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1290,7 +1316,8 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
}
|
||||
if ($this->deploymentType() === 'other') {
|
||||
$fullRepoUrl = $customRepository;
|
||||
$git_clone_command = "{$git_clone_command} {$customRepository} {$baseDir}";
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true);
|
||||
|
||||
if ($pull_request_id !== 0) {
|
||||
|
|
@ -1301,7 +1328,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
|
|
@ -1309,14 +1336,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue