Merge branch 'next' into patch-1

This commit is contained in:
Andras Bacsai authored and GitHub committed 2025-11-18 10:35:50 +01:00
commit 5363310466
226 files changed
+13047 -2781

No files matched your search

+349 -2
View File
@@ -267,18 +267,365 @@ For complete documentation, see **[form-components.mdc](mdc:.cursor/rules/form-c
## Form Handling Patterns
### Livewire Component Data Synchronization Pattern
**IMPORTANT**: All Livewire components must use the **manual `syncData()` pattern** for synchronizing component properties with Eloquent models.
#### Property Naming Convention
- **Component properties**: Use camelCase (e.g., `$gitRepository`, `$isStatic`)
- **Database columns**: Use snake_case (e.g., `git_repository`, `is_static`)
- **View bindings**: Use camelCase matching component properties (e.g., `id="gitRepository"`)
#### The syncData() Method Pattern
```php
use Livewire\Attributes\Validate;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
class MyComponent extends Component
{
use AuthorizesRequests;
public Application $application;
// Properties with validation attributes
#[Validate(['required'])]
public string $name;
#[Validate(['string', 'nullable'])]
public ?string $description = null;
#[Validate(['boolean', 'required'])]
public bool $isStatic = false;
public function mount()
{
$this->authorize('view', $this->application);
$this->syncData(); // Load from model
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Sync TO model (camelCase → snake_case)
$this->application->name = $this->name;
$this->application->description = $this->description;
$this->application->is_static = $this->isStatic;
$this->application->save();
} else {
// Sync FROM model (snake_case → camelCase)
$this->name = $this->application->name;
$this->description = $this->application->description;
$this->isStatic = $this->application->is_static;
}
}
public function submit()
{
$this->authorize('update', $this->application);
$this->syncData(toModel: true); // Save to model
$this->dispatch('success', 'Saved successfully.');
}
}
```
#### Validation with #[Validate] Attributes
All component properties should have `#[Validate]` attributes:
```php
// Boolean properties
#[Validate(['boolean'])]
public bool $isEnabled = false;
// Required strings
#[Validate(['string', 'required'])]
public string $name;
// Nullable strings
#[Validate(['string', 'nullable'])]
public ?string $description = null;
// With constraints
#[Validate(['integer', 'min:1'])]
public int $timeout;
```
#### Benefits of syncData() Pattern
- **Explicit Control**: Clear visibility of what's being synchronized
- **Type Safety**: #[Validate] attributes provide compile-time validation info
- **Easy Debugging**: Single method to check for data flow issues
- **Maintainability**: All sync logic in one place
- **Flexibility**: Can add custom logic (encoding, transformations, etc.)
#### Creating New Form Components with syncData()
#### Step-by-Step Component Creation Guide
**Step 1: Define properties in camelCase with #[Validate] attributes**
```php
use Livewire\Attributes\Validate;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class MyFormComponent extends Component
{
use AuthorizesRequests;
// The model we're syncing with
public Application $application;
// Component properties in camelCase with validation
#[Validate(['string', 'required'])]
public string $name;
#[Validate(['string', 'nullable'])]
public ?string $gitRepository = null;
#[Validate(['string', 'nullable'])]
public ?string $installCommand = null;
#[Validate(['boolean'])]
public bool $isStatic = false;
}
```
**Step 2: Implement syncData() method**
```php
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Sync TO model (component camelCase → database snake_case)
$this->application->name = $this->name;
$this->application->git_repository = $this->gitRepository;
$this->application->install_command = $this->installCommand;
$this->application->is_static = $this->isStatic;
$this->application->save();
} else {
// Sync FROM model (database snake_case → component camelCase)
$this->name = $this->application->name;
$this->gitRepository = $this->application->git_repository;
$this->installCommand = $this->application->install_command;
$this->isStatic = $this->application->is_static;
}
}
```
**Step 3: Implement mount() to load initial data**
```php
public function mount()
{
$this->authorize('view', $this->application);
$this->syncData(); // Load data from model to component properties
}
```
**Step 4: Implement action methods with authorization**
```php
public function instantSave()
{
try {
$this->authorize('update', $this->application);
$this->syncData(toModel: true); // Save component properties to model
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {
$this->authorize('update', $this->application);
$this->syncData(toModel: true); // Save component properties to model
$this->dispatch('success', 'Changes saved successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
```
**Step 5: Create Blade view with camelCase bindings**
```blade
<div>
<form wire:submit="submit">
<x-forms.input
canGate="update"
:canResource="$application"
id="name"
label="Name"
required />
<x-forms.input
canGate="update"
:canResource="$application"
id="gitRepository"
label="Git Repository" />
<x-forms.input
canGate="update"
:canResource="$application"
id="installCommand"
label="Install Command" />
<x-forms.checkbox
instantSave
canGate="update"
:canResource="$application"
id="isStatic"
label="Static Site" />
<x-forms.button
canGate="update"
:canResource="$application"
type="submit">
Save Changes
</x-forms.button>
</form>
</div>
```
**Key Points**:
- Use `wire:model="camelCase"` and `id="camelCase"` in Blade views
- Component properties are camelCase, database columns are snake_case
- Always include authorization checks (`authorize()`, `canGate`, `canResource`)
- Use `instantSave` for checkboxes that save immediately without form submission
#### Special Patterns
**Pattern 1: Related Models (e.g., Application → Settings)**
```php
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Sync main model
$this->application->name = $this->name;
$this->application->save();
// Sync related model
$this->application->settings->is_static = $this->isStatic;
$this->application->settings->save();
} else {
// From main model
$this->name = $this->application->name;
// From related model
$this->isStatic = $this->application->settings->is_static;
}
}
```
**Pattern 2: Custom Encoding/Decoding**
```php
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Encode before saving
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
} else {
// Decode when loading
$this->customLabels = $this->application->parseContainerLabels();
}
}
```
**Pattern 3: Error Rollback**
```php
public function submit()
{
$this->authorize('update', $this->resource);
$original = $this->model->getOriginal();
try {
$this->syncData(toModel: true);
$this->dispatch('success', 'Saved successfully.');
} catch (\Throwable $e) {
// Rollback on error
$this->model->setRawAttributes($original);
$this->model->save();
$this->syncData(); // Reload from model
return handleError($e, $this);
}
}
```
#### Property Type Patterns
**Required Strings**
```php
#[Validate(['string', 'required'])]
public string $name; // No ?, no default, always has value
```
**Nullable Strings**
```php
#[Validate(['string', 'nullable'])]
public ?string $description = null; // ?, = null, can be empty
```
**Booleans**
```php
#[Validate(['boolean'])]
public bool $isEnabled = false; // Always has default value
```
**Integers with Constraints**
```php
#[Validate(['integer', 'min:1'])]
public int $timeout; // Required
#[Validate(['integer', 'min:1', 'nullable'])]
public ?int $port = null; // Nullable
```
#### Testing Checklist
After creating a new component with syncData(), verify:
- [ ] All checkboxes save correctly (especially `instantSave` ones)
- [ ] All form inputs persist to database
- [ ] Custom encoded fields (like labels) display correctly if applicable
- [ ] Form validation works for all fields
- [ ] No console errors in browser
- [ ] Authorization checks work (`@can` directives and `authorize()` calls)
- [ ] Error rollback works if exceptions occur
- [ ] Related models save correctly if applicable (e.g., Application + ApplicationSetting)
#### Common Pitfalls to Avoid
1. **snake_case in component properties**: Always use camelCase for component properties (e.g., `$gitRepository` not `$git_repository`)
2. **Missing #[Validate] attributes**: Every property should have validation attributes for type safety
3. **Forgetting to call syncData()**: Must call `syncData()` in `mount()` to load initial data
4. **Missing authorization**: Always use `authorize()` in methods and `canGate`/`canResource` in views
5. **View binding mismatch**: Use camelCase in Blade (e.g., `id="gitRepository"` not `id="git_repository"`)
6. **wire:model vs wire:model.live**: Use `.live` for `instantSave` checkboxes to avoid timing issues
7. **Validation sync**: If using `rules()` method, keep it in sync with `#[Validate]` attributes
8. **Related models**: Don't forget to save both main and related models in syncData() method
### Livewire Forms
```php
class ServerCreateForm extends Component
{
public $name;
public $ip;
protected $rules = [
'name' => 'required|min:3',
'ip' => 'required|ip',
];
public function save()
{
$this->validate();
@@ -4,6 +4,11 @@ on:
schedule:
- cron: '0 1 * * *'
permissions:
issues: write
discussions: write
pull-requests: write
jobs:
lock-threads:
runs-on: ubuntu-latest
@@ -13,5 +18,5 @@ jobs:
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '30'
pr-inactive-days: '30'
discussion-inactive-days: '30'
pr-inactive-days: '30'
@@ -4,6 +4,10 @@ on:
schedule:
- cron: '0 2 * * *'
permissions:
issues: write
pull-requests: write
jobs:
manage-stale:
runs-on: ubuntu-latest
+4 -11
View File
@@ -3,20 +3,13 @@ on:
pull_request_target:
types:
- labeled
permissions:
pull-requests: write
jobs:
add-comment:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
actions: none
checks: none
deployments: none
issues: none
packages: none
repository-projects: none
security-events: none
statuses: none
strategy:
matrix:
include:
@@ -8,6 +8,10 @@ on:
pull_request_target:
types: [closed]
permissions:
issues: write
pull-requests: write
jobs:
remove-labels-and-assignees:
runs-on: ubuntu-latest
-79
View File
@@ -1,79 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
if: false
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Direct prompt for automated review (no @claude mention needed)
direct_prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Be constructive and helpful in your feedback.
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
# use_sticky_comment: true
# Optional: Customize review based on file types
# direct_prompt: |
# Review this PR focusing on:
# - For TypeScript files: Type safety and proper interface usage
# - For API endpoints: Security, input validation, and error handling
# - For React components: Performance, accessibility, and best practices
# - For tests: Coverage, edge cases, and test quality
# Optional: Different prompts for different authors
# direct_prompt: |
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
# Optional: Add specific tools for running tests or linting
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
# Optional: Skip review for certain conditions
# if: |
# !contains(github.event.pull_request.title, '[skip-review]') &&
# !contains(github.event.pull_request.title, '[WIP]')
-65
View File
@@ -1,65 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'Claude') ||
(github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'Claude') ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test
+3 -6
View File
@@ -1,17 +1,14 @@
name: Cleanup Untagged GHCR Images
on:
workflow_dispatch: # Manual trigger only
workflow_dispatch:
env:
GITHUB_REGISTRY: ghcr.io
permissions:
packages: write
jobs:
cleanup-all-packages:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host']
+32 -57
View File
@@ -7,19 +7,31 @@ on:
- .github/workflows/coolify-helper-next.yml
- docker/coolify-helper/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
jobs:
amd64:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -40,66 +52,27 @@ jobs:
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
platforms: linux/amd64
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
labels: |
coolify.managed=true
aarch64:
runs-on: [ self-hosted, arm64 ]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
platforms: linux/aarch64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [ amd64, aarch64 ]
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
@@ -124,14 +97,16 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
+31 -57
View File
@@ -7,19 +7,31 @@ on:
- .github/workflows/coolify-helper.yml
- docker/coolify-helper/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
jobs:
amd64:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -40,65 +52,25 @@ jobs:
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
platforms: linux/amd64
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
labels: |
coolify.managed=true
aarch64:
runs-on: [ self-hosted, arm64 ]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
platforms: linux/aarch64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [ amd64, aarch64 ]
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
@@ -124,14 +96,16 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
+31 -50
View File
@@ -14,16 +14,31 @@ on:
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
amd64:
runs-on: ubuntu-latest
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -44,60 +59,24 @@ jobs:
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: linux/amd64
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
aarch64:
runs-on: [self-hosted, arm64]
steps:
- uses: actions/checkout@v4
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: linux/aarch64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [amd64, aarch64]
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
@@ -123,14 +102,16 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
+31 -58
View File
@@ -11,19 +11,31 @@ on:
- docker/coolify-realtime/package-lock.json
- docker/coolify-realtime/soketi-entrypoint.sh
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
jobs:
amd64:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -44,67 +56,26 @@ jobs:
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
platforms: linux/amd64
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
labels: |
coolify.managed=true
aarch64:
runs-on: [ self-hosted, arm64 ]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
platforms: linux/aarch64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [ amd64, aarch64 ]
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
@@ -130,14 +101,16 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
+31 -58
View File
@@ -11,19 +11,31 @@ on:
- docker/coolify-realtime/package-lock.json
- docker/coolify-realtime/soketi-entrypoint.sh
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
jobs:
amd64:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -44,67 +56,26 @@ jobs:
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
platforms: linux/amd64
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
labels: |
coolify.managed=true
aarch64:
runs-on: [ self-hosted, arm64 ]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
platforms: linux/aarch64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [ amd64, aarch64 ]
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
@@ -130,14 +101,16 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
+38 -55
View File
@@ -17,16 +17,31 @@ on:
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
amd64:
runs-on: ubuntu-latest
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
@@ -35,6 +50,9 @@ jobs:
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
@@ -49,65 +67,28 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and Push Image
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: linux/amd64
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ 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]
permissions:
contents: read
packages: write
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:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: linux/aarch64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
cache-from: |
type=gha,scope=build-${{ matrix.arch }}
type=registry,ref=${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [amd64, aarch64]
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
@@ -135,13 +116,15 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
${{ 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 }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
${{ 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
+31 -53
View File
@@ -7,19 +7,31 @@ on:
- .github/workflows/coolify-testing-host.yml
- docker/testing-host/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-testing-host"
jobs:
amd64:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -35,62 +47,26 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and Push Image
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/testing-host/Dockerfile
platforms: linux/amd64
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
labels: |
coolify.managed=true
aarch64:
runs-on: [ self-hosted, arm64 ]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
context: .
file: docker/testing-host/Dockerfile
platforms: linux/aarch64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [ amd64, aarch64 ]
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
@@ -111,13 +87,15 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
+1
View File
@@ -37,3 +37,4 @@ scripts/load-test/*
docker/coolify-realtime/node_modules
.DS_Store
CHANGELOG.md
/.workspaces
+200 -58
View File
@@ -4,80 +4,164 @@ All notable changes to this project will be documented in this file.
## [unreleased]
### 🐛 Bug Fixes
- Update syncData method to use data_get for safer property access
- Update version numbers to 4.0.0-beta.441 and 4.0.0-beta.442
- Enhance menu item styles and update theme color meta tag
- Clean up input attributes for PostgreSQL settings in general.blade.php
- Update docker stop command to use --time instead of --timeout
- Clean up utility classes and improve readability in Blade templates
- Enhance styling for page width component in Blade template
- Remove debugging output from StartPostgresql command handling
### 📚 Documentation
- Update changelog
## [4.0.0-beta.436] - 2025-10-17
### 🚀 Features
- Implement TrustHosts middleware to handle FQDN and IP address trust logic
- Implement TrustHosts middleware to handle FQDN and IP address trust logic
- Allow safe environment variable defaults in array-format volumes
- Add signoz template
- *(signoz)* Replace png icon by svg icon
- *(signoz)* Remove explicit 'networks' setting
- *(signoz)* Add predefined environment variables to configure Telemetry, SMTP and email sending for Alert Manager
- *(signoz)* Generate URLs for `otel-collector` service
- *(signoz)* Update documentation link
- *(signoz)* Add healthcheck to otel-collector service
- *(signoz)* Use latest tag instead of hardcoded versions
- *(signoz)* Remove redundant users.xml volume from clickhouse container
- *(signoz)* Replace clickhouse' config.xml volume with simpler configuration
- *(signoz)* Remove deprecated parameters of signoz container
- *(signoz)* Remove volumes from signoz.yaml
- *(signoz)* Assume there is a single zookeeper container
- *(signoz)* Update Clickhouse config to include all settings required by Signoz
- *(signoz)* Update config.xml and users.xml to ensure clickhouse boots correctly
- *(signoz)* Update otel-collector configuration to match upstream
- *(signoz)* Fix otel-collector config for version v0.128.0
- *(signoz)* Remove unecessary port mapping for otel-collector
- *(signoz)* Add SIGNOZ_JWT_SECRET env var generation
- *(signoz)* Upgrade clickhouse image to 25.5.6
- *(signoz)* Use latest tag for signoz/zookeeper
- *(signoz)* Update variables for SMTP configuration
- *(signoz)* Replace deprecated `TELEMETRY_ENABLED` by `SIGNOZ_STATSREPORTER_ENABLED`
- *(signoz)* Pin service image tags and `exclude_from_hc` flag to services excluded from health checks
- *(templates)* Add SMTP configuration to ente-photos compose templates
- *(templates)* Add SMTP encryption configuration to ente-photos compose templates
## [4.0.0-beta.440] - 2025-11-04
### 🐛 Bug Fixes
- Use wasChanged() instead of isDirty() in updated hooks
- Prevent command injection in git ls-remote operations
- Handle null environment variable values in bash escaping
- Critical privilege escalation in team invitation system
- Add authentication context to TeamPolicyTest
- Ensure negative cache results are stored in TrustHosts middleware
- Use wasChanged() instead of isDirty() in updated hook
- Prevent command injection in Docker Compose parsing - add pre-save validation
- Use canonical parser for Windows path validation
- Correct variable name typo in generateGitLsRemoteCommands method
- Update version numbers to 4.0.0-beta.436 and 4.0.0-beta.437
- Ensure authorization checks are in place for viewing and updating the application
- Ensure authorization check is performed during component mount
- *(signoz)* Remove example secrets to avoid triggering GitGuardian
- *(signoz)* Remove hardcoded container names
- *(signoz)* Remove HTTP collector FQDN in otel-collector
- *(n8n)* Add DB_SQLITE_POOL_SIZE environment variable for configuration
- Fix SPA toggle nginx regeneration and add confirmation modal
### 📚 Documentation
- Update changelog
## [4.0.0-beta.439] - 2025-11-03
### 📚 Documentation
- Update changelog
## [4.0.0-beta.438] - 2025-10-29
### 🚀 Features
- Display service logos in original colors with consistent sizing
- Add warnings for system-wide GitHub Apps
- Show message when no resources use GitHub App
- Add dynamic viewport-based height for compose editor
- Add funding information for Coollabs including sponsorship plans and channels
- Update Evolution API slogan to better reflect its capabilities
- *(templates)* Update plane compose to v1.0.0
- Add token validation functionality for Hetzner and DigitalOcean providers
- Add dev_helper_version to instance settings and update related functionality
- Add RestoreDatabase command for PostgreSQL dump restoration
- Update ApplicationSetting model to include additional boolean casts
- Enhance General component with additional properties and validation rules
- Update version numbers to 4.0.0-beta.440 and 4.0.0-beta.441
### 🐛 Bug Fixes
- Handle redis_password in API database creation
- Make modals scrollable on small screens
- Resolve Livewire wire:model binding error in domains input
- Make environment variable forms responsive
- Make proxy logs page responsive
- Improve proxy logs form layout for better responsive behavior
- Prevent horizontal overflow in log text
- Use break-all to force line wrapping in logs
- Ensure deployment failure notifications are sent reliably
- GitHub source creation and configuration issues
- Make system-wide warning reactive in Create view
- Prevent system-wide warning callout from making modal too wide
- Constrain callout width with max-w-2xl and wrap text properly
- Center system-wide warning callout in modal
- Left-align callout on regular view, keep centered in modal
- Allow callout to take full width in regular view
- Change app_id and installation_id to integer values in createGithubAppManually method
- Use x-cloak instead of inline style to prevent FOUC
- Clarify warning message for allowed IPs configuration
- Server URL generation in ServerPatchCheck notification
- Monaco editor empty for docker compose applications
- Update sponsor link from Darweb to Dade2 in README
- *(database)* Prevent malformed URLs when server IP is empty
- Optimize caching in Dockerfile and GitHub Actions workflow
- Remove wire:ignore from modal and add wire:key to EditCompose component
- Add wire:ignore directive to modal component for improved functionality
- Clean up formatting and remove unnecessary key binding in stack form component
- Add null checks and validation to OAuth bulk update method
- *(docs)* Update documentation URL to version 2 in evolution-api.yaml
- *(templates)* Remove volumes from Plane's compose
- *(templates)* Add redis env to live service in Plane
- *(templates)* Update minio image to use coollabsio fork in Plane
- Prevent login rate limit bypass via spoofed headers
- Correct login rate limiter key format to include IP address
- Change SMTP port input type to number for better validation
- Remove unnecessary step attribute from maximum storage input fields
- Update boarding flow logic to complete onboarding when server is created
- Convert network aliases to string for display
- Improve custom_network_aliases handling and testing
- Remove duplicate custom_labels from config hash calculation
- Improve run script and enhance sticky header style
### 💼 Other
- *(deps-dev)* Bump vite from 6.3.6 to 6.4.1
### 🚜 Refactor
- Improve validation error handling and coding standards
- Preserve exception chain in validation error handling
- Harden and deduplicate validateShellSafePath
- Replace random ID generation with Cuid2 for unique HTML IDs in form components
- Remove deprecated next() method
- Replace allowed IPs validation logic with regex
- Remove redundant
- Streamline allowed IPs validation and enhance UI warnings for API access
- Remove staging URL logic from ServerPatchCheck constructor
- Streamline Docker build process with matrix strategy for multi-architecture support
- Simplify project data retrieval and enhance OAuth settings handling
- Improve handling of custom network aliases
- Remove unused submodules
- Update subproject commit hashes
- Remove SynchronizesModelData trait and implement syncData method for model synchronization
### 📚 Documentation
- Update changelog
- Update changelog
- Update changelog
- Update changelog
- Update changelog
- Update changelog
- Update changelog
- Add service & database deployment logging plan
### 🧪 Testing
- Add coverage for newline and tab rejection in volume strings
- Add unit tests for ServerPatchCheck notification URL generation
- Fix ServerPatchCheckNotification tests to avoid global state pollution
### ⚙️ Miscellaneous Tasks
- *(signoz)* Remove unused ports
- *(signoz)* Bump version to 0.77.0
- *(signoz)* Bump version to 0.78.1
- Add category field to siyuan.yaml
- Update siyuan category in service templates
- Add spacing and format callout text in modal
- Update version numbers to 4.0.0-beta.439 and 4.0.0-beta.440
- Add .workspaces to .gitignore
## [4.0.0-beta.437] - 2025-10-21
### 🚀 Features
- *(templates)* Add sparkyfitness compose template and logo
- *(servide)* Add siyuan template
- Add onboarding guide link to global search no results state
- Add category filter dropdown to service selection
### 🐛 Bug Fixes
- *(service)* Update image version & healthcheck start period
- Filter deprecated server types for Hetzner
- Eliminate dark mode white screen flicker on page transitions
### 💼 Other
- Preserve clean docker_compose_raw without Coolify additions
### 📚 Documentation
- Update changelog
- Update changelog
## [4.0.0-beta.435] - 2025-10-15
@@ -143,6 +227,35 @@ All notable changes to this project will be documented in this file.
- Add Hetzner affiliate link to token form
- Update Hetzner affiliate link text and URL
- Add CPU vendor information to server types in Hetzner integration
- Implement TrustHosts middleware to handle FQDN and IP address trust logic
- Implement TrustHosts middleware to handle FQDN and IP address trust logic
- Allow safe environment variable defaults in array-format volumes
- Add signoz template
- *(signoz)* Replace png icon by svg icon
- *(signoz)* Remove explicit 'networks' setting
- *(signoz)* Add predefined environment variables to configure Telemetry, SMTP and email sending for Alert Manager
- *(signoz)* Generate URLs for `otel-collector` service
- *(signoz)* Update documentation link
- *(signoz)* Add healthcheck to otel-collector service
- *(signoz)* Use latest tag instead of hardcoded versions
- *(signoz)* Remove redundant users.xml volume from clickhouse container
- *(signoz)* Replace clickhouse' config.xml volume with simpler configuration
- *(signoz)* Remove deprecated parameters of signoz container
- *(signoz)* Remove volumes from signoz.yaml
- *(signoz)* Assume there is a single zookeeper container
- *(signoz)* Update Clickhouse config to include all settings required by Signoz
- *(signoz)* Update config.xml and users.xml to ensure clickhouse boots correctly
- *(signoz)* Update otel-collector configuration to match upstream
- *(signoz)* Fix otel-collector config for version v0.128.0
- *(signoz)* Remove unecessary port mapping for otel-collector
- *(signoz)* Add SIGNOZ_JWT_SECRET env var generation
- *(signoz)* Upgrade clickhouse image to 25.5.6
- *(signoz)* Use latest tag for signoz/zookeeper
- *(signoz)* Update variables for SMTP configuration
- *(signoz)* Replace deprecated `TELEMETRY_ENABLED` by `SIGNOZ_STATSREPORTER_ENABLED`
- *(signoz)* Pin service image tags and `exclude_from_hc` flag to services excluded from health checks
- *(templates)* Add SMTP configuration to ente-photos compose templates
- *(templates)* Add SMTP encryption configuration to ente-photos compose templates
### 🐛 Bug Fixes
@@ -230,6 +343,25 @@ All notable changes to this project will be documented in this file.
- Use computed imageTag variable for digest-based Docker images
- Improve Docker image digest handling and add auto-parse feature
- 'new image' quick action not progressing to resource selection
- Use wasChanged() instead of isDirty() in updated hooks
- Prevent command injection in git ls-remote operations
- Handle null environment variable values in bash escaping
- Critical privilege escalation in team invitation system
- Add authentication context to TeamPolicyTest
- Ensure negative cache results are stored in TrustHosts middleware
- Use wasChanged() instead of isDirty() in updated hook
- Prevent command injection in Docker Compose parsing - add pre-save validation
- Use canonical parser for Windows path validation
- Correct variable name typo in generateGitLsRemoteCommands method
- Update version numbers to 4.0.0-beta.436 and 4.0.0-beta.437
- Ensure authorization checks are in place for viewing and updating the application
- Ensure authorization check is performed during component mount
- *(signoz)* Remove example secrets to avoid triggering GitGuardian
- *(signoz)* Remove hardcoded container names
- *(signoz)* Remove HTTP collector FQDN in otel-collector
- *(n8n)* Add DB_SQLITE_POOL_SIZE environment variable for configuration
- *(template)* Remove default values for environment variables
- Update metamcp image version and clean up environment variable syntax
### 💼 Other
@@ -242,6 +374,8 @@ All notable changes to this project will be documented in this file.
- Remove volumes
- Add ray logging for Hetzner createServer API request/response
- Escape all shell directory paths in Git deployment commands
- Remove content from docker_compose_raw to prevent file overwrites
- *(templates)* Metamcp app
### 🚜 Refactor
@@ -269,6 +403,10 @@ All notable changes to this project will be documented in this file.
- Migrate database components from legacy model binding to explicit properties
- Volumes set back to ./pds-data:/pds
- *(campfire)* Streamline environment variable definitions in Docker Compose file
- Improve validation error handling and coding standards
- Preserve exception chain in validation error handling
- Harden and deduplicate validateShellSafePath
- Replace random ID generation with Cuid2 for unique HTML IDs in form components
### 📚 Documentation
@@ -292,12 +430,16 @@ All notable changes to this project will be documented in this file.
### 🧪 Testing
- Improve Git ls-remote parsing tests with uppercase SHA and negative cases
- Add coverage for newline and tab rejection in volume strings
### ⚙️ Miscellaneous Tasks
- *(versions)* Update Coolify version numbers to 4.0.0-beta.435 and 4.0.0-beta.436
- Update package-lock.json
- *(service)* Update convex template and image
- *(signoz)* Remove unused ports
- *(signoz)* Bump version to 0.77.0
- *(signoz)* Bump version to 0.78.1
## [4.0.0-beta.434] - 2025-10-03
+1
View File
@@ -146,6 +146,7 @@ class MyComponent extends Component
- State management handled on the server
- Use wire:model for two-way data binding
- Dispatch events for component communication
- **CRITICAL**: Livewire component views **MUST** have exactly ONE root element. ALL content must be contained within this single root element. Placing ANY elements (`<style>`, `<script>`, `<div>`, comments, or any other HTML) outside the root element will break Livewire's component tracking and cause `wire:click` and other directives to fail silently.
### Code Organization Patterns
- **Actions Pattern**: Use Actions for complex business logic (`app/Actions/`)
+1 -2
View File
@@ -70,10 +70,9 @@ Thank you so much!
* [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
* [Dade2](https://dade2.net/?ref=coolify.io) - IT Consulting, Cloud Solutions & System Integration
* [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
+1 -1
View File
@@ -105,7 +105,7 @@ class StartClickhouse
$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 stop --time=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.'";
+1 -1
View File
@@ -192,7 +192,7 @@ class StartDragonfly
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 stop --time=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.'";
+1 -1
View File
@@ -208,7 +208,7 @@ class StartKeydb
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 stop --time=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.'";
+1 -1
View File
@@ -209,7 +209,7 @@ class StartMariadb
$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 stop --time=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.'";
+1 -1
View File
@@ -260,7 +260,7 @@ class StartMongodb
$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 stop --time=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) {
+1 -1
View File
@@ -210,7 +210,7 @@ class StartMysql
$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 stop --time=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";
+1 -3
View File
@@ -223,7 +223,7 @@ class StartPostgresql
$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 stop --time=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) {
@@ -231,8 +231,6 @@ class StartPostgresql
}
$this->commands[] = "echo 'Database started.'";
ray($this->commands);
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}
+1 -1
View File
@@ -205,7 +205,7 @@ class StartRedis
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 stop --time=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.'";
+1 -1
View File
@@ -49,7 +49,7 @@ class StopDatabase
{
$server = $database->destination->server;
instant_remote_process(command: [
"docker stop --timeout=$timeout $containerName",
"docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
+82 -11
View File
@@ -10,6 +10,7 @@ use App\Models\Server;
use App\Models\ServiceDatabase;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Lorisleiva\Actions\Concerns\AsAction;
class GetContainersStatus
@@ -28,6 +29,8 @@ class GetContainersStatus
protected ?Collection $applicationContainerStatuses;
protected ?Collection $applicationContainerRestartCounts;
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{
$this->containers = $containers;
@@ -136,6 +139,18 @@ class GetContainersStatus
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
// Track restart counts for applications
$restartCount = data_get($container, 'RestartCount', 0);
if (! isset($this->applicationContainerRestartCounts)) {
$this->applicationContainerRestartCounts = collect();
}
if (! $this->applicationContainerRestartCounts->has($applicationId)) {
$this->applicationContainerRestartCounts->put($applicationId, collect());
}
if ($containerName) {
$this->applicationContainerRestartCounts->get($applicationId)->put($containerName, $restartCount);
}
} else {
// Notify user that this container should not be there.
}
@@ -291,7 +306,24 @@ class GetContainersStatus
continue;
}
$application->update(['status' => 'exited']);
// If container was recently restarting (crash loop), keep it as degraded for a grace period
// This prevents false "exited" status during the brief moment between container removal and recreation
$recentlyRestarted = $application->restart_count > 0 &&
$application->last_restart_at &&
$application->last_restart_at->greaterThan(now()->subSeconds(30));
if ($recentlyRestarted) {
// Keep it as degraded if it was recently in a crash loop
$application->update(['status' => 'degraded (unhealthy)']);
} else {
// Reset restart count when application exits completely
$application->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
}
}
$notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews);
foreach ($notRunningApplicationPreviews as $previewId) {
@@ -340,22 +372,56 @@ class GetContainersStatus
continue;
}
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
if ($aggregatedStatus) {
$statusFromDb = $application->status;
if ($statusFromDb !== $aggregatedStatus) {
$application->update(['status' => $aggregatedStatus]);
} else {
$application->update(['last_online_at' => now()]);
}
// Track restart counts first
$maxRestartCount = 0;
if (isset($this->applicationContainerRestartCounts) && $this->applicationContainerRestartCounts->has($applicationId)) {
$containerRestartCounts = $this->applicationContainerRestartCounts->get($applicationId);
$maxRestartCount = $containerRestartCounts->max() ?? 0;
}
// Wrap all database updates in a transaction to ensure consistency
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
$previousRestartCount = $application->restart_count ?? 0;
if ($maxRestartCount > $previousRestartCount) {
// Restart count increased - this is a crash restart
$application->update([
'restart_count' => $maxRestartCount,
'last_restart_at' => now(),
'last_restart_type' => 'crash',
]);
// Send notification
$containerName = $application->name;
$projectUuid = data_get($application, 'environment.project.uuid');
$environmentName = data_get($application, 'environment.name');
$applicationUuid = data_get($application, 'uuid');
if ($projectUuid && $applicationUuid && $environmentName) {
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
} else {
$url = null;
}
}
// Aggregate status after tracking restart counts
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount);
if ($aggregatedStatus) {
$statusFromDb = $application->status;
if ($statusFromDb !== $aggregatedStatus) {
$application->update(['status' => $aggregatedStatus]);
} else {
$application->update(['last_online_at' => now()]);
}
}
});
}
}
ServiceChecked::dispatch($this->server->team->id);
}
private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
private function aggregateApplicationStatus($application, Collection $containerStatuses, int $maxRestartCount = 0): ?string
{
// Parse docker compose to check for excluded containers
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
@@ -413,6 +479,11 @@ class GetContainersStatus
return 'degraded (unhealthy)';
}
// If container is exited but has restart count > 0, it's in a crash loop
if ($hasExited && $maxRestartCount > 0) {
return 'degraded (unhealthy)';
}
if ($hasRunning && $hasExited) {
return 'degraded (unhealthy)';
}
@@ -421,7 +492,7 @@ class GetContainersStatus
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
}
// All containers are exited
// All containers are exited with no restart count - truly stopped
return 'exited (unhealthy)';
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ class CleanupDocker
$realtimeImageWithoutPrefix = 'coollabsio/coolify-realtime';
$realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion";
$helperImageVersion = data_get($settings, 'helper_version');
$helperImageVersion = getHelperVersion();
$helperImage = config('constants.coolify.helper_image');
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
+55 -2
View File
@@ -11,9 +11,11 @@ class InstallDocker
{
use AsAction;
private string $dockerVersion;
public function handle(Server $server)
{
$dockerVersion = config('constants.docker.minimum_required_version');
$this->dockerVersion = config('constants.docker.minimum_required_version');
$supported_os_type = $server->validateOS();
if (! $supported_os_type) {
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>.');
@@ -99,7 +101,19 @@ class InstallDocker
}
$command = $command->merge([
"echo 'Installing Docker Engine...'",
"curl https://releases.rancher.com/install-docker/{$dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$dockerVersion}",
]);
if ($supported_os_type->contains('debian')) {
$command = $command->merge([$this->getDebianDockerInstallCommand()]);
} elseif ($supported_os_type->contains('rhel')) {
$command = $command->merge([$this->getRhelDockerInstallCommand()]);
} elseif ($supported_os_type->contains('sles')) {
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
} else {
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
}
$command = $command->merge([
"echo 'Configuring Docker Engine (merging existing configuration with the required)...'",
'test -s /etc/docker/daemon.json && cp /etc/docker/daemon.json "/etc/docker/daemon.json.original-$(date +"%Y%m%d-%H%M%S")"',
"test ! -s /etc/docker/daemon.json && echo '{$config}' | base64 -d | tee /etc/docker/daemon.json > /dev/null",
@@ -128,4 +142,43 @@ class InstallDocker
return remote_process($command, $server);
}
}
private function getDebianDockerInstallCommand(): string
{
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
'install -m 0755 -d /etc/apt/keyrings && '.
'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && '.
'chmod a+r /etc/apt/keyrings/docker.asc && '.
'. /etc/os-release && '.
'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list && '.
'apt-get update && '.
'apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin'.
')';
}
private function getRhelDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
'dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && '.
'dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
'systemctl start docker && '.
'systemctl enable docker'.
')';
}
private function getSuseDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
'zypper addrepo https://download.docker.com/linux/sles/docker-ce.repo && '.
'zypper refresh && '.
'zypper install -y --no-confirm docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
'systemctl start docker && '.
'systemctl enable docker'.
')';
}
private function getGenericDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
}
}
+3 -2
View File
@@ -2,7 +2,6 @@
namespace App\Actions\Server;
use App\Jobs\PullHelperImageJob;
use App\Models\Server;
use Illuminate\Support\Sleep;
use Lorisleiva\Actions\Concerns\AsAction;
@@ -50,7 +49,9 @@ class UpdateCoolify
private function update()
{
PullHelperImageJob::dispatch($this->server);
$helperImage = config('constants.coolify.helper_image');
$latest_version = getHelperVersion();
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
instant_remote_process(["docker pull -q $image"], $this->server, false);
+9 -4
View File
@@ -20,18 +20,23 @@ class StartService
}
$service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
$commands[] = 'cd '.$service->workdir();
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
$workdir = $service->workdir();
// $commands[] = "cd {$workdir}";
$commands[] = "echo 'Saved configuration files to {$workdir}.'";
// Ensure .env exists in the correct directory before docker compose tries to load it
// This is defensive programming - saveComposeConfigs() already creates it,
// but we guarantee it here in case of any edge cases or manual deployments
$commands[] = "touch {$workdir}/.env";
if ($pullLatestImages) {
$commands[] = "echo 'Pulling images.'";
$commands[] = 'docker compose pull';
$commands[] = "docker compose --project-directory {$workdir} pull";
}
if ($service->networks()->count() > 0) {
$commands[] = "echo 'Creating Docker network.'";
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
}
$commands[] = 'echo Starting service.';
$commands[] = 'docker compose up -d --remove-orphans --force-recreate --build';
$commands[] = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --remove-orphans --force-recreate --build";
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
if (data_get($service, 'connect_to_docker_network')) {
$compose = data_get($service, 'docker_compose', []);
+135 -1
View File
@@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Redis;
class CleanupRedis extends Command
{
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks}';
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks} {--restart : Aggressive cleanup mode for system restart (marks all processing jobs as failed)}';
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)';
@@ -63,6 +63,14 @@ class CleanupRedis extends Command
$deletedCount += $locksCleaned;
}
// Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
$isRestart = $this->option('restart');
if ($isRestart || $this->option('clear-locks')) {
$this->info($isRestart ? 'Cleaning up stuck jobs (RESTART MODE - aggressive)...' : 'Checking for stuck jobs (runtime mode - conservative)...');
$jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart);
$deletedCount += $jobsCleaned;
}
if ($dryRun) {
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
} else {
@@ -332,4 +340,130 @@ class CleanupRedis extends Command
return $cleanedCount;
}
/**
* Clean up stuck jobs based on mode (restart vs runtime).
*
* @param mixed $redis Redis connection
* @param string $prefix Horizon prefix
* @param bool $dryRun Dry run mode
* @param bool $isRestart Restart mode (aggressive) vs runtime mode (conservative)
* @return int Number of jobs cleaned
*/
private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $isRestart): int
{
$cleanedCount = 0;
$now = time();
// Get all keys with the horizon prefix
$cursor = 0;
$keys = [];
do {
$result = $redis->scan($cursor, ['match' => '*', 'count' => 100]);
// Guard against scan() returning false
if ($result === false) {
$this->error('Redis scan failed, stopping key retrieval');
break;
}
$cursor = $result[0];
$keys = array_merge($keys, $result[1]);
} while ($cursor !== 0);
foreach ($keys as $key) {
$keyWithoutPrefix = str_replace($prefix, '', $key);
$type = $redis->command('type', [$keyWithoutPrefix]);
// Only process hash-type keys (individual jobs)
if ($type !== 5) {
continue;
}
$data = $redis->command('hgetall', [$keyWithoutPrefix]);
$status = data_get($data, 'status');
$payload = data_get($data, 'payload');
// Only process jobs in "processing" or "reserved" state
if (! in_array($status, ['processing', 'reserved'])) {
continue;
}
// Parse job payload to get job class and started time
$payloadData = json_decode($payload, true);
// Check for JSON decode errors
if ($payloadData === null || json_last_error() !== JSON_ERROR_NONE) {
$errorMsg = json_last_error_msg();
$truncatedPayload = is_string($payload) ? substr($payload, 0, 200) : 'non-string payload';
$this->error("Failed to decode job payload for {$keyWithoutPrefix}: {$errorMsg}. Payload: {$truncatedPayload}");
continue;
}
$jobClass = data_get($payloadData, 'displayName', 'Unknown');
// Prefer reserved_at (when job started processing), fallback to created_at
$reservedAt = (int) data_get($data, 'reserved_at', 0);
$createdAt = (int) data_get($data, 'created_at', 0);
$startTime = $reservedAt ?: $createdAt;
// If we can't determine when the job started, skip it
if (! $startTime) {
continue;
}
// Calculate how long the job has been processing
$processingTime = $now - $startTime;
$shouldFail = false;
$reason = '';
if ($isRestart) {
// RESTART MODE: Mark ALL processing/reserved jobs as failed
// Safe because all workers are dead on restart
$shouldFail = true;
$reason = 'System restart - all workers terminated';
} else {
// RUNTIME MODE: Only mark truly stuck jobs as failed
// Be conservative to avoid killing legitimate long-running jobs
// Skip ApplicationDeploymentJob entirely (has dynamic_timeout, can run 2+ hours)
if (str_contains($jobClass, 'ApplicationDeploymentJob')) {
continue;
}
// Skip DatabaseBackupJob (large backups can take hours)
if (str_contains($jobClass, 'DatabaseBackupJob')) {
continue;
}
// For other jobs, only fail if processing > 12 hours
if ($processingTime > 43200) { // 12 hours
$shouldFail = true;
$reason = 'Processing for more than 12 hours';
}
}
if ($shouldFail) {
if ($dryRun) {
$this->warn(" Would mark as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1)." min) - {$reason}");
} else {
// Mark job as failed
$redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
$redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
$redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]);
$this->info(" ✓ Marked as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1).' min) - '.$reason);
}
$cleanedCount++;
}
}
if ($cleanedCount === 0) {
$this->info($isRestart ? ' No jobs to clean up' : ' No stuck jobs found (all jobs running normally)');
}
return $cleanedCount;
}
}
@@ -222,9 +222,14 @@ class CleanupStuckedResources extends Command
try {
$scheduled_backups = ScheduledDatabaseBackup::all();
foreach ($scheduled_backups as $scheduled_backup) {
if (! $scheduled_backup->server()) {
echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
$scheduled_backup->delete();
try {
$server = $scheduled_backup->server();
if (! $server) {
echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
$scheduled_backup->delete();
}
} catch (\Throwable $e) {
echo "Error checking server for scheduledbackup {$scheduled_backup->id}: {$e->getMessage()}\n";
}
}
} catch (\Throwable $e) {
@@ -416,7 +421,7 @@ class CleanupStuckedResources extends Command
foreach ($serviceApplications as $service) {
if (! data_get($service, 'service')) {
echo 'ServiceApplication without service: '.$service->name.'\n';
DeleteResourceJob::dispatch($service);
$service->forceDelete();
continue;
}
@@ -429,7 +434,7 @@ class CleanupStuckedResources extends Command
foreach ($serviceDatabases as $service) {
if (! data_get($service, 'service')) {
echo 'ServiceDatabase without service: '.$service->name.'\n';
DeleteResourceJob::dispatch($service);
$service->forceDelete();
continue;
}
@@ -0,0 +1,219 @@
<?php
namespace App\Console\Commands\Cloud;
use Illuminate\Console\Command;
class RestoreDatabase extends Command
{
protected $signature = 'cloud:restore-database {file : Path to the database dump file} {--debug : Show detailed debug output}';
protected $description = 'Restore a PostgreSQL database from a dump file (development mode only)';
private bool $debug = false;
public function handle(): int
{
$this->debug = $this->option('debug');
if (! $this->isDevelopment()) {
$this->error('This command can only be run in development mode.');
return 1;
}
$filePath = $this->argument('file');
if (! file_exists($filePath)) {
$this->error("File not found: {$filePath}");
return 1;
}
if (! is_readable($filePath)) {
$this->error("File is not readable: {$filePath}");
return 1;
}
try {
$this->info('Starting database restoration...');
$database = config('database.connections.pgsql.database');
$host = config('database.connections.pgsql.host');
$port = config('database.connections.pgsql.port');
$username = config('database.connections.pgsql.username');
$password = config('database.connections.pgsql.password');
if (! $database || ! $username) {
$this->error('Database configuration is incomplete.');
return 1;
}
$this->info("Restoring to database: {$database}");
// Drop all tables
if (! $this->dropAllTables($database, $host, $port, $username, $password)) {
return 1;
}
// Restore the database dump
if (! $this->restoreDatabaseDump($filePath, $database, $host, $port, $username, $password)) {
return 1;
}
$this->info('Database restoration completed successfully!');
return 0;
} catch (\Exception $e) {
$this->error("An error occurred: {$e->getMessage()}");
return 1;
}
}
private function dropAllTables(string $database, string $host, string $port, string $username, string $password): bool
{
$this->info('Dropping all tables...');
// SQL to drop all tables
$dropTablesSQL = <<<'SQL'
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
SQL;
// Build the psql command to drop all tables
$command = sprintf(
'PGPASSWORD=%s psql -h %s -p %s -U %s -d %s -c %s',
escapeshellarg($password),
escapeshellarg($host),
escapeshellarg($port),
escapeshellarg($username),
escapeshellarg($database),
escapeshellarg($dropTablesSQL)
);
if ($this->debug) {
$this->line('<comment>Executing drop command:</comment>');
$this->line($command);
}
$output = shell_exec($command.' 2>&1');
if ($this->debug) {
$this->line("<comment>Output:</comment> {$output}");
}
$this->info('All tables dropped successfully.');
return true;
}
private function restoreDatabaseDump(string $filePath, string $database, string $host, string $port, string $username, string $password): bool
{
$this->info('Restoring database from dump file...');
// Handle gzipped files by decompressing first
$actualFile = $filePath;
if (str_ends_with($filePath, '.gz')) {
$actualFile = rtrim($filePath, '.gz');
$this->info('Decompressing gzipped dump file...');
$decompressCommand = sprintf(
'gunzip -c %s > %s',
escapeshellarg($filePath),
escapeshellarg($actualFile)
);
if ($this->debug) {
$this->line('<comment>Executing decompress command:</comment>');
$this->line($decompressCommand);
}
$decompressOutput = shell_exec($decompressCommand.' 2>&1');
if ($this->debug && $decompressOutput) {
$this->line("<comment>Decompress output:</comment> {$decompressOutput}");
}
}
// Use pg_restore for custom format dumps
$command = sprintf(
'PGPASSWORD=%s pg_restore -h %s -p %s -U %s -d %s -v %s',
escapeshellarg($password),
escapeshellarg($host),
escapeshellarg($port),
escapeshellarg($username),
escapeshellarg($database),
escapeshellarg($actualFile)
);
if ($this->debug) {
$this->line('<comment>Executing restore command:</comment>');
$this->line($command);
}
// Execute the restore command
$process = proc_open(
$command,
[
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
],
$pipes
);
if (! is_resource($process)) {
$this->error('Failed to start restoration process.');
return false;
}
$output = stream_get_contents($pipes[1]);
$error = stream_get_contents($pipes[2]);
$exitCode = proc_close($process);
// Clean up decompressed file if we created one
if ($actualFile !== $filePath && file_exists($actualFile)) {
unlink($actualFile);
}
if ($this->debug) {
if ($output) {
$this->line('<comment>Output:</comment>');
$this->line($output);
}
if ($error) {
$this->line('<comment>Error output:</comment>');
$this->line($error);
}
$this->line("<comment>Exit code:</comment> {$exitCode}");
}
if ($exitCode !== 0) {
$this->error("Restoration failed with exit code: {$exitCode}");
if ($error) {
$this->error('Error details:');
$this->error($error);
}
return false;
}
if ($output && ! $this->debug) {
$this->line($output);
}
return true;
}
private function isDevelopment(): bool
{
return app()->environment(['local', 'development', 'dev']);
}
}
+41
View File
@@ -4,6 +4,9 @@ namespace App\Console\Commands;
use App\Jobs\CheckHelperImageJob;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTaskExecution;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
@@ -45,6 +48,44 @@ class Dev extends Command
} else {
echo "Instance already initialized.\n";
}
// Clean up stuck jobs and stale locks on development startup
try {
echo "Cleaning up Redis (stuck jobs and stale locks)...\n";
Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
echo "Redis cleanup completed.\n";
} catch (\Throwable $e) {
echo "Error in cleanup:redis: {$e->getMessage()}\n";
}
try {
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
'status' => 'failed',
'message' => 'Marked as failed during Coolify startup - job was interrupted',
'finished_at' => Carbon::now(),
]);
if ($updatedTaskCount > 0) {
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
}
} catch (\Throwable $e) {
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
}
try {
$updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
'status' => 'failed',
'message' => 'Marked as failed during Coolify startup - job was interrupted',
'finished_at' => Carbon::now(),
]);
if ($updatedBackupCount > 0) {
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
}
} catch (\Throwable $e) {
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
}
CheckHelperImageJob::dispatch();
}
}
+1 -1
View File
@@ -167,7 +167,7 @@ class Emails extends Command
]);
}
$output = 'Because of an error, the backup of the database '.$db->name.' failed.';
$this->mail = (new BackupFailed($backup, $db, $output))->toMail();
$this->mail = (new BackupFailed($backup, $db, $output, $backup->database_name ?? 'unknown'))->toMail();
$this->sendEmail();
break;
case 'backup-success':
+33 -1
View File
@@ -10,9 +10,12 @@ use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
@@ -73,7 +76,7 @@ class Init extends Command
$this->cleanupUnusedNetworkFromCoolifyProxy();
try {
$this->call('cleanup:redis', ['--clear-locks' => true]);
$this->call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
} catch (\Throwable $e) {
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
}
@@ -86,6 +89,7 @@ class Init extends Command
$this->call('cleanup:stucked-resources');
} catch (\Throwable $e) {
echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n";
echo "Continuing with initialization - cleanup errors will not prevent Coolify from starting\n";
}
try {
$updatedCount = ApplicationDeploymentQueue::whereIn('status', [
@@ -102,6 +106,34 @@ class Init extends Command
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
}
try {
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
'status' => 'failed',
'message' => 'Marked as failed during Coolify startup - job was interrupted',
'finished_at' => Carbon::now(),
]);
if ($updatedTaskCount > 0) {
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
}
} catch (\Throwable $e) {
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
}
try {
$updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
'status' => 'failed',
'message' => 'Marked as failed during Coolify startup - job was interrupted',
'finished_at' => Carbon::now(),
]);
if ($updatedBackupCount > 0) {
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
}
} catch (\Throwable $e) {
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
}
try {
$localhost = $this->servers->where('id', 0)->first();
if ($localhost) {
+117 -32
View File
@@ -26,9 +26,9 @@ class SyncBunny extends Command
protected $description = 'Sync files to BunnyCDN';
/**
* Fetch GitHub releases and sync to CDN
* Fetch GitHub releases and sync to GitHub repository
*/
private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn)
private function syncReleasesToGitHubRepo(): bool
{
$this->info('Fetching releases from GitHub...');
try {
@@ -37,33 +37,122 @@ class SyncBunny extends Command
'per_page' => 30, // Fetch more releases for better changelog
]);
if ($response->successful()) {
$releases = $response->json();
// Save releases to a temporary file
$releases_file = "$parent_dir/releases.json";
file_put_contents($releases_file, json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
// Upload to CDN
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $releases_file)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/releases.json"),
$pool->purge("$bunny_cdn/coolify/releases.json"),
]);
// Clean up temporary file
unlink($releases_file);
$this->info('releases.json uploaded & purged...');
$this->info('Total releases synced: '.count($releases));
return true;
} else {
if (! $response->successful()) {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
$releases = $response->json();
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp;
$branchName = 'update-releases-'.$timestamp;
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write releases.json to: $releasesPath");
$this->error('Possible reasons: directory does not exist, permission denied, or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Stage and commit
$this->info('Committing changes...');
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('Releases are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
$commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR Output: '.implode("\n", $output));
}
$this->info('Total releases synced: '.count($releases));
return true;
} catch (\Throwable $e) {
$this->error('Error fetching releases: '.$e->getMessage());
$this->error('Error syncing releases: '.$e->getMessage());
return false;
}
@@ -174,11 +263,7 @@ class SyncBunny extends Command
return;
}
// First sync GitHub releases
$this->info('Syncing GitHub releases first...');
$this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn);
// Then sync versions.json
// Sync versions.json to BunnyCDN
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
@@ -187,14 +272,14 @@ class SyncBunny extends Command
return;
} elseif ($only_github_releases) {
$this->info('About to sync GitHub releases to BunnyCDN.');
$this->info('About to sync GitHub releases to GitHub repository.');
$confirmed = confirm('Are you sure you want to sync GitHub releases?');
if (! $confirmed) {
return;
}
// Use the reusable function
$this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn);
// Sync releases to GitHub repository
$this->syncReleasesToGitHubRepo();
return;
}
@@ -0,0 +1,791 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Yaml\Yaml;
class UpdateServiceVersions extends Command
{
protected $signature = 'services:update-versions
{--service= : Update specific service template}
{--dry-run : Show what would be updated without making changes}
{--registry= : Filter by registry (dockerhub, ghcr, quay, codeberg)}';
protected $description = 'Update service template files with latest Docker image versions from registries';
protected array $stats = [
'total' => 0,
'updated' => 0,
'failed' => 0,
'skipped' => 0,
];
protected array $registryCache = [];
protected array $majorVersionUpdates = [];
public function handle(): int
{
$this->info('Starting service version update...');
$templateFiles = $this->getTemplateFiles();
$this->stats['total'] = count($templateFiles);
foreach ($templateFiles as $file) {
$this->processTemplate($file);
}
$this->newLine();
$this->displayStats();
return self::SUCCESS;
}
protected function getTemplateFiles(): array
{
$pattern = base_path('templates/compose/*.yaml');
$files = glob($pattern);
if ($service = $this->option('service')) {
$files = array_filter($files, fn ($file) => basename($file) === "$service.yaml");
}
return $files;
}
protected function processTemplate(string $filePath): void
{
$filename = basename($filePath);
$this->info("Processing: {$filename}");
try {
$content = file_get_contents($filePath);
$yaml = Yaml::parse($content);
if (! isset($yaml['services'])) {
$this->warn(" No services found in {$filename}");
$this->stats['skipped']++;
return;
}
$updated = false;
$updatedYaml = $yaml;
foreach ($yaml['services'] as $serviceName => $serviceConfig) {
if (! isset($serviceConfig['image'])) {
continue;
}
$currentImage = $serviceConfig['image'];
// Check if using 'latest' tag and log for manual review
if (str_contains($currentImage, ':latest')) {
$registryUrl = $this->getRegistryUrl($currentImage);
$this->warn(" {$serviceName}: {$currentImage} (using 'latest' tag)");
if ($registryUrl) {
$this->line(" → Manual review: {$registryUrl}");
}
}
$latestVersion = $this->getLatestVersion($currentImage);
if ($latestVersion && $latestVersion !== $currentImage) {
$this->line(" {$serviceName}: {$currentImage} → {$latestVersion}");
$updatedYaml['services'][$serviceName]['image'] = $latestVersion;
$updated = true;
} else {
$this->line(" {$serviceName}: {$currentImage} (up to date)");
}
}
if ($updated) {
if (! $this->option('dry-run')) {
$this->updateYamlFile($filePath, $content, $updatedYaml);
$this->stats['updated']++;
} else {
$this->warn(' [DRY RUN] Would update this file');
$this->stats['updated']++;
}
} else {
$this->stats['skipped']++;
}
} catch (\Throwable $e) {
$this->error(" Failed: {$e->getMessage()}");
$this->stats['failed']++;
}
$this->newLine();
}
protected function getLatestVersion(string $image): ?string
{
// Parse the image string
[$repository, $currentTag] = $this->parseImage($image);
// Determine registry and fetch latest version
$result = null;
if (str_starts_with($repository, 'ghcr.io/')) {
$result = $this->getGhcrLatestVersion($repository, $currentTag);
} elseif (str_starts_with($repository, 'quay.io/')) {
$result = $this->getQuayLatestVersion($repository, $currentTag);
} elseif (str_starts_with($repository, 'codeberg.org/')) {
$result = $this->getCodebergLatestVersion($repository, $currentTag);
} elseif (str_starts_with($repository, 'lscr.io/')) {
$result = $this->getDockerHubLatestVersion($repository, $currentTag);
} elseif ($this->isCustomRegistry($repository)) {
// Custom registries - skip for now, log warning
$this->warn(" Skipping custom registry: {$repository}");
$result = null;
} else {
// DockerHub (default registry - no prefix or docker.io/index.docker.io)
$result = $this->getDockerHubLatestVersion($repository, $currentTag);
}
return $result;
}
protected function isCustomRegistry(string $repository): bool
{
// List of custom/private registries that we can't query
$customRegistries = [
'docker.elastic.co/',
'docker.n8n.io/',
'docker.flipt.io/',
'docker.getoutline.com/',
'cr.weaviate.io/',
'downloads.unstructured.io/',
'budibase.docker.scarf.sh/',
'calcom.docker.scarf.sh/',
'code.forgejo.org/',
'registry.supertokens.io/',
'registry.rocket.chat/',
'nabo.codimd.dev/',
'gcr.io/',
];
foreach ($customRegistries as $registry) {
if (str_starts_with($repository, $registry)) {
return true;
}
}
return false;
}
protected function getRegistryUrl(string $image): ?string
{
[$repository] = $this->parseImage($image);
// GitHub Container Registry
if (str_starts_with($repository, 'ghcr.io/')) {
$parts = explode('/', str_replace('ghcr.io/', '', $repository));
if (count($parts) >= 2) {
return "https://github.com/{$parts[0]}/{$parts[1]}/pkgs/container/{$parts[1]}";
}
}
// Quay.io
if (str_starts_with($repository, 'quay.io/')) {
$repo = str_replace('quay.io/', '', $repository);
return "https://quay.io/repository/{$repo}?tab=tags";
}
// Codeberg
if (str_starts_with($repository, 'codeberg.org/')) {
$parts = explode('/', str_replace('codeberg.org/', '', $repository));
if (count($parts) >= 2) {
return "https://codeberg.org/{$parts[0]}/-/packages/container/{$parts[1]}";
}
}
// Docker Hub
$cleanRepo = str_replace(['index.docker.io/', 'docker.io/', 'lscr.io/'], '', $repository);
if (! str_contains($cleanRepo, '/')) {
// Official image
return "https://hub.docker.com/_/{$cleanRepo}/tags";
} else {
// User/org image
return "https://hub.docker.com/r/{$cleanRepo}/tags";
}
}
protected function parseImage(string $image): array
{
if (str_contains($image, ':')) {
[$repo, $tag] = explode(':', $image, 2);
} else {
$repo = $image;
$tag = 'latest';
}
// Handle variables in tags
if (str_contains($tag, '$')) {
$tag = 'latest'; // Default to latest for variable tags
}
return [$repo, $tag];
}
protected function getDockerHubLatestVersion(string $repository, string $currentTag): ?string
{
try {
// Check if we've already fetched tags for this repository
if (! isset($this->registryCache[$repository.'_tags'])) {
// Remove various registry prefixes
$cleanRepo = $repository;
$cleanRepo = str_replace('index.docker.io/', '', $cleanRepo);
$cleanRepo = str_replace('docker.io/', '', $cleanRepo);
$cleanRepo = str_replace('lscr.io/', '', $cleanRepo);
// For official images (no /) add library prefix
if (! str_contains($cleanRepo, '/')) {
$cleanRepo = "library/{$cleanRepo}";
}
$url = "https://hub.docker.com/v2/repositories/{$cleanRepo}/tags";
$response = Http::timeout(10)->get($url, [
'page_size' => 100,
'ordering' => 'last_updated',
]);
if (! $response->successful()) {
return null;
}
$data = $response->json();
$tags = $data['results'] ?? [];
// Cache the tags for this repository
$this->registryCache[$repository.'_tags'] = $tags;
} else {
$this->line(" [cached] Using cached tags for {$repository}");
$tags = $this->registryCache[$repository.'_tags'];
}
// Find the best matching tag
return $this->findBestTag($tags, $currentTag, $repository);
} catch (\Throwable $e) {
$this->warn(" DockerHub API error for {$repository}: {$e->getMessage()}");
return null;
}
}
protected function findLatestTagDigest(array $tags, string $targetTag = 'latest'): ?string
{
// Find the digest/sha for the target tag (usually 'latest')
foreach ($tags as $tag) {
if ($tag['name'] === $targetTag) {
return $tag['digest'] ?? $tag['images'][0]['digest'] ?? null;
}
}
return null;
}
protected function findVersionTagsForDigest(array $tags, string $digest): array
{
// Find all semantic version tags that share the same digest
$versionTags = [];
foreach ($tags as $tag) {
$tagDigest = $tag['digest'] ?? $tag['images'][0]['digest'] ?? null;
if ($tagDigest === $digest) {
$tagName = $tag['name'];
// Only include semantic version tags
if (preg_match('/^\d+\.\d+(\.\d+)?$/', $tagName)) {
$versionTags[] = $tagName;
}
}
}
return $versionTags;
}
protected function getGhcrLatestVersion(string $repository, string $currentTag): ?string
{
try {
// GHCR doesn't have a public API for listing tags without auth
// We'll try to fetch the package metadata via GitHub API
$parts = explode('/', str_replace('ghcr.io/', '', $repository));
if (count($parts) < 2) {
return null;
}
$owner = $parts[0];
$package = $parts[1];
// Try GitHub Container Registry API
$url = "https://api.github.com/users/{$owner}/packages/container/{$package}/versions";
$response = Http::timeout(10)
->withHeaders([
'Accept' => 'application/vnd.github.v3+json',
])
->get($url, ['per_page' => 100]);
if (! $response->successful()) {
// Most GHCR packages require authentication
if ($currentTag === 'latest') {
$this->warn(' ⚠ GHCR requires authentication - manual review needed');
}
return null;
}
$versions = $response->json();
$tags = [];
// Build tags array with digest information
foreach ($versions as $version) {
$digest = $version['name'] ?? null; // This is the SHA digest
if (isset($version['metadata']['container']['tags'])) {
foreach ($version['metadata']['container']['tags'] as $tag) {
$tags[] = [
'name' => $tag,
'digest' => $digest,
];
}
}
}
return $this->findBestTag($tags, $currentTag, $repository);
} catch (\Throwable $e) {
$this->warn(" GHCR API error for {$repository}: {$e->getMessage()}");
return null;
}
}
protected function getQuayLatestVersion(string $repository, string $currentTag): ?string
{
try {
// Check if we've already fetched tags for this repository
if (! isset($this->registryCache[$repository.'_tags'])) {
$cleanRepo = str_replace('quay.io/', '', $repository);
$url = "https://quay.io/api/v1/repository/{$cleanRepo}/tag/";
$response = Http::timeout(10)->get($url, ['limit' => 100]);
if (! $response->successful()) {
return null;
}
$data = $response->json();
$tags = array_map(fn ($tag) => ['name' => $tag['name']], $data['tags'] ?? []);
// Cache the tags for this repository
$this->registryCache[$repository.'_tags'] = $tags;
} else {
$this->line(" [cached] Using cached tags for {$repository}");
$tags = $this->registryCache[$repository.'_tags'];
}
return $this->findBestTag($tags, $currentTag, $repository);
} catch (\Throwable $e) {
$this->warn(" Quay API error for {$repository}: {$e->getMessage()}");
return null;
}
}
protected function getCodebergLatestVersion(string $repository, string $currentTag): ?string
{
try {
// Check if we've already fetched tags for this repository
if (! isset($this->registryCache[$repository.'_tags'])) {
// Codeberg uses Forgejo/Gitea, which has a container registry API
$cleanRepo = str_replace('codeberg.org/', '', $repository);
$parts = explode('/', $cleanRepo);
if (count($parts) < 2) {
return null;
}
$owner = $parts[0];
$package = $parts[1];
// Codeberg API endpoint for packages
$url = "https://codeberg.org/api/packages/{$owner}/container/{$package}";
$response = Http::timeout(10)->get($url);
if (! $response->successful()) {
return null;
}
$data = $response->json();
$tags = [];
if (isset($data['versions'])) {
foreach ($data['versions'] as $version) {
if (isset($version['name'])) {
$tags[] = ['name' => $version['name']];
}
}
}
// Cache the tags for this repository
$this->registryCache[$repository.'_tags'] = $tags;
} else {
$this->line(" [cached] Using cached tags for {$repository}");
$tags = $this->registryCache[$repository.'_tags'];
}
return $this->findBestTag($tags, $currentTag, $repository);
} catch (\Throwable $e) {
$this->warn(" Codeberg API error for {$repository}: {$e->getMessage()}");
return null;
}
}
protected function findBestTag(array $tags, string $currentTag, string $repository): ?string
{
if (empty($tags)) {
return null;
}
// If current tag is 'latest', find what version it actually points to
if ($currentTag === 'latest') {
// First, try to find the digest for 'latest' tag
$latestDigest = $this->findLatestTagDigest($tags, 'latest');
if ($latestDigest) {
// Find all semantic version tags that share the same digest
$versionTags = $this->findVersionTagsForDigest($tags, $latestDigest);
if (! empty($versionTags)) {
// Prefer shorter version tags (1.8 over 1.8.1)
$bestVersion = $this->preferShorterVersion($versionTags);
$this->info(" ✓ Found 'latest' points to: {$bestVersion}");
return $repository.':'.$bestVersion;
}
}
// Fallback: get the latest semantic version available (prefer shorter)
$semverTags = $this->filterSemanticVersionTags($tags);
if (! empty($semverTags)) {
$bestVersion = $this->preferShorterVersion($semverTags);
return $repository.':'.$bestVersion;
}
// If no semantic versions found, keep 'latest'
return null;
}
// Check for major version updates for reporting
$this->checkForMajorVersionUpdate($tags, $currentTag, $repository);
// If current tag is a major version (e.g., "8", "5", "16")
if (preg_match('/^\d+$/', $currentTag)) {
$majorVersion = (int) $currentTag;
$matchingTags = array_filter($tags, function ($tag) use ($majorVersion) {
$name = $tag['name'];
// Match tags that start with the major version
return preg_match("/^{$majorVersion}(\.\d+)?(\.\d+)?$/", $name);
});
if (! empty($matchingTags)) {
$versions = array_column($matchingTags, 'name');
$bestVersion = $this->preferShorterVersion($versions);
if ($bestVersion !== $currentTag) {
return $repository.':'.$bestVersion;
}
}
}
// If current tag is date-based version (e.g., "2025.06.02-sha-xxx")
if (preg_match('/^\d{4}\.\d{2}\.\d{2}/', $currentTag)) {
// Get all date-based tags
$dateTags = array_filter($tags, function ($tag) {
return preg_match('/^\d{4}\.\d{2}\.\d{2}/', $tag['name']);
});
if (! empty($dateTags)) {
$versions = array_column($dateTags, 'name');
$sorted = $this->sortSemanticVersions($versions);
$latestDate = $sorted[0];
// Compare dates
if ($latestDate !== $currentTag) {
return $repository.':'.$latestDate;
}
}
return null;
}
// If current tag is semantic version (e.g., "1.7.4", "8.0")
if (preg_match('/^\d+\.\d+(\.\d+)?$/', $currentTag)) {
$parts = explode('.', $currentTag);
$majorMinor = $parts[0].'.'.$parts[1];
$matchingTags = array_filter($tags, function ($tag) use ($majorMinor) {
$name = $tag['name'];
return str_starts_with($name, $majorMinor);
});
if (! empty($matchingTags)) {
$versions = array_column($matchingTags, 'name');
$bestVersion = $this->preferShorterVersion($versions);
if (version_compare($bestVersion, $currentTag, '>') || version_compare($bestVersion, $currentTag, '=')) {
// Only update if it's newer or if we can simplify (1.8.1 -> 1.8)
if ($bestVersion !== $currentTag) {
return $repository.':'.$bestVersion;
}
}
}
}
// If current tag is a named version (e.g., "stable")
if (in_array($currentTag, ['stable', 'lts', 'edge'])) {
// Check if the same tag exists in the list (it's up to date)
$exists = array_filter($tags, fn ($tag) => $tag['name'] === $currentTag);
if (! empty($exists)) {
return null; // Tag exists and is current
}
}
return null;
}
protected function filterSemanticVersionTags(array $tags): array
{
$semverTags = array_filter($tags, function ($tag) {
$name = $tag['name'];
// Accept semantic versions (1.2.3, v1.2.3)
if (preg_match('/^v?\d+\.\d+(\.\d+)?(\.\d+)?$/', $name)) {
// Exclude versions with suffixes like -rc, -beta, -alpha
if (preg_match('/-(rc|beta|alpha|dev|test|pre|snapshot)/i', $name)) {
return false;
}
return true;
}
// Accept date-based versions (2025.06.02, 2025.10.0, 2025.06.02-sha-xxx, RELEASE.2025-10-15T17-29-55Z)
if (preg_match('/^\d{4}\.\d{2}\.(\d{2}|\d)/', $name) || preg_match('/^RELEASE\.\d{4}-\d{2}-\d{2}/', $name)) {
return true;
}
return false;
});
return $this->sortSemanticVersions(array_column($semverTags, 'name'));
}
protected function sortSemanticVersions(array $versions): array
{
usort($versions, function ($a, $b) {
// Check if these are date-based versions (YYYY.MM.DD or YYYY.MM.D format)
$isDateA = preg_match('/^(\d{4})\.(\d{2})\.(\d{1,2})/', $a, $matchesA);
$isDateB = preg_match('/^(\d{4})\.(\d{2})\.(\d{1,2})/', $b, $matchesB);
if ($isDateA && $isDateB) {
// Both are date-based (YYYY.MM.DD), compare as dates
$dateA = $matchesA[1].$matchesA[2].str_pad($matchesA[3], 2, '0', STR_PAD_LEFT); // YYYYMMDD
$dateB = $matchesB[1].$matchesB[2].str_pad($matchesB[3], 2, '0', STR_PAD_LEFT); // YYYYMMDD
return strcmp($dateB, $dateA); // Descending order (newest first)
}
// Check if these are RELEASE date versions (RELEASE.YYYY-MM-DDTHH-MM-SSZ)
$isReleaseA = preg_match('/^RELEASE\.(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z/', $a, $matchesA);
$isReleaseB = preg_match('/^RELEASE\.(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z/', $b, $matchesB);
if ($isReleaseA && $isReleaseB) {
// Both are RELEASE format, compare as datetime
$dateTimeA = $matchesA[1].$matchesA[2].$matchesA[3].$matchesA[4].$matchesA[5].$matchesA[6]; // YYYYMMDDHHMMSS
$dateTimeB = $matchesB[1].$matchesB[2].$matchesB[3].$matchesB[4].$matchesB[5].$matchesB[6]; // YYYYMMDDHHMMSS
return strcmp($dateTimeB, $dateTimeA); // Descending order (newest first)
}
// Strip 'v' prefix for version comparison
$cleanA = ltrim($a, 'v');
$cleanB = ltrim($b, 'v');
// Fall back to semantic version comparison
return version_compare($cleanB, $cleanA); // Descending order
});
return $versions;
}
protected function preferShorterVersion(array $versions): string
{
if (empty($versions)) {
return '';
}
// Sort by version (highest first)
$sorted = $this->sortSemanticVersions($versions);
$highest = $sorted[0];
// Parse the highest version
$parts = explode('.', $highest);
// Look for shorter versions that match
// Priority: major (8) > major.minor (8.0) > major.minor.patch (8.0.39)
// Try to find just major.minor (e.g., 1.8 instead of 1.8.1)
if (count($parts) === 3) {
$majorMinor = $parts[0].'.'.$parts[1];
if (in_array($majorMinor, $versions)) {
return $majorMinor;
}
}
// Try to find just major (e.g., 8 instead of 8.0.39)
if (count($parts) >= 2) {
$major = $parts[0];
if (in_array($major, $versions)) {
return $major;
}
}
// Return the highest version we found
return $highest;
}
protected function updateYamlFile(string $filePath, string $originalContent, array $updatedYaml): void
{
// Preserve comments and formatting by updating the YAML content
$lines = explode("\n", $originalContent);
$updatedLines = [];
$inServices = false;
$currentService = null;
foreach ($lines as $line) {
// Detect if we're in the services section
if (preg_match('/^services:/', $line)) {
$inServices = true;
$updatedLines[] = $line;
continue;
}
// Detect service name (allow hyphens and underscores)
if ($inServices && preg_match('/^ ([\w-]+):/', $line, $matches)) {
$currentService = $matches[1];
$updatedLines[] = $line;
continue;
}
// Update image line
if ($currentService && preg_match('/^(\s+)image:\s*(.+)$/', $line, $matches)) {
$indent = $matches[1];
$newImage = $updatedYaml['services'][$currentService]['image'] ?? $matches[2];
$updatedLines[] = "{$indent}image: {$newImage}";
continue;
}
// If we hit a non-indented line, we're out of services
if ($inServices && preg_match('/^\S/', $line) && ! preg_match('/^services:/', $line)) {
$inServices = false;
$currentService = null;
}
$updatedLines[] = $line;
}
file_put_contents($filePath, implode("\n", $updatedLines));
}
protected function checkForMajorVersionUpdate(array $tags, string $currentTag, string $repository): void
{
// Only check semantic versions
if (! preg_match('/^v?(\d+)\./', $currentTag, $currentMatches)) {
return;
}
$currentMajor = (int) $currentMatches[1];
// Get all semantic version tags
$semverTags = $this->filterSemanticVersionTags($tags);
// Find the highest major version available
$highestMajor = $currentMajor;
foreach ($semverTags as $version) {
if (preg_match('/^v?(\d+)\./', $version, $matches)) {
$major = (int) $matches[1];
if ($major > $highestMajor) {
$highestMajor = $major;
}
}
}
// If there's a higher major version available, record it
if ($highestMajor > $currentMajor) {
$this->majorVersionUpdates[] = [
'repository' => $repository,
'current' => $currentTag,
'current_major' => $currentMajor,
'available_major' => $highestMajor,
'registry_url' => $this->getRegistryUrl($repository.':'.$currentTag),
];
}
}
protected function displayStats(): void
{
$this->info('Summary:');
$this->table(
['Metric', 'Count'],
[
['Total Templates', $this->stats['total']],
['Updated', $this->stats['updated']],
['Skipped (up to date)', $this->stats['skipped']],
['Failed', $this->stats['failed']],
]
);
// Display major version updates if any
if (! empty($this->majorVersionUpdates)) {
$this->newLine();
$this->warn('⚠ Services with available MAJOR version updates:');
$this->newLine();
$tableData = [];
foreach ($this->majorVersionUpdates as $update) {
$tableData[] = [
$update['repository'],
"v{$update['current_major']}.x",
"v{$update['available_major']}.x",
$update['registry_url'],
];
}
$this->table(
['Repository', 'Current', 'Available', 'Registry URL'],
$tableData
);
$this->newLine();
$this->comment('💡 Major version updates may include breaking changes. Review before upgrading.');
}
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* Exception for expected deployment failures caused by user/application errors.
* These are not Coolify bugs and should not be logged to laravel.log.
* Examples: Nixpacks detection failures, missing Dockerfiles, invalid configs, etc.
*/
class DeploymentException extends Exception
{
/**
* Create a new deployment exception instance.
*
* @param string $message
* @param int $code
*/
public function __construct($message = '', $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
/**
* Create from another exception, preserving its message and stack trace.
*/
public static function fromException(\Throwable $exception): static
{
return new static($exception->getMessage(), $exception->getCode(), $exception);
}
}
+1
View File
@@ -30,6 +30,7 @@ class Handler extends ExceptionHandler
protected $dontReport = [
ProcessException::class,
NonReportableException::class,
DeploymentException::class,
];
/**
@@ -1893,7 +1893,6 @@ class ApplicationsController extends Controller
public function delete_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
$cleanup = filter_var($request->query->get('cleanup', true), FILTER_VALIDATE_BOOLEAN);
if (is_null($teamId)) {
return invalidTokenResponse();
}
@@ -1912,10 +1911,10 @@ class ApplicationsController extends Controller
DeleteResourceJob::dispatch(
resource: $application,
deleteVolumes: $request->query->get('delete_volumes', true),
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true),
deleteConfigurations: $request->query->get('delete_configurations', true),
dockerCleanup: $request->query->get('docker_cleanup', true)
deleteVolumes: $request->boolean('delete_volumes', true),
deleteConnectedNetworks: $request->boolean('delete_connected_networks', true),
deleteConfigurations: $request->boolean('delete_configurations', true),
dockerCleanup: $request->boolean('docker_cleanup', true)
);
return response()->json([
@@ -3155,8 +3154,8 @@ class ApplicationsController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$force = $request->query->get('force') ?? false;
$instant_deploy = $request->query->get('instant_deploy') ?? false;
$force = $request->boolean('force', false);
$instant_deploy = $request->boolean('instant_deploy', false);
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
@@ -1619,6 +1619,18 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
}
$destination = $destinations->first();
if ($destinations->count() > 1 && $request->has('destination_uuid')) {
$destination = $destinations->where('uuid', $request->destination_uuid)->first();
if (! $destination) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.',
],
], 422);
}
}
if ($request->has('public_port') && $request->is_public) {
if (isPublicPortAlreadyUsed($server, $request->public_port)) {
return response()->json(['message' => 'Public port already used by another database.'], 400);
@@ -2133,7 +2145,6 @@ class DatabasesController extends Controller
public function delete_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
$cleanup = filter_var($request->query->get('cleanup', true), FILTER_VALIDATE_BOOLEAN);
if (is_null($teamId)) {
return invalidTokenResponse();
}
@@ -2149,10 +2160,10 @@ class DatabasesController extends Controller
DeleteResourceJob::dispatch(
resource: $database,
deleteVolumes: $request->query->get('delete_volumes', true),
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true),
deleteConfigurations: $request->query->get('delete_configurations', true),
dockerCleanup: $request->query->get('docker_cleanup', true)
deleteVolumes: $request->boolean('delete_volumes', true),
deleteConnectedNetworks: $request->boolean('delete_connected_networks', true),
deleteConfigurations: $request->boolean('delete_configurations', true),
dockerCleanup: $request->boolean('docker_cleanup', true)
);
return response()->json([
@@ -2243,7 +2254,7 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Backup configuration not found.'], 404);
}
$deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN);
$deleteS3 = $request->boolean('delete_s3', false);
try {
DB::beginTransaction();
@@ -2376,7 +2387,7 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Backup execution not found.'], 404);
}
$deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN);
$deleteS3 = $request->boolean('delete_s3', false);
try {
if ($execution->filename) {
@@ -649,10 +649,10 @@ class ServicesController extends Controller
DeleteResourceJob::dispatch(
resource: $service,
deleteVolumes: $request->query->get('delete_volumes', true),
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true),
deleteConfigurations: $request->query->get('delete_configurations', true),
dockerCleanup: $request->query->get('docker_cleanup', true)
deleteVolumes: $request->boolean('delete_volumes', true),
deleteConnectedNetworks: $request->boolean('delete_connected_networks', true),
deleteConfigurations: $request->boolean('delete_configurations', true),
dockerCleanup: $request->boolean('docker_cleanup', true)
);
return response()->json([
+70
View File
@@ -246,6 +246,40 @@ class Github extends Controller
if ($action === 'closed') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
// Cancel any active deployments for this PR immediately
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
->where('pull_request_id', $pull_request_id)
->whereIn('status', [
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
])
->first();
if ($activeDeployment) {
try {
// Mark deployment as cancelled
$activeDeployment->update([
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
// Add cancellation log entry
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
// Check if helper container exists and kill it
$deployment_uuid = $activeDeployment->deployment_uuid;
$server = $application->destination->server;
$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(["docker rm -f {$deployment_uuid}"], $server);
$activeDeployment->addLogEntry('Deployment container stopped.');
}
} catch (\Throwable $e) {
// Silently handle errors during deployment cancellation
}
}
DeleteResourceJob::dispatch($found);
$return_payloads->push([
'application' => $application->name,
@@ -481,6 +515,42 @@ class Github extends Controller
if ($action === 'closed' || $action === 'close') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
// Cancel any active deployments for this PR immediately
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
->where('pull_request_id', $pull_request_id)
->whereIn('status', [
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
])
->first();
if ($activeDeployment) {
try {
// Mark deployment as cancelled
$activeDeployment->update([
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
// Add cancellation log entry
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
// Check if helper container exists and kill it
$deployment_uuid = $activeDeployment->deployment_uuid;
$server = $application->destination->server;
$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(["docker rm -f {$deployment_uuid}"], $server);
$activeDeployment->addLogEntry('Deployment container stopped.');
}
} catch (\Throwable $e) {
// Silently handle errors during deployment cancellation
}
}
// Clean up any deployed containers
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
if ($containers->isNotEmpty()) {
$containers->each(function ($container) use ($application) {
+383 -197
View File
@@ -7,6 +7,7 @@ use App\Enums\ApplicationDeploymentStatus;
use App\Enums\ProcessStatus;
use App\Events\ApplicationConfigurationChanged;
use App\Events\ServiceStatusChanged;
use App\Exceptions\DeploymentException;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
@@ -31,7 +32,6 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
use RuntimeException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Throwable;
@@ -341,20 +341,42 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->fail($e);
throw $e;
} finally {
$this->application_deployment_queue->update([
'finished_at' => Carbon::now()->toImmutable(),
]);
if ($this->use_build_server) {
$this->server = $this->build_server;
} else {
$this->write_deployment_configurations();
// Wrap cleanup operations in try-catch to prevent exceptions from interfering
// with Laravel's job failure handling and status updates
try {
$this->application_deployment_queue->update([
'finished_at' => Carbon::now()->toImmutable(),
]);
} catch (Exception $e) {
// Log but don't fail - finished_at is not critical
\Log::warning('Failed to update finished_at for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
$this->graceful_shutdown_container($this->deployment_uuid);
try {
if ($this->use_build_server) {
$this->server = $this->build_server;
} else {
$this->write_deployment_configurations();
}
} catch (Exception $e) {
// Log but don't fail - configuration writing errors shouldn't prevent status updates
$this->application_deployment_queue->addLogEntry('Warning: Failed to write deployment configurations: '.$e->getMessage(), 'stderr');
}
ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
try {
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
$this->graceful_shutdown_container($this->deployment_uuid);
} catch (Exception $e) {
// Log but don't fail - container cleanup errors are expected when container is already gone
\Log::warning('Failed to shutdown container '.$this->deployment_uuid.': '.$e->getMessage());
}
try {
ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
} catch (Exception $e) {
// Log but don't fail - event dispatch errors shouldn't prevent status updates
\Log::warning('Failed to dispatch ServiceStatusChanged for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
}
}
@@ -459,7 +481,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function post_deployment()
{
GetContainersStatus::dispatch($this->server);
$this->next(ApplicationDeploymentStatus::FINISHED->value);
$this->completeDeployment();
if ($this->pull_request_id !== 0) {
if ($this->application->is_github_based()) {
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
@@ -954,7 +976,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} catch (Exception $e) {
$this->application_deployment_queue->addLogEntry('Failed to push image to docker registry. Please check debug logs for more information.');
if ($forceFail) {
throw new RuntimeException($e->getMessage(), 69420);
throw new DeploymentException(get_class($e).': '.$e->getMessage(), $e->getCode(), $e);
}
}
}
@@ -1008,7 +1030,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->generate_image_names();
$this->check_image_locally_or_remotely();
$this->should_skip_build();
$this->next(ApplicationDeploymentStatus::FINISHED->value);
$this->completeDeployment();
}
private function should_skip_build()
@@ -1146,6 +1168,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
foreach ($runtime_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
}
// Check for PORT environment variable mismatch with ports_exposes
if ($this->build_pack !== 'dockercompose') {
$detectedPort = $this->application->detectPortFromEnvironment(false);
if ($detectedPort && ! empty($ports) && ! in_array($detectedPort, $ports)) {
$this->application_deployment_queue->addLogEntry(
"Warning: PORT environment variable ({$detectedPort}) does not match configured ports_exposes: ".implode(',', $ports).'. It could case "bad gateway" or "no server" errors. Check the "General" page to fix it.',
'stderr'
);
}
}
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
@@ -1576,123 +1610,131 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function rolling_update()
{
$this->checkForCancellation();
if ($this->server->isSwarm()) {
$this->application_deployment_queue->addLogEntry('Rolling update started.');
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "docker stack deploy --detach=true --with-registry-auth -c {$this->workdir}{$this->docker_compose_location} {$this->application->uuid}"),
],
);
$this->application_deployment_queue->addLogEntry('Rolling update completed.');
} else {
if ($this->use_build_server) {
$this->write_deployment_configurations();
$this->server = $this->original_server;
}
if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
$this->application_deployment_queue->addLogEntry('----------------------------------------');
if (count($this->application->ports_mappings_array) > 0) {
$this->application_deployment_queue->addLogEntry('Application has ports mapped to the host system, rolling update is not supported.');
}
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
$this->application_deployment_queue->addLogEntry('Consistent container name feature enabled, rolling update is not supported.');
}
if (str($this->application->settings->custom_internal_name)->isNotEmpty()) {
$this->application_deployment_queue->addLogEntry('Custom internal name is set, rolling update is not supported.');
}
if ($this->pull_request_id !== 0) {
$this->application->settings->is_consistent_container_name_enabled = true;
$this->application_deployment_queue->addLogEntry('Pull request deployment, rolling update is not supported.');
}
if (str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
$this->application_deployment_queue->addLogEntry('Custom IP address is set, rolling update is not supported.');
}
$this->stop_running_container(force: true);
$this->start_by_compose_file();
} else {
$this->application_deployment_queue->addLogEntry('----------------------------------------');
try {
$this->checkForCancellation();
if ($this->server->isSwarm()) {
$this->application_deployment_queue->addLogEntry('Rolling update started.');
$this->start_by_compose_file();
$this->health_check();
$this->stop_running_container();
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "docker stack deploy --detach=true --with-registry-auth -c {$this->workdir}{$this->docker_compose_location} {$this->application->uuid}"),
],
);
$this->application_deployment_queue->addLogEntry('Rolling update completed.');
} else {
if ($this->use_build_server) {
$this->write_deployment_configurations();
$this->server = $this->original_server;
}
if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
$this->application_deployment_queue->addLogEntry('----------------------------------------');
if (count($this->application->ports_mappings_array) > 0) {
$this->application_deployment_queue->addLogEntry('Application has ports mapped to the host system, rolling update is not supported.');
}
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
$this->application_deployment_queue->addLogEntry('Consistent container name feature enabled, rolling update is not supported.');
}
if (str($this->application->settings->custom_internal_name)->isNotEmpty()) {
$this->application_deployment_queue->addLogEntry('Custom internal name is set, rolling update is not supported.');
}
if ($this->pull_request_id !== 0) {
$this->application->settings->is_consistent_container_name_enabled = true;
$this->application_deployment_queue->addLogEntry('Pull request deployment, rolling update is not supported.');
}
if (str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
$this->application_deployment_queue->addLogEntry('Custom IP address is set, rolling update is not supported.');
}
$this->stop_running_container(force: true);
$this->start_by_compose_file();
} else {
$this->application_deployment_queue->addLogEntry('----------------------------------------');
$this->application_deployment_queue->addLogEntry('Rolling update started.');
$this->start_by_compose_file();
$this->health_check();
$this->stop_running_container();
$this->application_deployment_queue->addLogEntry('Rolling update completed.');
}
}
} catch (Exception $e) {
throw new DeploymentException('Rolling update failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e);
}
}
private function health_check()
{
if ($this->server->isSwarm()) {
// Implement healthcheck for swarm
} else {
if ($this->application->isHealthcheckDisabled() && $this->application->custom_healthcheck_found === false) {
$this->newVersionIsHealthy = true;
try {
if ($this->server->isSwarm()) {
// Implement healthcheck for swarm
} else {
if ($this->application->isHealthcheckDisabled() && $this->application->custom_healthcheck_found === false) {
$this->newVersionIsHealthy = true;
return;
}
if ($this->application->custom_healthcheck_found) {
$this->application_deployment_queue->addLogEntry('Custom healthcheck found in Dockerfile.');
}
if ($this->container_name) {
$counter = 1;
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
return;
}
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
$sleeptime = 0;
while ($sleeptime < $this->application->health_check_start_period) {
Sleep::for(1)->seconds();
$sleeptime++;
if ($this->application->custom_healthcheck_found) {
$this->application_deployment_queue->addLogEntry('Custom healthcheck found in Dockerfile.');
}
while ($counter <= $this->application->health_check_retries) {
$this->execute_remote_command(
[
"docker inspect --format='{{json .State.Health.Status}}' {$this->container_name}",
'hidden' => true,
'save' => 'health_check',
'append' => false,
],
[
"docker inspect --format='{{json .State.Health.Log}}' {$this->container_name}",
'hidden' => true,
'save' => 'health_check_logs',
'append' => false,
],
);
$this->application_deployment_queue->addLogEntry("Attempt {$counter} of {$this->application->health_check_retries} | Healthcheck status: {$this->saved_outputs->get('health_check')}");
$health_check_logs = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'Output', '(no logs)');
if (empty($health_check_logs)) {
$health_check_logs = '(no logs)';
if ($this->container_name) {
$counter = 1;
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
}
$health_check_return_code = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'ExitCode', '(no return code)');
if ($health_check_logs !== '(no logs)' || $health_check_return_code !== '(no return code)') {
$this->application_deployment_queue->addLogEntry("Healthcheck logs: {$health_check_logs} | Return code: {$health_check_return_code}");
}
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'healthy') {
$this->newVersionIsHealthy = true;
$this->application->update(['status' => 'running']);
$this->application_deployment_queue->addLogEntry('New container is healthy.');
break;
}
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
$this->newVersionIsHealthy = false;
$this->query_logs();
break;
}
$counter++;
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
$sleeptime = 0;
while ($sleeptime < $this->application->health_check_interval) {
while ($sleeptime < $this->application->health_check_start_period) {
Sleep::for(1)->seconds();
$sleeptime++;
}
}
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'starting') {
$this->query_logs();
while ($counter <= $this->application->health_check_retries) {
$this->execute_remote_command(
[
"docker inspect --format='{{json .State.Health.Status}}' {$this->container_name}",
'hidden' => true,
'save' => 'health_check',
'append' => false,
],
[
"docker inspect --format='{{json .State.Health.Log}}' {$this->container_name}",
'hidden' => true,
'save' => 'health_check_logs',
'append' => false,
],
);
$this->application_deployment_queue->addLogEntry("Attempt {$counter} of {$this->application->health_check_retries} | Healthcheck status: {$this->saved_outputs->get('health_check')}");
$health_check_logs = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'Output', '(no logs)');
if (empty($health_check_logs)) {
$health_check_logs = '(no logs)';
}
$health_check_return_code = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'ExitCode', '(no return code)');
if ($health_check_logs !== '(no logs)' || $health_check_return_code !== '(no return code)') {
$this->application_deployment_queue->addLogEntry("Healthcheck logs: {$health_check_logs} | Return code: {$health_check_return_code}");
}
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'healthy') {
$this->newVersionIsHealthy = true;
$this->application->update(['status' => 'running']);
$this->application_deployment_queue->addLogEntry('New container is healthy.');
break;
}
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
$this->newVersionIsHealthy = false;
$this->query_logs();
break;
}
$counter++;
$sleeptime = 0;
while ($sleeptime < $this->application->health_check_interval) {
Sleep::for(1)->seconds();
$sleeptime++;
}
}
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'starting') {
$this->query_logs();
}
}
}
} catch (Exception $e) {
throw new DeploymentException('Health check failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e);
}
}
@@ -1780,9 +1822,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function prepare_builder_image(bool $firstTry = true)
{
$this->checkForCancellation();
$settings = instanceSettings();
$helperImage = config('constants.coolify.helper_image');
$helperImage = "{$helperImage}:{$settings->helper_version}";
$helperImage = "{$helperImage}:".getHelperVersion();
// Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
@@ -1790,7 +1831,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$env_flags = $this->generate_docker_env_flags_for_secrets();
if ($this->use_build_server) {
if ($this->dockerConfigFileExists === 'NOK') {
throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
}
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else {
@@ -2056,7 +2097,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->saved_outputs->get('nixpacks_type')) {
$this->nixpacks_type = $this->saved_outputs->get('nixpacks_type');
if (str($this->nixpacks_type)->isEmpty()) {
throw new RuntimeException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers');
throw new DeploymentException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers');
}
}
@@ -2322,8 +2363,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->application->parseHealthcheckFromDockerfile($this->saved_outputs->get('dockerfile_from_repo'));
}
$custom_network_aliases = [];
if (is_array($this->application->custom_network_aliases) && count($this->application->custom_network_aliases) > 0) {
$custom_network_aliases = $this->application->custom_network_aliases;
if (! empty($this->application->custom_network_aliases_array)) {
$custom_network_aliases = $this->application->custom_network_aliases_array;
}
$docker_compose = [
'services' => [
@@ -3001,55 +3042,66 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
private function stop_running_container(bool $force = false)
{
$this->application_deployment_queue->addLogEntry('Removing old containers.');
if ($this->newVersionIsHealthy || $force) {
if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
$this->graceful_shutdown_container($this->container_name);
} else {
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
if ($this->pull_request_id === 0) {
$containers = $containers->filter(function ($container) {
return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id);
try {
$this->application_deployment_queue->addLogEntry('Removing old containers.');
if ($this->newVersionIsHealthy || $force) {
if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
$this->graceful_shutdown_container($this->container_name);
} else {
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
if ($this->pull_request_id === 0) {
$containers = $containers->filter(function ($container) {
return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id);
});
}
$containers->each(function ($container) {
$this->graceful_shutdown_container(data_get($container, 'Names'));
});
}
$containers->each(function ($container) {
$this->graceful_shutdown_container(data_get($container, 'Names'));
});
} else {
if ($this->application->dockerfile || $this->application->build_pack === 'dockerfile' || $this->application->build_pack === 'dockerimage') {
$this->application_deployment_queue->addLogEntry('----------------------------------------');
$this->application_deployment_queue->addLogEntry("WARNING: Dockerfile or Docker Image based deployment detected. The healthcheck needs a curl or wget command to check the health of the application. Please make sure that it is available in the image or turn off healthcheck on Coolify's UI.");
$this->application_deployment_queue->addLogEntry('----------------------------------------');
}
$this->application_deployment_queue->addLogEntry('New container is not healthy, rolling back to the old container.');
$this->failDeployment();
$this->graceful_shutdown_container($this->container_name);
}
} else {
if ($this->application->dockerfile || $this->application->build_pack === 'dockerfile' || $this->application->build_pack === 'dockerimage') {
$this->application_deployment_queue->addLogEntry('----------------------------------------');
$this->application_deployment_queue->addLogEntry("WARNING: Dockerfile or Docker Image based deployment detected. The healthcheck needs a curl or wget command to check the health of the application. Please make sure that it is available in the image or turn off healthcheck on Coolify's UI.");
$this->application_deployment_queue->addLogEntry('----------------------------------------');
}
$this->application_deployment_queue->addLogEntry('New container is not healthy, rolling back to the old container.');
$this->application_deployment_queue->update([
'status' => ApplicationDeploymentStatus::FAILED->value,
]);
$this->graceful_shutdown_container($this->container_name);
} catch (Exception $e) {
throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e);
}
}
private function start_by_compose_file()
{
if ($this->application->build_pack === 'dockerimage') {
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
try {
// Ensure .env file exists before docker compose tries to load it (defensive programming)
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} pull"), 'hidden' => true],
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} up --build -d"), 'hidden' => true],
["touch {$this->configuration_dir}/.env", 'hidden' => true],
);
} else {
if ($this->use_build_server) {
if ($this->application->build_pack === 'dockerimage') {
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
$this->execute_remote_command(
["{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true],
[executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} pull"), 'hidden' => true],
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} up --build -d"), 'hidden' => true],
);
} else {
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up --build -d"), 'hidden' => true],
);
if ($this->use_build_server) {
$this->execute_remote_command(
["{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true],
);
} else {
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up --build -d"), 'hidden' => true],
);
}
}
$this->application_deployment_queue->addLogEntry('New container started.');
} catch (Exception $e) {
throw new DeploymentException("Failed to start container: {$e->getMessage()}", $e->getCode(), $e);
}
$this->application_deployment_queue->addLogEntry('New container started.');
}
private function analyzeBuildTimeVariables($variables)
@@ -3229,6 +3281,20 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key);
}
protected function findFromInstructionLines($dockerfile): array
{
$fromLines = [];
foreach ($dockerfile as $index => $line) {
$trimmedLine = trim($line);
// Check if line starts with FROM (case-insensitive)
if (preg_match('/^FROM\s+/i', $trimmedLine)) {
$fromLines[] = $index;
}
}
return $fromLines;
}
private function add_build_env_variables_to_dockerfile()
{
if ($this->dockerBuildkitSupported) {
@@ -3241,6 +3307,18 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
'ignore_errors' => true,
]);
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
// Find all FROM instruction positions
$fromLines = $this->findFromInstructionLines($dockerfile);
// If no FROM instructions found, skip ARG insertion
if (empty($fromLines)) {
return;
}
// Collect all ARG statements to insert
$argsToInsert = collect();
if ($this->pull_request_id === 0) {
// Only add environment variables that are available during build
$envs = $this->application->environment_variables()
@@ -3249,9 +3327,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
->get();
foreach ($envs as $env) {
if (data_get($env, 'is_multiline') === true) {
$dockerfile->splice(1, 0, ["ARG {$env->key}"]);
$argsToInsert->push("ARG {$env->key}");
} else {
$dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
}
}
// Add Coolify variables as ARGs
@@ -3261,9 +3339,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
->map(function ($var) {
return "ARG {$var}";
});
foreach ($coolify_vars as $arg) {
$dockerfile->splice(1, 0, [$arg]);
}
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
} else {
// Only add preview environment variables that are available during build
@@ -3273,9 +3349,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
->get();
foreach ($envs as $env) {
if (data_get($env, 'is_multiline') === true) {
$dockerfile->splice(1, 0, ["ARG {$env->key}"]);
$argsToInsert->push("ARG {$env->key}");
} else {
$dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
}
}
// Add Coolify variables as ARGs
@@ -3285,15 +3361,23 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
->map(function ($var) {
return "ARG {$var}";
});
foreach ($coolify_vars as $arg) {
$dockerfile->splice(1, 0, [$arg]);
}
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
}
if ($envs->isNotEmpty()) {
$secrets_hash = $this->generate_secrets_hash($envs);
$dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]);
// Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers)
if ($argsToInsert->isNotEmpty()) {
foreach (array_reverse($fromLines) as $fromLineIndex) {
// Insert all ARGs after this FROM instruction
foreach ($argsToInsert->reverse() as $arg) {
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
}
}
$envs_mapped = $envs->mapWithKeys(function ($env) {
return [$env->key => $env->real_value];
});
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
@@ -3608,7 +3692,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return;
}
}
throw new RuntimeException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
}
private function run_post_deployment_command()
@@ -3644,7 +3728,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return;
}
}
throw new RuntimeException('Post-deployment command: Could not find a valid container. Is the container name correct?');
throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
}
/**
@@ -3655,51 +3739,153 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
throw new \RuntimeException('Deployment cancelled by user', 69420);
throw new DeploymentException('Deployment cancelled by user', 69420);
}
}
private function next(string $status)
/**
* Transition deployment to a new status with proper validation and side effects.
* This is the single source of truth for status transitions.
*/
private function transitionToStatus(ApplicationDeploymentStatus $status): void
{
// Refresh to get latest status
$this->application_deployment_queue->refresh();
// Never allow changing status from FAILED or CANCELLED_BY_USER to anything else
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
$this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview));
if ($this->isInTerminalState()) {
return;
}
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
// Job was cancelled, stop execution
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
throw new \RuntimeException('Deployment cancelled by user', 69420);
}
$this->application_deployment_queue->update([
'status' => $status,
]);
$this->updateDeploymentStatus($status);
$this->handleStatusTransition($status);
queue_next_deployment($this->application);
}
if ($status === ApplicationDeploymentStatus::FINISHED->value) {
event(new ApplicationConfigurationChanged($this->application->team()->id));
/**
* Check if deployment is in a terminal state (FAILED or CANCELLED).
* Terminal states cannot be changed.
*/
private function isInTerminalState(): bool
{
$this->application_deployment_queue->refresh();
if (! $this->only_this_server) {
$this->deploy_to_additional_destinations();
}
$this->application->environment->project->team?->notify(new DeploymentSuccess($this->application, $this->deployment_uuid, $this->preview));
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
return true;
}
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
throw new DeploymentException('Deployment cancelled by user', 69420);
}
return false;
}
/**
* Update the deployment status in the database.
*/
private function updateDeploymentStatus(ApplicationDeploymentStatus $status): void
{
$this->application_deployment_queue->update([
'status' => $status->value,
]);
}
/**
* Execute status-specific side effects (events, notifications, additional deployments).
*/
private function handleStatusTransition(ApplicationDeploymentStatus $status): void
{
match ($status) {
ApplicationDeploymentStatus::FINISHED => $this->handleSuccessfulDeployment(),
ApplicationDeploymentStatus::FAILED => $this->handleFailedDeployment(),
default => null,
};
}
/**
* Handle side effects when deployment succeeds.
*/
private function handleSuccessfulDeployment(): void
{
event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) {
$this->deploy_to_additional_destinations();
}
$this->sendDeploymentNotification(DeploymentSuccess::class);
}
/**
* Handle side effects when deployment fails.
*/
private function handleFailedDeployment(): void
{
$this->sendDeploymentNotification(DeploymentFailed::class);
}
/**
* Send deployment status notification to the team.
*/
private function sendDeploymentNotification(string $notificationClass): void
{
$this->application->environment->project->team?->notify(
new $notificationClass($this->application, $this->deployment_uuid, $this->preview)
);
}
/**
* Complete deployment successfully.
* Sends success notification and triggers additional deployments if needed.
*/
private function completeDeployment(): void
{
$this->transitionToStatus(ApplicationDeploymentStatus::FINISHED);
}
/**
* Fail the deployment.
* Sends failure notification and queues next deployment.
*/
protected function failDeployment(): void
{
$this->transitionToStatus(ApplicationDeploymentStatus::FAILED);
}
public function failed(Throwable $exception): void
{
$this->next(ApplicationDeploymentStatus::FAILED->value);
$this->application_deployment_queue->addLogEntry('Oops something is not okay, are you okay? 😢', 'stderr');
if (str($exception->getMessage())->isNotEmpty()) {
$this->application_deployment_queue->addLogEntry($exception->getMessage(), 'stderr');
$this->failDeployment();
// Log comprehensive error information
$errorMessage = $exception->getMessage() ?: 'Unknown error occurred';
$errorCode = $exception->getCode();
$errorClass = get_class($exception);
$this->application_deployment_queue->addLogEntry('========================================', 'stderr');
$this->application_deployment_queue->addLogEntry("Deployment failed: {$errorMessage}", 'stderr');
$this->application_deployment_queue->addLogEntry("Error type: {$errorClass}", 'stderr', hidden: true);
$this->application_deployment_queue->addLogEntry("Error code: {$errorCode}", 'stderr', hidden: true);
// Log the exception file and line for debugging
$this->application_deployment_queue->addLogEntry("Location: {$exception->getFile()}:{$exception->getLine()}", 'stderr', hidden: true);
// Log previous exceptions if they exist (for chained exceptions)
$previous = $exception->getPrevious();
if ($previous) {
$this->application_deployment_queue->addLogEntry('Caused by:', 'stderr', hidden: true);
$previousMessage = $previous->getMessage() ?: 'No message';
$previousClass = get_class($previous);
$this->application_deployment_queue->addLogEntry(" {$previousClass}: {$previousMessage}", 'stderr', hidden: true);
$this->application_deployment_queue->addLogEntry(" at {$previous->getFile()}:{$previous->getLine()}", 'stderr', hidden: true);
}
// Log first few lines of stack trace for debugging
$trace = $exception->getTraceAsString();
$traceLines = explode("\n", $trace);
$this->application_deployment_queue->addLogEntry('Stack trace (first 5 lines):', 'stderr', hidden: true);
foreach (array_slice($traceLines, 0, 5) as $traceLine) {
$this->application_deployment_queue->addLogEntry(" {$traceLine}", 'stderr', hidden: true);
}
$this->application_deployment_queue->addLogEntry('========================================', 'stderr');
if ($this->application->build_pack !== 'dockercompose') {
$code = $exception->getCode();
if ($code !== 69420) {
+46 -3
View File
@@ -2,6 +2,8 @@
namespace App\Jobs;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -20,10 +22,51 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
public function handle(): void
{
try {
// Get all active deployments on this server
$activeDeployments = ApplicationDeploymentQueue::where('server_id', $this->server->id)
->whereIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
])
->pluck('deployment_uuid')
->toArray();
\Log::info('CleanupHelperContainersJob - Active deployments', [
'server' => $this->server->name,
'active_deployment_uuids' => $activeDeployments,
]);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
$containerIds = collect(json_decode($containers))->pluck('ID');
if ($containerIds->count() > 0) {
foreach ($containerIds as $containerId) {
$helperContainers = collect(json_decode($containers));
if ($helperContainers->count() > 0) {
foreach ($helperContainers as $container) {
$containerId = data_get($container, 'ID');
$containerName = data_get($container, 'Names');
// Check if this container belongs to an active deployment
$isActiveDeployment = false;
foreach ($activeDeployments as $deploymentUuid) {
if (str_contains($containerName, $deploymentUuid)) {
$isActiveDeployment = true;
break;
}
}
if ($isActiveDeployment) {
\Log::info('CleanupHelperContainersJob - Skipping active deployment container', [
'container' => $containerName,
'id' => $containerId,
]);
continue;
}
\Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [
'container' => $containerName,
'id' => $containerId,
]);
instant_remote_process_with_timeout(['docker container rm -f '.$containerId], $this->server, false);
}
}
+49
View File
@@ -3,18 +3,35 @@
namespace App\Jobs;
use App\Actions\CoolifyTask\RunRemoteProcess;
use App\Enums\ProcessStatus;
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\Log;
use Spatie\Activitylog\Models\Activity;
class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public $tries = 3;
/**
* The maximum number of unhandled exceptions to allow before failing.
*/
public $maxExceptions = 1;
/**
* The number of seconds the job can run before timing out.
*/
public $timeout = 600;
/**
* Create a new job instance.
*/
@@ -42,4 +59,36 @@ class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
$remote_process();
}
/**
* Calculate the number of seconds to wait before retrying the job.
*/
public function backoff(): array
{
return [30, 90, 180]; // 30s, 90s, 180s between retries
}
/**
* Handle a job failure.
*/
public function failed(?\Throwable $exception): void
{
Log::channel('scheduled-errors')->error('CoolifyTask permanently failed', [
'job' => 'CoolifyTask',
'activity_id' => $this->activity->id,
'server_uuid' => $this->activity->getExtraProperty('server_uuid'),
'command_preview' => substr($this->activity->getExtraProperty('command') ?? '', 0, 200),
'error' => $exception?->getMessage(),
'total_attempts' => $this->attempts(),
'trace' => $exception?->getTraceAsString(),
]);
// Update activity status to reflect permanent failure
$this->activity->properties = $this->activity->properties->merge([
'status' => ProcessStatus::ERROR->value,
'error' => $exception?->getMessage() ?? 'Job permanently failed',
'failed_at' => now()->toIso8601String(),
]);
$this->activity->save();
}
}
+25 -4
View File
@@ -23,6 +23,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
use Visus\Cuid2\Cuid2;
@@ -31,6 +32,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $maxExceptions = 1;
public ?Team $team = null;
public Server $server;
@@ -74,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public ScheduledDatabaseBackup $backup)
{
$this->onQueue('high');
$this->timeout = $backup->timeout;
$this->timeout = $backup->timeout ?? 3600;
}
public function handle(): void
@@ -653,24 +656,42 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
private function getFullImageName(): string
{
$settings = instanceSettings();
$helperImage = config('constants.coolify.helper_image');
$latestVersion = $settings->helper_version;
$latestVersion = getHelperVersion();
return "{$helperImage}:{$latestVersion}";
}
public function failed(?Throwable $exception): void
{
Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [
'job' => 'DatabaseBackupJob',
'backup_id' => $this->backup->uuid,
'database' => $this->database?->name ?? 'unknown',
'database_type' => get_class($this->database ?? new \stdClass),
'server' => $this->server?->name ?? 'unknown',
'total_attempts' => $this->attempts(),
'error' => $exception?->getMessage(),
'trace' => $exception?->getTraceAsString(),
]);
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
if ($log) {
$log->update([
'status' => 'failed',
'message' => 'Job failed: '.($exception?->getMessage() ?? 'Unknown error'),
'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
'size' => 0,
'filename' => null,
'finished_at' => Carbon::now(),
]);
}
// Notify team about permanent failure
if ($this->team) {
$databaseName = $log?->database_name ?? 'unknown';
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
}
}
}
+40 -3
View File
@@ -124,16 +124,54 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
$this->resource->delete();
}
// Cancel any active deployments for this PR (same logic as API cancel_deployment)
$activeDeployments = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
->where('pull_request_id', $pull_request_id)
->whereIn('status', [
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
])
->get();
foreach ($activeDeployments as $activeDeployment) {
try {
// Mark deployment as cancelled
$activeDeployment->update([
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
// Add cancellation log entry
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
// Check if helper container exists and kill it
$deployment_uuid = $activeDeployment->deployment_uuid;
$escapedDeploymentUuid = escapeshellarg($deployment_uuid);
$checkCommand = "docker ps -a --filter name={$escapedDeploymentUuid} --format '{{.Names}}'";
$containerExists = instant_remote_process([$checkCommand], $server);
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
instant_remote_process(["docker rm -f {$escapedDeploymentUuid}"], $server);
$activeDeployment->addLogEntry('Deployment container stopped.');
} else {
$activeDeployment->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
}
} catch (\Throwable $e) {
// Silently handle errors during deployment cancellation
}
}
try {
if ($server->isSwarm()) {
instant_remote_process(["docker stack rm {$application->uuid}-{$pull_request_id}"], $server);
$escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
instant_remote_process(["docker stack rm {$escapedStackName}"], $server);
} else {
$containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray();
$this->stopPreviewContainers($containers, $server);
}
} catch (\Throwable $e) {
// Log the error but don't fail the job
ray('Error stopping preview containers: '.$e->getMessage());
\Log::warning('Error stopping preview containers for application '.$application->uuid.', PR #'.$pull_request_id.': '.$e->getMessage());
}
// Finally, force delete to trigger resource cleanup
@@ -156,7 +194,6 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
"docker stop --time=$timeout $containerList",
"docker rm -f $containerList",
];
instant_remote_process(
command: $commands,
server: $server,
-30
View File
@@ -1,30 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 1000;
public function __construct(public Server $server)
{
$this->onQueue('high');
}
public function handle(): void
{
$helperImage = config('constants.coolify.helper_image');
$latest_version = instanceSettings()->helper_version;
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ class ScheduledJobManager implements ShouldQueue
{
return [
(new WithoutOverlapping('scheduled-job-manager'))
->expireAfter(60) // Lock expires after 1 minute to prevent stale locks
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
->dontRelease(), // Don't re-queue on lock conflict
];
}
+120 -3
View File
@@ -18,14 +18,30 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ScheduledTaskJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public $tries = 3;
/**
* The maximum number of unhandled exceptions to allow before failing.
*/
public $maxExceptions = 1;
/**
* The number of seconds the job can run before timing out.
*/
public $timeout = 300;
public Team $team;
public Server $server;
public ?Server $server = null;
public ScheduledTask $task;
@@ -33,6 +49,11 @@ class ScheduledTaskJob implements ShouldQueue
public ?ScheduledTaskExecution $task_log = null;
/**
* Store execution ID to survive job serialization for timeout handling.
*/
protected ?int $executionId = null;
public string $task_status = 'failed';
public ?string $task_output = null;
@@ -55,6 +76,9 @@ class ScheduledTaskJob implements ShouldQueue
}
$this->team = Team::findOrFail($task->team_id);
$this->server_timezone = $this->getServerTimezone();
// Set timeout from task configuration
$this->timeout = $this->task->timeout ?? 300;
}
private function getServerTimezone(): string
@@ -70,11 +94,18 @@ class ScheduledTaskJob implements ShouldQueue
public function handle(): void
{
$startTime = Carbon::now();
try {
$this->task_log = ScheduledTaskExecution::create([
'scheduled_task_id' => $this->task->id,
'started_at' => $startTime,
'retry_count' => $this->attempts() - 1,
]);
// Store execution ID for timeout handling
$this->executionId = $this->task_log->id;
$this->server = $this->resource->destination->server;
if ($this->resource->type() === 'application') {
@@ -129,15 +160,101 @@ class ScheduledTaskJob implements ShouldQueue
'message' => $this->task_output ?? $e->getMessage(),
]);
}
$this->team?->notify(new TaskFailed($this->task, $e->getMessage()));
// Log the error to the scheduled-errors channel
Log::channel('scheduled-errors')->error('ScheduledTask execution failed', [
'job' => 'ScheduledTaskJob',
'task_id' => $this->task->uuid,
'task_name' => $this->task->name,
'server' => $this->server?->name ?? 'unknown',
'attempt' => $this->attempts(),
'error' => $e->getMessage(),
]);
// Only notify and throw on final failure
// Re-throw to trigger Laravel's retry mechanism with backoff
throw $e;
} finally {
ScheduledTaskDone::dispatch($this->team->id);
if ($this->task_log) {
$finishedAt = Carbon::now();
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
$this->task_log->update([
'finished_at' => Carbon::now()->toImmutable(),
'finished_at' => $finishedAt->toImmutable(),
'duration' => $duration,
]);
}
}
}
/**
* Calculate the number of seconds to wait before retrying the job.
*/
public function backoff(): array
{
return [30, 60, 120]; // 30s, 60s, 120s between retries
}
/**
* Handle a job failure.
*/
public function failed(?\Throwable $exception): void
{
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
'job' => 'ScheduledTaskJob',
'task_id' => $this->task->uuid,
'task_name' => $this->task->name,
'server' => $this->server?->name ?? 'unknown',
'total_attempts' => $this->attempts(),
'error' => $exception?->getMessage(),
'trace' => $exception?->getTraceAsString(),
]);
// Reload execution log from database
// When a job times out, failed() is called in a fresh process with the original
// queue payload, so $executionId will be null. We need to query for the latest execution.
$execution = null;
// Try to find execution using stored ID first (works for non-timeout failures)
if ($this->executionId) {
$execution = ScheduledTaskExecution::find($this->executionId);
}
// If no stored ID or not found, query for the most recent execution log for this task
if (! $execution) {
$execution = ScheduledTaskExecution::query()
->where('scheduled_task_id', $this->task->id)
->orderBy('created_at', 'desc')
->first();
}
// Last resort: check task_log property
if (! $execution && $this->task_log) {
$execution = $this->task_log;
}
if ($execution) {
$errorMessage = 'Job permanently failed after '.$this->attempts().' attempts';
if ($exception) {
$errorMessage .= ': '.$exception->getMessage();
}
$execution->update([
'status' => 'failed',
'message' => $errorMessage,
'error_details' => $exception?->getTraceAsString(),
'finished_at' => Carbon::now()->toImmutable(),
]);
} else {
Log::channel('scheduled-errors')->warning('Could not find execution log to update', [
'execution_id' => $this->executionId,
'task_id' => $this->task->uuid,
]);
}
// Notify team about permanent failure
$this->team?->notify(new TaskFailed($this->task, $exception?->getMessage() ?? 'Unknown error'));
}
}
+2 -2
View File
@@ -87,7 +87,7 @@ class ServerManagerJob implements ShouldQueue
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
'error' => get_class($e).': '.$e->getMessage(),
]);
}
});
@@ -103,7 +103,7 @@ class ServerManagerJob implements ShouldQueue
Log::channel('scheduled-errors')->error('Error processing server tasks', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
'error' => get_class($e).': '.$e->getMessage(),
]);
}
}
@@ -1,35 +0,0 @@
<?php
namespace App\Livewire\Concerns;
trait SynchronizesModelData
{
/**
* Define the mapping between component properties and model keys.
*
* @return array<string, string> Array mapping property names to model keys (e.g., ['content' => 'fileStorage.content'])
*/
abstract protected function getModelBindings(): array;
/**
* Synchronize component properties TO the model.
* Copies values from component properties to the model.
*/
protected function syncToModel(): void
{
foreach ($this->getModelBindings() as $property => $modelKey) {
data_set($this, $modelKey, $this->{$property});
}
}
/**
* Synchronize component properties FROM the model.
* Copies values from the model to component properties.
*/
protected function syncFromModel(): void
{
foreach ($this->getModelBindings() as $property => $modelKey) {
$this->{$property} = data_get($this, $modelKey);
}
}
}
+361 -251
View File
@@ -3,11 +3,11 @@
namespace App\Livewire\Project\Application;
use App\Actions\Application\GenerateConfig;
use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\Application;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@@ -15,7 +15,6 @@ use Visus\Cuid2\Cuid2;
class General extends Component
{
use AuthorizesRequests;
use SynchronizesModelData;
public string $applicationId;
@@ -23,94 +22,136 @@ class General extends Component
public Collection $services;
#[Validate('required|regex:/^[a-zA-Z0-9\s\-_.\/:()]+$/')]
public string $name;
#[Validate(['string', 'nullable'])]
public ?string $description = null;
#[Validate(['nullable'])]
public ?string $fqdn = null;
public string $git_repository;
#[Validate(['required'])]
public string $gitRepository;
public string $git_branch;
#[Validate(['required'])]
public string $gitBranch;
public ?string $git_commit_sha = null;
#[Validate(['string', 'nullable'])]
public ?string $gitCommitSha = null;
public ?string $install_command = null;
#[Validate(['string', 'nullable'])]
public ?string $installCommand = null;
public ?string $build_command = null;
#[Validate(['string', 'nullable'])]
public ?string $buildCommand = null;
public ?string $start_command = null;
#[Validate(['string', 'nullable'])]
public ?string $startCommand = null;
public string $build_pack;
#[Validate(['required'])]
public string $buildPack;
public string $static_image;
#[Validate(['required'])]
public string $staticImage;
public string $base_directory;
#[Validate(['required'])]
public string $baseDirectory;
public ?string $publish_directory = null;
#[Validate(['string', 'nullable'])]
public ?string $publishDirectory = null;
public ?string $ports_exposes = null;
#[Validate(['string', 'nullable'])]
public ?string $portsExposes = null;
public ?string $ports_mappings = null;
#[Validate(['string', 'nullable'])]
public ?string $portsMappings = null;
public ?string $custom_network_aliases = null;
#[Validate(['string', 'nullable'])]
public ?string $customNetworkAliases = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerfile = null;
public ?string $dockerfile_location = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerfileLocation = null;
public ?string $dockerfile_target_build = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerfileTargetBuild = null;
public ?string $docker_registry_image_name = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageName = null;
public ?string $docker_registry_image_tag = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageTag = null;
public ?string $docker_compose_location = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeLocation = null;
public ?string $docker_compose = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerCompose = null;
public ?string $docker_compose_raw = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeRaw = null;
public ?string $docker_compose_custom_start_command = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomStartCommand = null;
public ?string $docker_compose_custom_build_command = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomBuildCommand = null;
public ?string $custom_labels = null;
#[Validate(['string', 'nullable'])]
public ?string $customDockerRunOptions = null;
public ?string $custom_docker_run_options = null;
#[Validate(['string', 'nullable'])]
public ?string $preDeploymentCommand = null;
public ?string $pre_deployment_command = null;
#[Validate(['string', 'nullable'])]
public ?string $preDeploymentCommandContainer = null;
public ?string $pre_deployment_command_container = null;
#[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommand = null;
public ?string $post_deployment_command = null;
#[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommandContainer = null;
public ?string $post_deployment_command_container = null;
#[Validate(['string', 'nullable'])]
public ?string $customNginxConfiguration = null;
public ?string $custom_nginx_configuration = null;
#[Validate(['boolean', 'required'])]
public bool $isStatic = false;
public bool $is_static = false;
#[Validate(['boolean', 'required'])]
public bool $isSpa = false;
public bool $is_spa = false;
#[Validate(['boolean', 'required'])]
public bool $isBuildServerEnabled = false;
public bool $is_build_server_enabled = false;
#[Validate(['boolean', 'required'])]
public bool $isPreserveRepositoryEnabled = false;
public bool $is_preserve_repository_enabled = false;
#[Validate(['boolean', 'required'])]
public bool $isContainerLabelEscapeEnabled = true;
public bool $is_container_label_escape_enabled = true;
#[Validate(['boolean', 'required'])]
public bool $isContainerLabelReadonlyEnabled = false;
public bool $is_container_label_readonly_enabled = false;
#[Validate(['boolean', 'required'])]
public bool $isHttpBasicAuthEnabled = false;
public bool $is_http_basic_auth_enabled = false;
#[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthUsername = null;
public ?string $http_basic_auth_username = null;
#[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthPassword = null;
public ?string $http_basic_auth_password = null;
public ?string $watch_paths = null;
#[Validate(['nullable'])]
public ?string $watchPaths = null;
#[Validate(['string', 'required'])]
public string $redirect;
#[Validate(['nullable'])]
public $customLabels;
public bool $labelsChanged = false;
@@ -141,46 +182,46 @@ class General extends Component
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable',
'git_repository' => 'required',
'git_branch' => 'required',
'git_commit_sha' => 'nullable',
'install_command' => 'nullable',
'build_command' => 'nullable',
'start_command' => 'nullable',
'build_pack' => 'required',
'static_image' => 'required',
'base_directory' => 'required',
'publish_directory' => 'nullable',
'ports_exposes' => 'required',
'ports_mappings' => 'nullable',
'custom_network_aliases' => 'nullable',
'gitRepository' => 'required',
'gitBranch' => 'required',
'gitCommitSha' => 'nullable',
'installCommand' => 'nullable',
'buildCommand' => 'nullable',
'startCommand' => 'nullable',
'buildPack' => 'required',
'staticImage' => 'required',
'baseDirectory' => 'required',
'publishDirectory' => 'nullable',
'portsExposes' => 'required',
'portsMappings' => 'nullable',
'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable',
'docker_registry_image_name' => 'nullable',
'docker_registry_image_tag' => 'nullable',
'dockerfile_location' => 'nullable',
'docker_compose_location' => 'nullable',
'docker_compose' => 'nullable',
'docker_compose_raw' => 'nullable',
'dockerfile_target_build' => 'nullable',
'docker_compose_custom_start_command' => 'nullable',
'docker_compose_custom_build_command' => 'nullable',
'custom_labels' => 'nullable',
'custom_docker_run_options' => 'nullable',
'pre_deployment_command' => 'nullable',
'pre_deployment_command_container' => 'nullable',
'post_deployment_command' => 'nullable',
'post_deployment_command_container' => 'nullable',
'custom_nginx_configuration' => 'nullable',
'is_static' => 'boolean|required',
'is_spa' => 'boolean|required',
'is_build_server_enabled' => 'boolean|required',
'is_container_label_escape_enabled' => 'boolean|required',
'is_container_label_readonly_enabled' => 'boolean|required',
'is_preserve_repository_enabled' => 'boolean|required',
'is_http_basic_auth_enabled' => 'boolean|required',
'http_basic_auth_username' => 'string|nullable',
'http_basic_auth_password' => 'string|nullable',
'watch_paths' => 'nullable',
'dockerRegistryImageName' => 'nullable',
'dockerRegistryImageTag' => 'nullable',
'dockerfileLocation' => 'nullable',
'dockerComposeLocation' => 'nullable',
'dockerCompose' => 'nullable',
'dockerComposeRaw' => 'nullable',
'dockerfileTargetBuild' => 'nullable',
'dockerComposeCustomStartCommand' => 'nullable',
'dockerComposeCustomBuildCommand' => 'nullable',
'customLabels' => 'nullable',
'customDockerRunOptions' => 'nullable',
'preDeploymentCommand' => 'nullable',
'preDeploymentCommandContainer' => 'nullable',
'postDeploymentCommand' => 'nullable',
'postDeploymentCommandContainer' => 'nullable',
'customNginxConfiguration' => 'nullable',
'isStatic' => 'boolean|required',
'isSpa' => 'boolean|required',
'isBuildServerEnabled' => 'boolean|required',
'isContainerLabelEscapeEnabled' => 'boolean|required',
'isContainerLabelReadonlyEnabled' => 'boolean|required',
'isPreserveRepositoryEnabled' => 'boolean|required',
'isHttpBasicAuthEnabled' => 'boolean|required',
'httpBasicAuthUsername' => 'string|nullable',
'httpBasicAuthPassword' => 'string|nullable',
'watchPaths' => 'nullable',
'redirect' => 'string|required',
];
}
@@ -193,26 +234,26 @@ class General extends Component
'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.',
'git_repository.required' => 'The Git Repository field is required.',
'git_branch.required' => 'The Git Branch field is required.',
'build_pack.required' => 'The Build Pack field is required.',
'static_image.required' => 'The Static Image field is required.',
'base_directory.required' => 'The Base Directory field is required.',
'ports_exposes.required' => 'The Exposed Ports field is required.',
'is_static.required' => 'The Static setting is required.',
'is_static.boolean' => 'The Static setting must be true or false.',
'is_spa.required' => 'The SPA setting is required.',
'is_spa.boolean' => 'The SPA setting must be true or false.',
'is_build_server_enabled.required' => 'The Build Server setting is required.',
'is_build_server_enabled.boolean' => 'The Build Server setting must be true or false.',
'is_container_label_escape_enabled.required' => 'The Container Label Escape setting is required.',
'is_container_label_escape_enabled.boolean' => 'The Container Label Escape setting must be true or false.',
'is_container_label_readonly_enabled.required' => 'The Container Label Readonly setting is required.',
'is_container_label_readonly_enabled.boolean' => 'The Container Label Readonly setting must be true or false.',
'is_preserve_repository_enabled.required' => 'The Preserve Repository setting is required.',
'is_preserve_repository_enabled.boolean' => 'The Preserve Repository setting must be true or false.',
'is_http_basic_auth_enabled.required' => 'The HTTP Basic Auth setting is required.',
'is_http_basic_auth_enabled.boolean' => 'The HTTP Basic Auth setting must be true or false.',
'gitRepository.required' => 'The Git Repository field is required.',
'gitBranch.required' => 'The Git Branch field is required.',
'buildPack.required' => 'The Build Pack field is required.',
'staticImage.required' => 'The Static Image field is required.',
'baseDirectory.required' => 'The Base Directory field is required.',
'portsExposes.required' => 'The Exposed Ports field is required.',
'isStatic.required' => 'The Static setting is required.',
'isStatic.boolean' => 'The Static setting must be true or false.',
'isSpa.required' => 'The SPA setting is required.',
'isSpa.boolean' => 'The SPA setting must be true or false.',
'isBuildServerEnabled.required' => 'The Build Server setting is required.',
'isBuildServerEnabled.boolean' => 'The Build Server setting must be true or false.',
'isContainerLabelEscapeEnabled.required' => 'The Container Label Escape setting is required.',
'isContainerLabelEscapeEnabled.boolean' => 'The Container Label Escape setting must be true or false.',
'isContainerLabelReadonlyEnabled.required' => 'The Container Label Readonly setting is required.',
'isContainerLabelReadonlyEnabled.boolean' => 'The Container Label Readonly setting must be true or false.',
'isPreserveRepositoryEnabled.required' => 'The Preserve Repository setting is required.',
'isPreserveRepositoryEnabled.boolean' => 'The Preserve Repository setting must be true or false.',
'isHttpBasicAuthEnabled.required' => 'The HTTP Basic Auth setting is required.',
'isHttpBasicAuthEnabled.boolean' => 'The HTTP Basic Auth setting must be true or false.',
'redirect.required' => 'The Redirect setting is required.',
'redirect.string' => 'The Redirect setting must be a string.',
]
@@ -220,43 +261,43 @@ class General extends Component
}
protected $validationAttributes = [
'application.name' => 'name',
'application.description' => 'description',
'application.fqdn' => 'FQDN',
'application.git_repository' => 'Git repository',
'application.git_branch' => 'Git branch',
'application.git_commit_sha' => 'Git commit SHA',
'application.install_command' => 'Install command',
'application.build_command' => 'Build command',
'application.start_command' => 'Start command',
'application.build_pack' => 'Build pack',
'application.static_image' => 'Static image',
'application.base_directory' => 'Base directory',
'application.publish_directory' => 'Publish directory',
'application.ports_exposes' => 'Ports exposes',
'application.ports_mappings' => 'Ports mappings',
'application.dockerfile' => 'Dockerfile',
'application.docker_registry_image_name' => 'Docker registry image name',
'application.docker_registry_image_tag' => 'Docker registry image tag',
'application.dockerfile_location' => 'Dockerfile location',
'application.docker_compose_location' => 'Docker compose location',
'application.docker_compose' => 'Docker compose',
'application.docker_compose_raw' => 'Docker compose raw',
'application.custom_labels' => 'Custom labels',
'application.dockerfile_target_build' => 'Dockerfile target build',
'application.custom_docker_run_options' => 'Custom docker run commands',
'application.custom_network_aliases' => 'Custom docker network aliases',
'application.docker_compose_custom_start_command' => 'Docker compose custom start command',
'application.docker_compose_custom_build_command' => 'Docker compose custom build command',
'application.custom_nginx_configuration' => 'Custom Nginx configuration',
'application.settings.is_static' => 'Is static',
'application.settings.is_spa' => 'Is SPA',
'application.settings.is_build_server_enabled' => 'Is build server enabled',
'application.settings.is_container_label_escape_enabled' => 'Is container label escape enabled',
'application.settings.is_container_label_readonly_enabled' => 'Is container label readonly',
'application.settings.is_preserve_repository_enabled' => 'Is preserve repository enabled',
'application.watch_paths' => 'Watch paths',
'application.redirect' => 'Redirect',
'name' => 'name',
'description' => 'description',
'fqdn' => 'FQDN',
'gitRepository' => 'Git repository',
'gitBranch' => 'Git branch',
'gitCommitSha' => 'Git commit SHA',
'installCommand' => 'Install command',
'buildCommand' => 'Build command',
'startCommand' => 'Start command',
'buildPack' => 'Build pack',
'staticImage' => 'Static image',
'baseDirectory' => 'Base directory',
'publishDirectory' => 'Publish directory',
'portsExposes' => 'Ports exposes',
'portsMappings' => 'Ports mappings',
'dockerfile' => 'Dockerfile',
'dockerRegistryImageName' => 'Docker registry image name',
'dockerRegistryImageTag' => 'Docker registry image tag',
'dockerfileLocation' => 'Dockerfile location',
'dockerComposeLocation' => 'Docker compose location',
'dockerCompose' => 'Docker compose',
'dockerComposeRaw' => 'Docker compose raw',
'customLabels' => 'Custom labels',
'dockerfileTargetBuild' => 'Dockerfile target build',
'customDockerRunOptions' => 'Custom docker run commands',
'customNetworkAliases' => 'Custom docker network aliases',
'dockerComposeCustomStartCommand' => 'Docker compose custom start command',
'dockerComposeCustomBuildCommand' => 'Docker compose custom build command',
'customNginxConfiguration' => 'Custom Nginx configuration',
'isStatic' => 'Is static',
'isSpa' => 'Is SPA',
'isBuildServerEnabled' => 'Is build server enabled',
'isContainerLabelEscapeEnabled' => 'Is container label escape enabled',
'isContainerLabelReadonlyEnabled' => 'Is container label readonly',
'isPreserveRepositoryEnabled' => 'Is preserve repository enabled',
'watchPaths' => 'Watch paths',
'redirect' => 'Redirect',
];
public function mount()
@@ -266,14 +307,14 @@ class General extends Component
if (is_null($this->parsedServices) || empty($this->parsedServices)) {
$this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
// Still sync data even if parse fails, so form fields are populated
$this->syncFromModel();
$this->syncData();
return;
}
} catch (\Throwable $e) {
$this->dispatch('error', $e->getMessage());
// Still sync data even on error, so form fields are populated
$this->syncFromModel();
$this->syncData();
}
if ($this->application->build_pack === 'dockercompose') {
// Only update if user has permission
@@ -325,57 +366,114 @@ class General extends Component
// Sync data from model to properties at the END, after all business logic
// This ensures any modifications to $this->application during mount() are reflected in properties
$this->syncFromModel();
$this->syncData();
}
protected function getModelBindings(): array
public function syncData(bool $toModel = false): void
{
return [
'name' => 'application.name',
'description' => 'application.description',
'fqdn' => 'application.fqdn',
'git_repository' => 'application.git_repository',
'git_branch' => 'application.git_branch',
'git_commit_sha' => 'application.git_commit_sha',
'install_command' => 'application.install_command',
'build_command' => 'application.build_command',
'start_command' => 'application.start_command',
'build_pack' => 'application.build_pack',
'static_image' => 'application.static_image',
'base_directory' => 'application.base_directory',
'publish_directory' => 'application.publish_directory',
'ports_exposes' => 'application.ports_exposes',
'ports_mappings' => 'application.ports_mappings',
'custom_network_aliases' => 'application.custom_network_aliases',
'dockerfile' => 'application.dockerfile',
'dockerfile_location' => 'application.dockerfile_location',
'dockerfile_target_build' => 'application.dockerfile_target_build',
'docker_registry_image_name' => 'application.docker_registry_image_name',
'docker_registry_image_tag' => 'application.docker_registry_image_tag',
'docker_compose_location' => 'application.docker_compose_location',
'docker_compose' => 'application.docker_compose',
'docker_compose_raw' => 'application.docker_compose_raw',
'docker_compose_custom_start_command' => 'application.docker_compose_custom_start_command',
'docker_compose_custom_build_command' => 'application.docker_compose_custom_build_command',
'custom_labels' => 'application.custom_labels',
'custom_docker_run_options' => 'application.custom_docker_run_options',
'pre_deployment_command' => 'application.pre_deployment_command',
'pre_deployment_command_container' => 'application.pre_deployment_command_container',
'post_deployment_command' => 'application.post_deployment_command',
'post_deployment_command_container' => 'application.post_deployment_command_container',
'custom_nginx_configuration' => 'application.custom_nginx_configuration',
'is_static' => 'application.settings.is_static',
'is_spa' => 'application.settings.is_spa',
'is_build_server_enabled' => 'application.settings.is_build_server_enabled',
'is_preserve_repository_enabled' => 'application.settings.is_preserve_repository_enabled',
'is_container_label_escape_enabled' => 'application.settings.is_container_label_escape_enabled',
'is_container_label_readonly_enabled' => 'application.settings.is_container_label_readonly_enabled',
'is_http_basic_auth_enabled' => 'application.is_http_basic_auth_enabled',
'http_basic_auth_username' => 'application.http_basic_auth_username',
'http_basic_auth_password' => 'application.http_basic_auth_password',
'watch_paths' => 'application.watch_paths',
'redirect' => 'application.redirect',
];
if ($toModel) {
$this->validate();
// Application properties
$this->application->name = $this->name;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->git_repository = $this->gitRepository;
$this->application->git_branch = $this->gitBranch;
$this->application->git_commit_sha = $this->gitCommitSha;
$this->application->install_command = $this->installCommand;
$this->application->build_command = $this->buildCommand;
$this->application->start_command = $this->startCommand;
$this->application->build_pack = $this->buildPack;
$this->application->static_image = $this->staticImage;
$this->application->base_directory = $this->baseDirectory;
$this->application->publish_directory = $this->publishDirectory;
$this->application->ports_exposes = $this->portsExposes;
$this->application->ports_mappings = $this->portsMappings;
$this->application->custom_network_aliases = $this->customNetworkAliases;
$this->application->dockerfile = $this->dockerfile;
$this->application->dockerfile_location = $this->dockerfileLocation;
$this->application->dockerfile_target_build = $this->dockerfileTargetBuild;
$this->application->docker_registry_image_name = $this->dockerRegistryImageName;
$this->application->docker_registry_image_tag = $this->dockerRegistryImageTag;
$this->application->docker_compose_location = $this->dockerComposeLocation;
$this->application->docker_compose = $this->dockerCompose;
$this->application->docker_compose_raw = $this->dockerComposeRaw;
$this->application->docker_compose_custom_start_command = $this->dockerComposeCustomStartCommand;
$this->application->docker_compose_custom_build_command = $this->dockerComposeCustomBuildCommand;
$this->application->custom_labels = is_null($this->customLabels)
? null
: base64_encode($this->customLabels);
$this->application->custom_docker_run_options = $this->customDockerRunOptions;
$this->application->pre_deployment_command = $this->preDeploymentCommand;
$this->application->pre_deployment_command_container = $this->preDeploymentCommandContainer;
$this->application->post_deployment_command = $this->postDeploymentCommand;
$this->application->post_deployment_command_container = $this->postDeploymentCommandContainer;
$this->application->custom_nginx_configuration = $this->customNginxConfiguration;
$this->application->is_http_basic_auth_enabled = $this->isHttpBasicAuthEnabled;
$this->application->http_basic_auth_username = $this->httpBasicAuthUsername;
$this->application->http_basic_auth_password = $this->httpBasicAuthPassword;
$this->application->watch_paths = $this->watchPaths;
$this->application->redirect = $this->redirect;
// Application settings properties
$this->application->settings->is_static = $this->isStatic;
$this->application->settings->is_spa = $this->isSpa;
$this->application->settings->is_build_server_enabled = $this->isBuildServerEnabled;
$this->application->settings->is_preserve_repository_enabled = $this->isPreserveRepositoryEnabled;
$this->application->settings->is_container_label_escape_enabled = $this->isContainerLabelEscapeEnabled;
$this->application->settings->is_container_label_readonly_enabled = $this->isContainerLabelReadonlyEnabled;
$this->application->settings->save();
} else {
// From model to properties
$this->name = $this->application->name;
$this->description = $this->application->description;
$this->fqdn = $this->application->fqdn;
$this->gitRepository = $this->application->git_repository;
$this->gitBranch = $this->application->git_branch;
$this->gitCommitSha = $this->application->git_commit_sha;
$this->installCommand = $this->application->install_command;
$this->buildCommand = $this->application->build_command;
$this->startCommand = $this->application->start_command;
$this->buildPack = $this->application->build_pack;
$this->staticImage = $this->application->static_image;
$this->baseDirectory = $this->application->base_directory;
$this->publishDirectory = $this->application->publish_directory;
$this->portsExposes = $this->application->ports_exposes;
$this->portsMappings = $this->application->ports_mappings;
$this->customNetworkAliases = $this->application->custom_network_aliases;
$this->dockerfile = $this->application->dockerfile;
$this->dockerfileLocation = $this->application->dockerfile_location;
$this->dockerfileTargetBuild = $this->application->dockerfile_target_build;
$this->dockerRegistryImageName = $this->application->docker_registry_image_name;
$this->dockerRegistryImageTag = $this->application->docker_registry_image_tag;
$this->dockerComposeLocation = $this->application->docker_compose_location;
$this->dockerCompose = $this->application->docker_compose;
$this->dockerComposeRaw = $this->application->docker_compose_raw;
$this->dockerComposeCustomStartCommand = $this->application->docker_compose_custom_start_command;
$this->dockerComposeCustomBuildCommand = $this->application->docker_compose_custom_build_command;
$this->customLabels = $this->application->parseContainerLabels();
$this->customDockerRunOptions = $this->application->custom_docker_run_options;
$this->preDeploymentCommand = $this->application->pre_deployment_command;
$this->preDeploymentCommandContainer = $this->application->pre_deployment_command_container;
$this->postDeploymentCommand = $this->application->post_deployment_command;
$this->postDeploymentCommandContainer = $this->application->post_deployment_command_container;
$this->customNginxConfiguration = $this->application->custom_nginx_configuration;
$this->isHttpBasicAuthEnabled = $this->application->is_http_basic_auth_enabled;
$this->httpBasicAuthUsername = $this->application->http_basic_auth_username;
$this->httpBasicAuthPassword = $this->application->http_basic_auth_password;
$this->watchPaths = $this->application->watch_paths;
$this->redirect = $this->application->redirect;
// Application settings properties
$this->isStatic = $this->application->settings->is_static;
$this->isSpa = $this->application->settings->is_spa;
$this->isBuildServerEnabled = $this->application->settings->is_build_server_enabled;
$this->isPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
$this->isContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
$this->isContainerLabelReadonlyEnabled = $this->application->settings->is_container_label_readonly_enabled;
}
}
public function instantSave()
@@ -386,33 +484,36 @@ class General extends Component
$oldPortsExposes = $this->application->ports_exposes;
$oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
$oldIsPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
$oldIsSpa = $this->application->settings->is_spa;
$oldIsHttpBasicAuthEnabled = $this->application->is_http_basic_auth_enabled;
$this->syncToModel();
$this->syncData(toModel: true);
if ($this->application->settings->isDirty('is_spa')) {
$this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static');
if ($oldIsSpa !== $this->isSpa) {
$this->generateNginxConfiguration($this->isSpa ? 'spa' : 'static');
}
if ($this->application->isDirty('is_http_basic_auth_enabled')) {
if ($oldIsHttpBasicAuthEnabled !== $this->isHttpBasicAuthEnabled) {
$this->application->save();
}
$this->application->settings->save();
$this->dispatch('success', 'Settings saved.');
$this->application->refresh();
$this->syncFromModel();
$this->syncData();
// If port_exposes changed, reset default labels
if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) {
if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) {
$this->resetDefaultLabels(false);
}
if ($oldIsPreserveRepositoryEnabled !== $this->is_preserve_repository_enabled) {
if ($this->is_preserve_repository_enabled === false) {
if ($oldIsPreserveRepositoryEnabled !== $this->isPreserveRepositoryEnabled) {
if ($this->isPreserveRepositoryEnabled === false) {
$this->application->fileStorages->each(function ($storage) {
$storage->is_based_on_git = $this->is_preserve_repository_enabled;
$storage->is_based_on_git = $this->isPreserveRepositoryEnabled;
$storage->save();
});
}
}
if ($this->is_container_label_readonly_enabled) {
if ($this->isContainerLabelReadonlyEnabled) {
$this->resetDefaultLabels(false);
}
} catch (\Throwable $e) {
@@ -438,6 +539,11 @@ class General extends Component
// Refresh parsedServiceDomains to reflect any changes in docker_compose_domains
$this->application->refresh();
// Sync the docker_compose_raw from the model to the component property
// This ensures the Monaco editor displays the loaded compose file
$this->syncData();
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots and dashes to use underscores for HTML form binding
$sanitizedDomains = [];
@@ -502,7 +608,7 @@ class General extends Component
public function updatedBaseDirectory()
{
if ($this->build_pack === 'dockercompose') {
if ($this->buildPack === 'dockercompose') {
$this->loadComposeFile();
}
}
@@ -522,24 +628,22 @@ class General extends Component
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User doesn't have permission, revert the change and return
$this->application->refresh();
$this->syncFromModel();
$this->syncData();
return;
}
// Sync property to model before checking/modifying
$this->syncToModel();
$this->syncData(toModel: true);
if ($this->build_pack !== 'nixpacks') {
$this->is_static = false;
if ($this->buildPack !== 'nixpacks') {
$this->isStatic = false;
$this->application->settings->is_static = false;
$this->application->settings->save();
} else {
$this->ports_exposes = 3000;
$this->application->ports_exposes = 3000;
$this->resetDefaultLabels(false);
}
if ($this->build_pack === 'dockercompose') {
if ($this->buildPack === 'dockercompose') {
// Only update if user has permission
try {
$this->authorize('update', $this->application);
@@ -549,22 +653,10 @@ class General extends Component
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User doesn't have update permission, just continue without saving
}
} else {
// Clear Docker Compose specific data when switching away from dockercompose
if ($this->application->getOriginal('build_pack') === 'dockercompose') {
$this->application->docker_compose_domains = null;
$this->application->docker_compose_raw = null;
// Remove SERVICE_FQDN_* and SERVICE_URL_* environment variables
$this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete();
$this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete();
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
}
}
if ($this->build_pack === 'static') {
$this->ports_exposes = 80;
$this->application->ports_exposes = 80;
if ($this->buildPack === 'static') {
$this->portsExposes = '80';
$this->application->ports_exposes = '80';
$this->resetDefaultLabels(false);
$this->generateNginxConfiguration();
}
@@ -581,10 +673,10 @@ class General extends Component
if ($server) {
$fqdn = generateUrl(server: $server, random: $this->application->uuid);
$this->fqdn = $fqdn;
$this->syncToModel();
$this->syncData(toModel: true);
$this->application->save();
$this->application->refresh();
$this->syncFromModel();
$this->syncData();
$this->resetDefaultLabels();
$this->dispatch('success', 'Wildcard domain generated.');
}
@@ -598,11 +690,11 @@ class General extends Component
try {
$this->authorize('update', $this->application);
$this->custom_nginx_configuration = defaultNginxConfiguration($type);
$this->syncToModel();
$this->customNginxConfiguration = defaultNginxConfiguration($type);
$this->syncData(toModel: true);
$this->application->save();
$this->application->refresh();
$this->syncFromModel();
$this->syncData();
$this->dispatch('success', 'Nginx configuration generated.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -612,16 +704,15 @@ class General extends Component
public function resetDefaultLabels($manualReset = false)
{
try {
if (! $this->is_container_label_readonly_enabled && ! $manualReset) {
if (! $this->isContainerLabelReadonlyEnabled && ! $manualReset) {
return;
}
$this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
$this->custom_labels = base64_encode($this->customLabels);
$this->syncToModel();
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
$this->application->refresh();
$this->syncFromModel();
if ($this->build_pack === 'dockercompose') {
$this->syncData();
if ($this->buildPack === 'dockercompose') {
$this->loadComposeFile(showToast: false);
}
$this->dispatch('configurationChanged');
@@ -717,7 +808,7 @@ class General extends Component
$this->dispatch('warning', __('warning.sslipdomain'));
}
$this->syncToModel();
$this->syncData(toModel: true);
if ($this->application->isDirty('redirect')) {
$this->setRedirect();
@@ -737,42 +828,42 @@ class General extends Component
$this->application->save();
}
if ($this->build_pack === 'dockercompose' && $oldDockerComposeLocation !== $this->docker_compose_location) {
if ($this->buildPack === 'dockercompose' && $oldDockerComposeLocation !== $this->dockerComposeLocation) {
$compose_return = $this->loadComposeFile(showToast: false);
if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
return;
}
}
if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) {
if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) {
$this->resetDefaultLabels();
}
if ($this->build_pack === 'dockerimage') {
if ($this->buildPack === 'dockerimage') {
$this->validate([
'docker_registry_image_name' => 'required',
'dockerRegistryImageName' => 'required',
]);
}
if ($this->custom_docker_run_options) {
$this->custom_docker_run_options = str($this->custom_docker_run_options)->trim()->toString();
$this->application->custom_docker_run_options = $this->custom_docker_run_options;
if ($this->customDockerRunOptions) {
$this->customDockerRunOptions = str($this->customDockerRunOptions)->trim()->toString();
$this->application->custom_docker_run_options = $this->customDockerRunOptions;
}
if ($this->dockerfile) {
$port = get_port_from_dockerfile($this->dockerfile);
if ($port && ! $this->ports_exposes) {
$this->ports_exposes = $port;
if ($port && ! $this->portsExposes) {
$this->portsExposes = $port;
$this->application->ports_exposes = $port;
}
}
if ($this->base_directory && $this->base_directory !== '/') {
$this->base_directory = rtrim($this->base_directory, '/');
$this->application->base_directory = $this->base_directory;
if ($this->baseDirectory && $this->baseDirectory !== '/') {
$this->baseDirectory = rtrim($this->baseDirectory, '/');
$this->application->base_directory = $this->baseDirectory;
}
if ($this->publish_directory && $this->publish_directory !== '/') {
$this->publish_directory = rtrim($this->publish_directory, '/');
$this->application->publish_directory = $this->publish_directory;
if ($this->publishDirectory && $this->publishDirectory !== '/') {
$this->publishDirectory = rtrim($this->publishDirectory, '/');
$this->application->publish_directory = $this->publishDirectory;
}
if ($this->build_pack === 'dockercompose') {
if ($this->buildPack === 'dockercompose') {
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) {
@@ -804,11 +895,11 @@ class General extends Component
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
$this->application->refresh();
$this->syncFromModel();
$this->syncData();
$showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!');
} catch (\Throwable $e) {
$this->application->refresh();
$this->syncFromModel();
$this->syncData();
return handleError($e, $this);
} finally {
@@ -895,4 +986,23 @@ class General extends Component
}
}
}
public function getDetectedPortInfoProperty(): ?array
{
$detectedPort = $this->application->detectPortFromEnvironment();
if (! $detectedPort) {
return null;
}
$portsExposesArray = $this->application->ports_exposes_array;
$isMatch = in_array($detectedPort, $portsExposesArray);
$isEmpty = empty($portsExposesArray);
return [
'port' => $detectedPort,
'matches' => $isMatch,
'isEmpty' => $isEmpty,
];
}
}
+16 -1
View File
@@ -101,11 +101,18 @@ class Heading extends Component
force_rebuild: $force_rebuild,
);
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
$this->dispatch('error', 'Deployment skipped', $result['message']);
return;
}
// Reset restart count on successful deployment
$this->application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
@@ -137,6 +144,7 @@ class Heading extends Component
return;
}
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
@@ -149,6 +157,13 @@ class Heading extends Component
return;
}
// Reset restart count on manual restart
$this->application->update([
'restart_count' => 0,
'last_restart_at' => now(),
'last_restart_type' => 'manual',
]);
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
+1 -1
View File
@@ -79,7 +79,7 @@ class BackupEdit extends Component
#[Validate(['required', 'boolean'])]
public bool $dumpAll = false;
#[Validate(['required', 'int', 'min:1', 'max:36000'])]
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
public int $timeout = 3600;
public function mount()
+1 -21
View File
@@ -18,20 +18,7 @@ class Index extends Component
public function mount()
{
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
$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;
});
$this->projects = Project::ownedByCurrentTeam()->get();
$this->servers = Server::ownedByCurrentTeam()->count();
}
@@ -39,11 +26,4 @@ class Index extends Component
{
return view('livewire.project.index');
}
public function navigateToProject($projectUuid)
{
$project = collect($this->projects)->firstWhere('uuid', $projectUuid);
return $this->redirect($project->navigateTo(), navigate: false);
}
}
+8 -8
View File
@@ -177,14 +177,14 @@ class Select extends Component
'id' => 'postgresql',
'name' => 'PostgreSQL',
'description' => 'PostgreSQL is an object-relational database known for its robustness, advanced features, and strong standards compliance.',
'logo' => '<svg class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:fill-white fill-black bg-black/10 dark:bg-white/10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#currentColor" d="M85.988 76.075c.632-5.262.443-6.034 4.362-5.182l.995.088c3.014.137 6.957-.485 9.272-1.561 4.986-2.313 7.942-6.177 3.026-5.162-11.215 2.313-11.986-1.483-11.986-1.483C103.5 45.204 108.451 22.9 104.178 17.44 92.524 2.548 72.35 9.59 72.012 9.773l-.108.021c-2.216-.461-4.695-.735-7.481-.78-5.075-.083-8.926 1.331-11.847 3.546 0 0-35.989-14.827-34.315 18.646.356 7.121 10.207 53.882 21.956 39.758 4.294-5.164 8.444-9.531 8.444-9.531 2.061 1.369 4.528 2.067 7.116 1.816l.2-.17c-.062.641-.035 1.268.081 2.01-3.027 3.383-2.137 3.977-8.189 5.222-6.122 1.262-2.525 3.508-.178 4.095 2.848.713 9.433 1.722 13.884-4.509l-.177.711c1.188.95 1.107 6.827 1.275 11.026.168 4.199.45 8.117 1.306 10.429.856 2.31 1.866 8.261 9.819 6.557 6.646-1.426 11.727-3.476 12.19-22.545"/><path fill="#currentColor" d="M71.208 102.77c-3.518 0-5.808-1.36-7.2-2.674-2.1-1.981-2.933-4.534-3.43-6.059l-.215-.637c-1.002-2.705-1.341-6.599-1.542-11.613a199.25 199.25 0 01-.075-2.352c-.017-.601-.038-1.355-.068-2.146a15.157 15.157 0 01-3.997 1.264c-2.48.424-5.146.286-7.926-.409-1.961-.49-3.999-1.506-5.16-3.076-3.385 2.965-6.614 2.562-8.373 1.976-3.103-1.035-5.88-3.942-8.491-8.89-1.859-3.523-3.658-8.115-5.347-13.646-2.94-9.633-4.808-19.779-4.974-23.109-.522-10.427 2.284-17.883 8.34-22.16 9.555-6.749 24.03-2.781 29.307-.979 3.545-2.137 7.716-3.178 12.43-3.102 2.532.041 4.942.264 7.181.662 2.335-.734 6.949-1.788 12.23-1.723 9.73.116 17.793 3.908 23.316 10.966 3.941 5.036 1.993 15.61.48 21.466-2.127 8.235-5.856 16.996-10.436 24.622 1.244.009 3.045-.141 5.607-.669 5.054-1.044 6.531 1.666 6.932 2.879 1.607 4.867-5.378 8.544-7.557 9.555-2.792 1.297-7.343 2.086-11.071 1.915l-.163-.011-.979-.086-.097.816-.093.799c-.25 9.664-1.631 15.784-4.472 19.829-2.977 4.239-7.116 5.428-10.761 6.209a16.146 16.146 0 01-3.396.383zm-7.402-35.174c2.271 1.817 2.47 5.236 2.647 11.626.022.797.043 1.552.071 2.257.086 2.134.287 7.132 1.069 9.244.111.298.21.602.314.922.872 2.672 1.31 4.011 5.081 3.203 3.167-.678 4.794-1.287 6.068-3.101 1.852-2.638 2.888-7.941 3.078-15.767l3.852.094-3.826-.459.112-.955c.367-3.148.631-5.424 2.736-6.928 1.688-1.207 3.613-1.09 5.146-.814-1.684-1.271-2.15-2.765-2.274-3.377l-.321-1.582.902-1.34c5.2-7.716 9.489-17.199 11.767-26.018 2.34-9.062 1.626-13.875.913-14.785-9.446-12.071-25.829-7.088-27.539-6.521l-.29.156-1.45.271-.743-.154c-2.047-.425-4.321-.66-6.76-.7-3.831-.064-6.921.841-9.455 2.764l-1.758 1.333-2.041-.841c-4.358-1.782-17.162-5.365-23.918-.58-3.75 2.656-5.458 7.861-5.078 15.47.125 2.512 1.833 12.021 4.647 21.245 3.891 12.746 7.427 16.979 8.903 17.472.257.087.926-.433 1.591-1.231 4.326-5.203 8.44-9.54 8.613-9.723l2.231-2.347 2.697 1.792c1.087.723 2.286 1.132 3.518 1.209l6.433-5.486-.932 9.51c-.021.214-.031.504.053 1.044l.28 1.803-1.213 1.358-.14.157 3.534 1.632 1.482-1.853z"/><path fill="#336791" d="M103.646 64.258c-11.216 2.313-11.987-1.484-11.987-1.484 11.842-17.571 16.792-39.876 12.52-45.335C92.524 2.547 72.35 9.59 72.013 9.773l-.109.019c-2.216-.459-4.695-.733-7.482-.778-5.075-.083-8.925 1.33-11.846 3.545 0 0-35.99-14.826-34.316 18.647.356 7.121 10.207 53.882 21.956 39.758 4.294-5.164 8.443-9.531 8.443-9.531 2.061 1.369 4.528 2.067 7.115 1.816l.201-.17c-.062.641-.034 1.268.08 2.01-3.026 3.383-2.138 3.977-8.188 5.222-6.123 1.262-2.526 3.508-.177 4.095 2.847.713 9.433 1.722 13.883-4.509l-.178.711c1.186.95 2.019 6.179 1.879 10.919s-.233 7.994.702 10.536c.935 2.541 1.866 8.261 9.82 6.557 6.646-1.425 10.09-5.116 10.57-11.272.34-4.377 1.109-3.73 1.158-7.644l.618-1.853c.711-5.934.113-7.848 4.208-6.957l.995.087c3.014.138 6.958-.485 9.273-1.561 4.986-2.314 7.943-6.177 3.028-5.162z"/><path fill="#fff" d="M71.61 100.394c-6.631.001-8.731-5.25-9.591-7.397-1.257-3.146-1.529-15.358-1.249-25.373a1.286 1.286 0 012.57.072c-.323 11.551.136 22.018 1.066 24.346 1.453 3.632 3.656 6.809 9.887 5.475 5.915-1.269 8.13-3.512 9.116-9.23.758-4.389 2.254-16.874 2.438-19.338a1.285 1.285 0 012.563.191c-.192 2.564-1.682 15.026-2.469 19.584-1.165 6.755-4.176 9.819-11.11 11.306a15.462 15.462 0 01-3.221.364zM35.659 74.749a5.343 5.343 0 01-1.704-.281c-4.307-1.437-8.409-8.451-12.193-20.849-2.88-9.438-4.705-19.288-4.865-22.489-.475-9.49 1.97-16.205 7.265-19.957 10.476-7.423 28.1-.354 28.845-.05a1.285 1.285 0 01-.972 2.379v.001c-.17-.07-17.07-6.84-26.392-.229-4.528 3.211-6.607 9.175-6.18 17.729.135 2.696 1.84 12.311 4.757 21.867 3.378 11.067 7.223 18.052 10.548 19.16.521.175 2.109.704 4.381-2.026 4.272-5.14 8.197-9.242 8.236-9.283a1.286 1.286 0 011.856 1.778c-.039.04-3.904 4.081-8.116 9.148-1.995 2.398-3.908 3.102-5.466 3.102zm55.92-10.829a1.284 1.284 0 01-1.065-2.004c11.971-17.764 16.173-39.227 12.574-43.825-4.53-5.788-10.927-8.812-19.012-8.985-5.987-.13-10.746 1.399-11.523 Line truncated
'logo' => '<svg class="w-[4.5rem] h-[4.5rem] p-2 transition-all duration-200 bg-black/10 dark:bg-white/10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#currentColor" d="M85.988 76.075c.632-5.262.443-6.034 4.362-5.182l.995.088c3.014.137 6.957-.485 9.272-1.561 4.986-2.313 7.942-6.177 3.026-5.162-11.215 2.313-11.986-1.483-11.986-1.483C103.5 45.204 108.451 22.9 104.178 17.44 92.524 2.548 72.35 9.59 72.012 9.773l-.108.021c-2.216-.461-4.695-.735-7.481-.78-5.075-.083-8.926 1.331-11.847 3.546 0 0-35.989-14.827-34.315 18.646.356 7.121 10.207 53.882 21.956 39.758 4.294-5.164 8.444-9.531 8.444-9.531 2.061 1.369 4.528 2.067 7.116 1.816l.2-.17c-.062.641-.035 1.268.081 2.01-3.027 3.383-2.137 3.977-8.189 5.222-6.122 1.262-2.525 3.508-.178 4.095 2.848.713 9.433 1.722 13.884-4.509l-.177.711c1.188.95 1.107 6.827 1.275 11.026.168 4.199.45 8.117 1.306 10.429.856 2.31 1.866 8.261 9.819 6.557 6.646-1.426 11.727-3.476 12.19-22.545"/><path fill="#currentColor" d="M71.208 102.77c-3.518 0-5.808-1.36-7.2-2.674-2.1-1.981-2.933-4.534-3.43-6.059l-.215-.637c-1.002-2.705-1.341-6.599-1.542-11.613a199.25 199.25 0 01-.075-2.352c-.017-.601-.038-1.355-.068-2.146a15.157 15.157 0 01-3.997 1.264c-2.48.424-5.146.286-7.926-.409-1.961-.49-3.999-1.506-5.16-3.076-3.385 2.965-6.614 2.562-8.373 1.976-3.103-1.035-5.88-3.942-8.491-8.89-1.859-3.523-3.658-8.115-5.347-13.646-2.94-9.633-4.808-19.779-4.974-23.109-.522-10.427 2.284-17.883 8.34-22.16 9.555-6.749 24.03-2.781 29.307-.979 3.545-2.137 7.716-3.178 12.43-3.102 2.532.041 4.942.264 7.181.662 2.335-.734 6.949-1.788 12.23-1.723 9.73.116 17.793 3.908 23.316 10.966 3.941 5.036 1.993 15.61.48 21.466-2.127 8.235-5.856 16.996-10.436 24.622 1.244.009 3.045-.141 5.607-.669 5.054-1.044 6.531 1.666 6.932 2.879 1.607 4.867-5.378 8.544-7.557 9.555-2.792 1.297-7.343 2.086-11.071 1.915l-.163-.011-.979-.086-.097.816-.093.799c-.25 9.664-1.631 15.784-4.472 19.829-2.977 4.239-7.116 5.428-10.761 6.209a16.146 16.146 0 01-3.396.383zm-7.402-35.174c2.271 1.817 2.47 5.236 2.647 11.626.022.797.043 1.552.071 2.257.086 2.134.287 7.132 1.069 9.244.111.298.21.602.314.922.872 2.672 1.31 4.011 5.081 3.203 3.167-.678 4.794-1.287 6.068-3.101 1.852-2.638 2.888-7.941 3.078-15.767l3.852.094-3.826-.459.112-.955c.367-3.148.631-5.424 2.736-6.928 1.688-1.207 3.613-1.09 5.146-.814-1.684-1.271-2.15-2.765-2.274-3.377l-.321-1.582.902-1.34c5.2-7.716 9.489-17.199 11.767-26.018 2.34-9.062 1.626-13.875.913-14.785-9.446-12.071-25.829-7.088-27.539-6.521l-.29.156-1.45.271-.743-.154c-2.047-.425-4.321-.66-6.76-.7-3.831-.064-6.921.841-9.455 2.764l-1.758 1.333-2.041-.841c-4.358-1.782-17.162-5.365-23.918-.58-3.75 2.656-5.458 7.861-5.078 15.47.125 2.512 1.833 12.021 4.647 21.245 3.891 12.746 7.427 16.979 8.903 17.472.257.087.926-.433 1.591-1.231 4.326-5.203 8.44-9.54 8.613-9.723l2.231-2.347 2.697 1.792c1.087.723 2.286 1.132 3.518 1.209l6.433-5.486-.932 9.51c-.021.214-.031.504.053 1.044l.28 1.803-1.213 1.358-.14.157 3.534 1.632 1.482-1.853z"/><path fill="#336791" d="M103.646 64.258c-11.216 2.313-11.987-1.484-11.987-1.484 11.842-17.571 16.792-39.876 12.52-45.335C92.524 2.547 72.35 9.59 72.013 9.773l-.109.019c-2.216-.459-4.695-.733-7.482-.778-5.075-.083-8.925 1.33-11.846 3.545 0 0-35.99-14.826-34.316 18.647.356 7.121 10.207 53.882 21.956 39.758 4.294-5.164 8.443-9.531 8.443-9.531 2.061 1.369 4.528 2.067 7.115 1.816l.201-.17c-.062.641-.034 1.268.08 2.01-3.026 3.383-2.138 3.977-8.188 5.222-6.123 1.262-2.526 3.508-.177 4.095 2.847.713 9.433 1.722 13.883-4.509l-.178.711c1.186.95 2.019 6.179 1.879 10.919s-.233 7.994.702 10.536c.935 2.541 1.866 8.261 9.82 6.557 6.646-1.425 10.09-5.116 10.57-11.272.34-4.377 1.109-3.73 1.158-7.644l.618-1.853c.711-5.934.113-7.848 4.208-6.957l.995.087c3.014.138 6.958-.485 9.273-1.561 4.986-2.314 7.943-6.177 3.028-5.162z"/><path fill="#fff" d="M71.61 100.394c-6.631.001-8.731-5.25-9.591-7.397-1.257-3.146-1.529-15.358-1.249-25.373a1.286 1.286 0 012.57.072c-.323 11.551.136 22.018 1.066 24.346 1.453 3.632 3.656 6.809 9.887 5.475 5.915-1.269 8.13-3.512 9.116-9.23.758-4.389 2.254-16.874 2.438-19.338a1.285 1.285 0 012.563.191c-.192 2.564-1.682 15.026-2.469 19.584-1.165 6.755-4.176 9.819-11.11 11.306a15.462 15.462 0 01-3.221.364zM35.659 74.749a5.343 5.343 0 01-1.704-.281c-4.307-1.437-8.409-8.451-12.193-20.849-2.88-9.438-4.705-19.288-4.865-22.489-.475-9.49 1.97-16.205 7.265-19.957 10.476-7.423 28.1-.354 28.845-.05a1.285 1.285 0 01-.972 2.379v.001c-.17-.07-17.07-6.84-26.392-.229-4.528 3.211-6.607 9.175-6.18 17.729.135 2.696 1.84 12.311 4.757 21.867 3.378 11.067 7.223 18.052 10.548 19.16.521.175 2.109.704 4.381-2.026 4.272-5.14 8.197-9.242 8.236-9.283a1.286 1.286 0 011.856 1.778c-.039.04-3.904 4.081-8.116 9.148-1.995 2.398-3.908 3.102-5.466 3.102zm55.92-10.829a1.284 1.284 0 01-1.065-2.004c11.971-17.764 16.173-39.227 12.574-43.825-4.53-5.788-10.927-8.812-19.012-8.985-5.987-.13-10.746 1.399-11.523 1.666l-.195.079c-.782.246-1.382-.183-1.608-.684a1.29 1.29 0 01.508-1.631l.346-.142-.017.005.018-.006c1.321-.483 6.1 Line truncated
',
],
[
'id' => 'mysql',
'name' => 'MySQL',
'description' => 'MySQL is an open-source relational database management system. ',
'logo' => '<svg class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:fill-white fill-black bg-black/10 dark:bg-white/10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
'logo' => '<svg class="w-[4.5rem] h-[4.5rem] p-2 transition-all duration-200 bg-black/10 dark:bg-white/10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<path fill="currentColor" d="M0 91.313h4.242V74.566l6.566 14.598c.773 1.77 1.832 2.391 3.914 2.391s3.098-.621 3.871-2.391l6.566-14.598v16.746h4.242V74.594c0-1.633-.652-2.422-2-2.828-3.223-1.004-5.383-.137-6.363 2.039l-6.441 14.41-6.238-14.41c-.937-2.176-3.14-3.043-6.359-2.039-1.348.406-2 1.195-2 2.828zM32.93 77.68h4.238v9.227c-.039.5.16 1.676 2.484 1.715h9.223V77.633h4.25c.02 0-.008 14.984-.008 15.047.023 3.695-4.582 4.496-6.707 4.559H33.02v-2.852l13.414-.004c2.73-.285 2.406-1.645 2.406-2.098v-1.113h-9.012c-4.195-.039-6.863-1.871-6.898-3.977-.004-.191.09-9.422 0-9.516zm0 0" />
<path fill="currentColor" d="M56.391 91.313h12.195c1.426 0 2.813-.301 3.914-.816 1.836-.84 2.73-1.984 2.73-3.48v-3.098c0-1.223-1.016-2.367-3.016-3.125-1.059-.41-2.367-.625-3.629-.625h-5.141c-1.711 0-2.527-.516-2.73-1.656-.039-.137-.039-.246-.039-.383V76.2c0-.109 0-.219.039-.355.203-.867.652-1.113 2.16-1.25l.41-.027h12.109v-2.824H63.488c-1.711 0-2.609.109-3.426.352-2.527.789-3.629 2.039-3.629 4.215v2.473c0 1.902 2.16 3.535 5.789 3.914l1.223.055h4.406c.164 0 .324 0 .449.027 1.344.109 1.914.355 2.324.844.211.195.332.473.324.758v2.477c0 .297-.203.68-.609 1.004-.367.328-.98.543-1.793.598l-.449.027H56.391zm45.297-4.922c0 2.91 2.164 4.539 6.523 4.867l1.227.055h11.051v-2.828h-11.133c-2.488 0-3.426-.625-3.426-2.121V71.738h-4.238V86.39zm-23.75.148V76.457c0-2.559 1.801-4.113 5.355-4.602a7.976 7.976 0 0 1 1.145-.082h8.047c.41 0 .777.027 1.188.082 3.555.488 5.352 2.043 5.352 4.602v10.082c0 2.078-.762 3.188-2.523 3.914l4.18 3.77h-4.926l-3.379-3.051-3.402.215H84.44a9.23 9.23 0 0 1-2.492-.352c-2.699-.734-4.008-2.152-4.008-4.496zm4.578-.246c0 .137.039.273.082.438.246 1.172 1.348 1.824 3.023 1.824h3.852l-3.539-3.195h4.926l3.086 2.789c.57-.305.941-.766 1.074-1.363.039-.137.039-.273.039-.41v-9.668c0-.109 0-.246-.039-.383-.246-1.09-1.348-1.715-2.984-1.715h-6.414c-1.879 0-3.105.816-3.105 2.098zm0 0" />
<path fill="#00618A" d="M124.219 67.047c-2.605-.07-4.598.172-6.301.891-.484.203-1.258.207-1.336.813.266.281.309.699.52 1.039.406.66 1.094 1.539 1.707 2l2.074 1.484c1.273.777 2.699 1.223 3.93 2 .723.461 1.441 1.039 2.148 1.559.348.254.582.656 1.039.816v-.074c-.238-.305-.301-.723-.52-1.039l-.965-.965c-.941-1.25-2.137-2.348-3.41-3.262-1.016-.727-3.281-1.711-3.707-2.891l-.074-.074c.719-.078 1.563-.34 2.223-.516 1.117-.301 2.113-.223 3.262-.52l1.559-.449v-.293c-.582-.598-.996-1.387-1.633-1.93-1.656-1.41-3.469-2.824-5.336-4.004-1.035-.652-2.312-1.074-3.41-1.629-.367-.187-1.016-.281-1.262-.594-.574-.734-.887-1.664-1.332-2.52l-2.668-5.633c-.562-1.285-.93-2.555-1.633-3.707-3.363-5.535-6.988-8.875-12.602-12.156-1.191-.699-2.633-.973-4.148-1.332l-2.449-.148c-.496-.211-1.012-.82-1.48-1.113-1.859-1.176-6.629-3.73-8.008-.371-.867 2.121 1.301 4.191 2.078 5.266.543.754 1.242 1.598 1.629 2.445.258.555.301 1.113.52 1.703.539 1.453 1.008 3.031 1.707 4.375.352.68.738 1.395 1.184 2 .273.371.742.539.816 1.113-.457.641-.484 1.633-.742 2.445-1.16 3.652-.723 8.191.965 10.898.516.828 1.734 2.609 3.41 1.926 1.465-.598 1.137-2.445 1.555-4.078.098-.367.039-.641.223-.887v.074l1.336 2.668c.988 1.59 2.738 3.25 4.223 4.371.773.582 1.379 1.59 2.375 1.93V68.6h-.074c-.195-.297-.496-.422-.742-.664-.582-.57-1.227-1.277-1.703-1.93-1.352-1.832-2.547-3.84-3.633-5.93-.52-.996-.973-2.098-1.41-3.113-.168-.391-.164-.984-.516-1.184-.48.742-1.187 1.344-1.559 2.223-.594 1.402-.668 3.117-.891 4.891l-.148.074c-1.031-.25-1.395-1.312-1.777-2.223-.973-2.305-1.152-6.02-.297-8.672.219-.687 1.219-2.852.813-3.484-.191-.633-.828-1-1.184-1.484a11.7 11.7 0 0 1-1.187-2.074c-.793-1.801-1.164-3.816-2-5.633-.398-.871-1.074-1.75-1.629-2.523-.617-.855-1.305-1.484-1.781-2.52-.168-.367-.398-.957-.148-1.336.078-.254.195-.359.445-.441.43-.332 1.629.109 2.074.293 1.191.496 2.184.965 3.191 1.633.48.32.969.941 1.555 1.113h.668c1.043.238 2.211.07 3.188.367 1.723.523 3.27 1.34 4.668 2.227 4.273 2.695 7.766 6.535 10.156 11.117.387.738.551 1.441.891 2.223.684 1.578 1.543 3.203 2.223 4.746s1.34 3.094 2.297 4.375c.504.672 2.453 1.031 3.336 1.406.621.262 1.637.535 2.223.891 1.125.676 2.211 1.48 3.266 2.223.523.375 2.141 1.188 2.223 1.855zM91.082 38.805a5.26 5.26 0 0 0-1.332.148v.074h.074c.258.535.715.879 1.035 1.336l.742 1.555.074-.07c.461-.324.668-.844.668-1.633-.187-.195-.211-.437-.371-.668-.211-.309-.621-.48-.891-.742zm0 0" />
@@ -195,37 +195,37 @@ class Select extends Component
'id' => 'mariadb',
'name' => 'MariaDB',
'description' => 'MariaDB is a community-developed, commercially supported fork of the MySQL relational database management system, intended to remain free and open-source.',
'logo' => '<svg class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:fill-white fill-black bg-black/10 dark:bg-white/10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="currentColor" d="M127.434 12.182a1.727 1.727 0 0 0-1.174-.392c-1.168 0-2.68.793-3.495 1.218l-.322.165a11.095 11.095 0 0 1-4.365 1.1c-1.554.049-2.892.14-4.635.322-10.327 1.06-14.933 8.975-19.37 16.63-2.416 4.164-4.91 8.489-8.33 11.793a22.472 22.472 0 0 1-2.252 1.913c-3.54 2.631-7.985 4.51-11.443 5.84-3.329 1.273-6.964 2.417-10.474 3.524-3.219 1.012-6.255 1.97-9.048 3.008a96.902 96.902 0 0 1-3.275 1.14c-2.545.825-4.378 1.458-7.06 3.304a45.386 45.386 0 0 0-2.804 2.066 29.585 29.585 0 0 0-5.597 5.894 34.802 34.802 0 0 1-4.701 5.642c-.566.554-1.57.826-3.074.826-1.76 0-3.895-.363-6.154-.747-2.33-.413-4.738-.805-6.803-.805-1.677 0-2.962.273-3.92.826 0 0-1.617.942-2.298 2.16l.67.302a13.718 13.718 0 0 1 2.859 2.065 14.342 14.342 0 0 0 2.973 2.115 2.553 2.553 0 0 1 .918.582c-.281.413-.694.946-1.129 1.516-2.384 3.119-3.774 5.09-2.977 6.163a2.507 2.507 0 0 0 1.239.28c5.196 0 7.989-1.35 11.52-3.06 1.024-.495 2.066-1.004 3.305-1.528 2.065-.896 4.288-2.325 6.647-3.838 3.084-2.01 6.31-4.076 9.442-5.072a25.734 25.734 0 0 1 7.943-1.115c3.305 0 6.783.441 10.138.872 2.499.322 5.089.652 7.63.805.986.057 1.9.086 2.787.086a32.307 32.307 0 0 0 3.557-.185l.284-.1c1.781-1.094 2.617-3.444 3.425-5.717.52-1.462.96-2.775 1.652-3.61a1.054 1.054 0 0 1 .132-.11.166.166 0 0 1 .202.032v.066c-.412 8.885-3.99 14.527-7.608 19.543l-2.416 2.59s3.383 0 5.307-.744c7.024-2.099 12.324-6.725 16.181-14.103a60.185 60.185 0 0 0 2.549-5.82c.065-.165.673-.47.616.384-.021.252-.038.533-.059.827 0 .173 0 .35-.033.528-.1 1.24-.392 3.859-.392 3.859l2.169-1.162c5.229-3.304 9.26-9.97 12.318-20.343 1.272-4.321 2.205-8.613 3.027-12.392.983-4.545 1.83-8.44 2.801-9.952 1.524-2.37 3.85-3.973 6.101-5.53.306-.211.616-.414.917-.637 2.83-1.986 5.643-4.279 6.263-8.555v-.095c.45-3.189.07-4.002-.364-4.373zm-7.283 103.727h-10.327V97.92h9.315c3.56 0 6.952.67 6.902 4.66 0 2.813-1.747 3.59-3.589 3.886 2.615.224 4.188 1.892 4.188 4.586.017 4.035-3.523 4.858-6.489 4.858zm-.772-10.14c3.565 0 4.362-1.372 4.362-3.115 0-2.619-1.595-3.214-4.362-3.214h-7.402v6.328zm.099 1.52h-7.501v7.1h7.823c2.194 0 4.511-.723 4.511-3.486 0-3.19-2.665-3.615-4.833-3.615zm-31.497-9.37h8.125c6.828 0 10.24 3.764 10.19 8.994.05 5.436-3.716 8.997-9.591 8.997H87.98zm2.242 1.596v14.825h6.197c5.432 0 7.501-3.665 7.501-7.477 0-4.309-2.59-7.348-7.5-7.348zm-10.838 5.357v-2.095h3.404v13.132h-3.392v-2.114c-.896 1.52-2.739 2.391-4.982 2.391-4.684 0-7.303-3.305-7.303-7.105 0-3.664 2.479-6.609 6.804-6.609 2.454.029 4.498.855 5.469 2.4zm-8.675 4.387c0 2.416 1.52 4.485 4.462 4.485 2.841 0 4.386-2.02 4.386-4.411 0-2.392-1.599-4.436-4.544-4.436-2.828 0-4.3 2.04-4.3 4.362zm-10.013-9.947a1.722 1.722 0 0 1 1.818-1.768 1.788 1.788 0 0 1 1.847 1.821 1.714 1.714 0 0 1-1.847 1.744 1.743 1.743 0 0 1-1.818-1.797zm.15 3.465h3.39v9.596c0 .595.125 1.02.62 1.02a3.657 3.657 0 0 0 .648-.073l.525 2.478a5.931 5.931 0 0 1-2.242.414c-1.421 0-2.942-.414-2.942-3.64zM52.15 115.91h-3.386v-13.132h3.386v2.942a5.197 5.197 0 0 1 4.735-3.218 6.13 6.13 0 0 1 2.119.347l-.723 2.479a7.435 7.435 0 0 0-1.793-.249c-2.445 0-4.338 1.843-4.338 4.545zm-11.037-11.037v-2.095h3.392v13.132h-3.392v-2.114c-.896 1.52-2.738 2.391-4.982 2.391-4.688 0-7.303-3.305-7.303-7.105 0-3.664 2.479-6.609 6.804-6.609 2.466.029 4.51.855 5.481 2.4zm-8.675 4.387c0 2.416 1.52 4.485 4.462 4.485 2.838 0 4.383-2.02 4.383-4.411 0-2.391-1.595-4.436-4.544-4.436-2.826 0-4.296 2.04-4.296 4.362zm-9.24-11.34 4.651 17.99h-3.51L21.24 102.95 15.4 115.91h-2.965l-5.808-12.883-3.19 12.883H0l4.61-17.99h3.04l6.28 13.93 6.253-13.93z"/></svg>',
'logo' => '<svg class="w-[4.5rem] h-[4.5rem] p-2 transition-all duration-200 bg-black/10 dark:bg-white/10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="currentColor" d="M127.434 12.182a1.727 1.727 0 0 0-1.174-.392c-1.168 0-2.68.793-3.495 1.218l-.322.165a11.095 11.095 0 0 1-4.365 1.1c-1.554.049-2.892.14-4.635.322-10.327 1.06-14.933 8.975-19.37 16.63-2.416 4.164-4.91 8.489-8.33 11.793a22.472 22.472 0 0 1-2.252 1.913c-3.54 2.631-7.985 4.51-11.443 5.84-3.329 1.273-6.964 2.417-10.474 3.524-3.219 1.012-6.255 1.97-9.048 3.008a96.902 96.902 0 0 1-3.275 1.14c-2.545.825-4.378 1.458-7.06 3.304a45.386 45.386 0 0 0-2.804 2.066 29.585 29.585 0 0 0-5.597 5.894 34.802 34.802 0 0 1-4.701 5.642c-.566.554-1.57.826-3.074.826-1.76 0-3.895-.363-6.154-.747-2.33-.413-4.738-.805-6.803-.805-1.677 0-2.962.273-3.92.826 0 0-1.617.942-2.298 2.16l.67.302a13.718 13.718 0 0 1 2.859 2.065 14.342 14.342 0 0 0 2.973 2.115 2.553 2.553 0 0 1 .918.582c-.281.413-.694.946-1.129 1.516-2.384 3.119-3.774 5.09-2.977 6.163a2.507 2.507 0 0 0 1.239.28c5.196 0 7.989-1.35 11.52-3.06 1.024-.495 2.066-1.004 3.305-1.528 2.065-.896 4.288-2.325 6.647-3.838 3.084-2.01 6.31-4.076 9.442-5.072a25.734 25.734 0 0 1 7.943-1.115c3.305 0 6.783.441 10.138.872 2.499.322 5.089.652 7.63.805.986.057 1.9.086 2.787.086a32.307 32.307 0 0 0 3.557-.185l.284-.1c1.781-1.094 2.617-3.444 3.425-5.717.52-1.462.96-2.775 1.652-3.61a1.054 1.054 0 0 1 .132-.11.166.166 0 0 1 .202.032v.066c-.412 8.885-3.99 14.527-7.608 19.543l-2.416 2.59s3.383 0 5.307-.744c7.024-2.099 12.324-6.725 16.181-14.103a60.185 60.185 0 0 0 2.549-5.82c.065-.165.673-.47.616.384-.021.252-.038.533-.059.827 0 .173 0 .35-.033.528-.1 1.24-.392 3.859-.392 3.859l2.169-1.162c5.229-3.304 9.26-9.97 12.318-20.343 1.272-4.321 2.205-8.613 3.027-12.392.983-4.545 1.83-8.44 2.801-9.952 1.524-2.37 3.85-3.973 6.101-5.53.306-.211.616-.414.917-.637 2.83-1.986 5.643-4.279 6.263-8.555v-.095c.45-3.189.07-4.002-.364-4.373zm-7.283 103.727h-10.327V97.92h9.315c3.56 0 6.952.67 6.902 4.66 0 2.813-1.747 3.59-3.589 3.886 2.615.224 4.188 1.892 4.188 4.586.017 4.035-3.523 4.858-6.489 4.858zm-.772-10.14c3.565 0 4.362-1.372 4.362-3.115 0-2.619-1.595-3.214-4.362-3.214h-7.402v6.328zm.099 1.52h-7.501v7.1h7.823c2.194 0 4.511-.723 4.511-3.486 0-3.19-2.665-3.615-4.833-3.615zm-31.497-9.37h8.125c6.828 0 10.24 3.764 10.19 8.994.05 5.436-3.716 8.997-9.591 8.997H87.98zm2.242 1.596v14.825h6.197c5.432 0 7.501-3.665 7.501-7.477 0-4.309-2.59-7.348-7.5-7.348zm-10.838 5.357v-2.095h3.404v13.132h-3.392v-2.114c-.896 1.52-2.739 2.391-4.982 2.391-4.684 0-7.303-3.305-7.303-7.105 0-3.664 2.479-6.609 6.804-6.609 2.454.029 4.498.855 5.469 2.4zm-8.675 4.387c0 2.416 1.52 4.485 4.462 4.485 2.841 0 4.386-2.02 4.386-4.411 0-2.392-1.599-4.436-4.544-4.436-2.828 0-4.3 2.04-4.3 4.362zm-10.013-9.947a1.722 1.722 0 0 1 1.818-1.768 1.788 1.788 0 0 1 1.847 1.821 1.714 1.714 0 0 1-1.847 1.744 1.743 1.743 0 0 1-1.818-1.797zm.15 3.465h3.39v9.596c0 .595.125 1.02.62 1.02a3.657 3.657 0 0 0 .648-.073l.525 2.478a5.931 5.931 0 0 1-2.242.414c-1.421 0-2.942-.414-2.942-3.64zM52.15 115.91h-3.386v-13.132h3.386v2.942a5.197 5.197 0 0 1 4.735-3.218 6.13 6.13 0 0 1 2.119.347l-.723 2.479a7.435 7.435 0 0 0-1.793-.249c-2.445 0-4.338 1.843-4.338 4.545zm-11.037-11.037v-2.095h3.392v13.132h-3.392v-2.114c-.896 1.52-2.738 2.391-4.982 2.391-4.688 0-7.303-3.305-7.303-7.105 0-3.664 2.479-6.609 6.804-6.609 2.466.029 4.51.855 5.481 2.4zm-8.675 4.387c0 2.416 1.52 4.485 4.462 4.485 2.838 0 4.383-2.02 4.383-4.411 0-2.391-1.595-4.436-4.544-4.436-2.826 0-4.296 2.04-4.296 4.362zm-9.24-11.34 4.651 17.99h-3.51L21.24 102.95 15.4 115.91h-2.965l-5.808-12.883-3.19 12.883H0l4.61-17.99h3.04l6.28 13.93 6.253-13.93z"/></svg>',
],
[
'id' => 'redis',
'name' => 'Redis',
'description' => 'Redis is a source-available, in-memory storage, used as a distributed, in-memory key–value database, cache and message broker, with optional durability.',
'logo' => '<svg class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:fill-white fill-black bg-black/10 dark:bg-white/10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path d="M21.4 97.6c0 1.8-1.5 3.5-3.5 3.5-1.5 0-2.8.4-4 1.3-1.3.8-2.2 1.9-3 3.2-1.6 2.1-2.4 4.6-2.7 5.5v12.5c0 1.9-1.6 3.5-3.6 3.5-1.9 0-3.5-1.6-3.5-3.5v-26c0-1.9 1.6-3.4 3.5-3.4 2 0 3.6 1.5 3.6 3.4v.5c.4-.5.9-1 1.4-1.3 2.2-1.4 5-2.6 8.3-2.6 2 0 3.5 1.5 3.5 3.4zm-1.9 13c.1-9 7-16.5 16.1-16.5 8.6 0 15.3 6.4 15.9 15.3v.3c0 .1 0 .5-.1.6-.2 1.6-1.6 2.6-3.4 2.6H27c.3 1.5 1.1 3.2 2.2 4.3 1.4 1.6 4 2.8 6.3 3 2.4.2 5.2-.4 6.8-1.6 1.4-1.4 4.1-1.3 4.9-.2.9.9 1.5 2.9 0 4.3-3.2 3-7.1 4.3-11.8 4.3-8.9 0-15.9-7.5-15.9-16.4zm7.1-3.2h18.6c-.7-2.6-4-6.5-9.7-7-5.6.2-8.3 4.3-8.9 7zm58.3 16.1c0 1.9-1.6 3.6-3.6 3.6-1.8 0-3.2-1.3-3.5-2.8-2.5 1.7-5.7 2.8-9 2.8-8.9 0-16-7.5-16-16.4 0-9 7.1-16.5 16-16.5 3.2 0 6.4 1.1 8.8 2.8V84.5c0-1.9 1.6-3.6 3.6-3.6s3.6 1.6 3.6 3.6v26.2l.1 12.8zm-16-22.2c-2.4 0-4.5 1-6.2 2.7-1.6 1.6-2.6 4-2.6 6.6 0 2.5 1 4.9 2.6 6.5 1.6 1.7 3.8 2.7 6.2 2.7 2.4 0 4.5-1 6.2-2.7 1.6-1.6 2.6-4 2.6-6.5 0-2.6-1-5-2.6-6.6-1.6-1.7-3.7-2.7-6.2-2.7zm28.6-15.4c0 2-1.5 3.6-3.6 3.6-2 0-3.6-1.6-3.6-3.6v-1.4c0-2 1.6-3.6 3.6-3.6s3.6 1.6 3.6 3.6v1.4zm0 11.9v25.7c0 2-1.5 3.6-3.6 3.6-2 0-3.6-1.6-3.6-3.6V97.8c0-2.1 1.6-3.6 3.6-3.6 2.1 0 3.6 1.5 3.6 3.6zm4.5 19.8c1.2-1.6 3.5-1.8 4.9-.5 1.7 1.4 4.7 3 7.2 2.9 1.8 0 3.4-.6 4.5-1.3.9-.8 1.2-1.4 1.2-2 0-.3-.1-.5-.2-.7-.1-.2-.3-.5-.9-.8-.9-.7-2.9-1.4-5.3-1.8h-.1c-2-.4-4-.9-5.7-1.7-1.8-.9-3.4-2-4.5-3.8-.7-1.2-1.1-2.6-1.1-4.1 0-3 1.7-5.6 3.9-7.2 2.3-1.6 5.1-2.4 8.1-2.4 4.5 0 7.8 2.2 9.9 3.6 1.6 1.1 2 3.2 1.1 4.9-1.1 1.6-3.2 2-4.9.9-2.1-1.4-4-2.4-6.1-2.4-1.6 0-3.1.5-4 1.2-.9.6-1.1 1.2-1.1 1.5 0 .3 0 .3.1.5.1.1.3.4.7.7.9.6 2.6 1.2 4.8 1.6l.1.1h.1c2.2.4 4.2 1 6.1 1.9 1.8.8 3.6 2 4.7 3.9.8 1.3 1.3 2.8 1.3 4.3 0 3.2-1.8 5.9-4.1 7.6-2.4 1.6-5.3 2.6-8.6 2.6-5.1-.1-9.1-2.4-11.7-4.5-1.4-1.3-1.6-3.5-.4-5z" fill="#currentColor"/><path fill="#A41E11" d="M106.9 62.7c-5 2.6-30.7 13.2-36.2 16-5.5 2.9-8.5 2.8-12.8.8-4.4-2.1-31.7-13.1-36.7-15.5-2.5-1.2-3.8-2.2-3.8-3.1v-9.4s35.6-7.8 41.4-9.8c5.8-2.1 7.8-2.1 12.6-.3 4.9 1.8 34.2 7.1 39 8.8v9.3c.1.9-1 1.9-3.5 3.2z"/><path fill="#D82C20" d="M106.9 53.3c-5 2.6-30.7 13.2-36.2 16-5.5 2.9-8.5 2.8-12.8.8C53.5 68 26.2 57 21.2 54.6c-4.9-2.4-5-4-.2-5.9 4.8-1.9 32.1-12.6 37.8-14.6 5.8-2.1 7.8-2.1 12.6-.3 4.9 1.8 30.5 12 35.3 13.7 5 1.8 5.2 3.2.2 5.8z"/><path fill="#A41E11" d="M106.9 47.4c-5 2.6-30.7 13.2-36.2 16-5.5 2.9-8.5 2.8-12.8.8-4.4-2.1-31.7-13.2-36.7-15.5-2.5-1.2-3.8-2.2-3.8-3.1v-9.4s35.6-7.8 41.4-9.8c5.8-2.1 7.8-2.1 12.6-.3 4.9 1.8 34.2 7.1 39 8.8v9.3c.1.9-1 1.9-3.5 3.2z"/><path fill="#D82C20" d="M106.9 38c-5 2.6-30.7 13.2-36.2 16-5.5 2.9-8.5 2.8-12.8.8-4.3-2.1-31.7-13.1-36.6-15.5-4.9-2.4-5-4-.2-5.9 4.8-1.9 32.1-12.6 37.8-14.6 5.8-2.1 7.8-2.1 12.6-.3 4.9 1.8 30.5 12 35.3 13.7 4.9 1.7 5.1 3.2.1 5.8z"/><path fill="#A41E11" d="M106.9 31.5c-5 2.6-30.7 13.2-36.2 16-5.5 2.9-8.5 2.8-12.8.8-4.3-2.1-31.7-13.1-36.6-15.5-2.5-1.2-3.8-2.2-3.8-3.1v-9.4s35.6-7.8 41.4-9.8c5.8-2.1 7.8-2.1 12.6-.3 4.9 1.8 34.2 7.1 39 8.8v9.3c0 .8-1.1 1.9-3.6 3.2z"/><path fill="#D82C20" d="M106.9 22.1c-5 2.6-30.7 13.2-36.2 16-5.5 2.9-8.5 2.8-12.8.8-4.3-2.1-31.7-13.1-36.6-15.5s-5-4-.2-5.9c4.8-1.9 32.1-12.6 37.8-14.6C64.7.8 66.7.8 71.5 2.6c4.9 1.8 30.5 12 35.3 13.7 4.9 1.7 5.1 3.2.1 5.8z"/><path fill="#fff" d="M76.2 13l-8.1.8-1.8 4.4-2.9-4.9-9.3-.8L61 10l-2-3.8 6.5 2.5 6.1-2-1.7 4zM65.8 34.1l-15-6.3 21.6-3.3z"/><ellipse fill="#fff" cx="45" cy="19.9" rx="11.5" ry="4.5"/><path fill="#7A0C00" d="M85.7 14.2l12.8 5-12.8 5.1z"/><path fill="#AD2115" d="M71.6 19.8l14.1-5.6v10.1l-1.3.5z"/></svg>',
'logo' => '<svg class="w-[4.5rem] h-[4.5rem] p-2 transition-all duration-200 bg-black/10 dark:bg-white/10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path d="M21.4 97.6c0 1.8-1.5 3.5-3.5 3.5-1.5 0-2.8.4-4 1.3-1.3.8-2.2 1.9-3 3.2-1.6 2.1-2.4 4.6-2.7 5.5v12.5c0 1.9-1.6 3.5-3.6 3.5-1.9 0-3.5-1.6-3.5-3.5v-26c0-1.9 1.6-3.4 3.5-3.4 2 0 3.6 1.5 3.6 3.4v.5c.4-.5.9-1 1.4-1.3 2.2-1.4 5-2.6 8.3-2.6 2 0 3.5 1.5 3.5 3.4zm-1.9 13c.1-9 7-16.5 16.1-16.5 8.6 0 15.3 6.4 15.9 15.3v.3c0 .1 0 .5-.1.6-.2 1.6-1.6 2.6-3.4 2.6H27c.3 1.5 1.1 3.2 2.2 4.3 1.4 1.6 4 2.8 6.3 3 2.4.2 5.2-.4 6.8-1.6 1.4-1.4 4.1-1.3 4.9-.2.9.9 1.5 2.9 0 4.3-3.2 3-7.1 4.3-11.8 4.3-8.9 0-15.9-7.5-15.9-16.4zm7.1-3.2h18.6c-.7-2.6-4-6.5-9.7-7-5.6.2-8.3 4.3-8.9 7zm58.3 16.1c0 1.9-1.6 3.6-3.6 3.6-1.8 0-3.2-1.3-3.5-2.8-2.5 1.7-5.7 2.8-9 2.8-8.9 0-16-7.5-16-16.4 0-9 7.1-16.5 16-16.5 3.2 0 6.4 1.1 8.8 2.8V84.5c0-1.9 1.6-3.6 3.6-3.6s3.6 1.6 3.6 3.6v26.2l.1 12.8zm-16-22.2c-2.4 0-4.5 1-6.2 2.7-1.6 1.6-2.6 4-2.6 6.6 0 2.5 1 4.9 2.6 6.5 1.6 1.7 3.8 2.7 6.2 2.7 2.4 0 4.5-1 6.2-2.7 1.6-1.6 2.6-4 2.6-6.5 0-2.6-1-5-2.6-6.6-1.6-1.7-3.7-2.7-6.2-2.7zm28.6-15.4c0 2-1.5 3.6-3.6 3.6-2 0-3.6-1.6-3.6-3.6v-1.4c0-2 1.6-3.6 3.6-3.6s3.6 1.6 3.6 3.6v1.4zm0 11.9v25.7c0 2-1.5 3.6-3.6 3.6-2 0-3.6-1.6-3.6-3.6V97.8c0-2.1 1.6-3.6 3.6-3.6 2.1 0 3.6 1.5 3.6 3.6zm4.5 19.8c1.2-1.6 3.5-1.8 4.9-.5 1.7 1.4 4.7 3 7.2 2.9 1.8 0 3.4-.6 4.5-1.3.9-.8 1.2-1.4 1.2-2 0-.3-.1-.5-.2-.7-.1-.2-.3-.5-.9-.8-.9-.7-2.9-1.4-5.3-1.8h-.1c-2-.4-4-.9-5.7-1.7-1.8-.9-3.4-2-4.5-3.8-.7-1.2-1.1-2.6-1.1-4.1 0-3 1.7-5.6 3.9-7.2 2.3-1.6 5.1-2.4 8.1-2.4 4.5 0 7.8 2.2 9.9 3.6 1.6 1.1 2 3.2 1.1 4.9-1.1 1.6-3.2 2-4.9.9-2.1-1.4-4-2.4-6.1-2.4-1.6 0-3.1.5-4 1.2-.9.6-1.1 1.2-1.1 1.5 0 .3 0 .3.1.5.1.1.3.4.7.7.9.6 2.6 1.2 4.8 1.6l.1.1h.1c2.2.4 4.2 1 6.1 1.9 1.8.8 3.6 2 4.7 3.9.8 1.3 1.3 2.8 1.3 4.3 0 3.2-1.8 5.9-4.1 7.6-2.4 1.6-5.3 2.6-8.6 2.6-5.1-.1-9.1-2.4-11.7-4.5-1.4-1.3-1.6-3.5-.4-5z" fill="#currentColor"/><path fill="#A41E11" d="M106.9 62.7c-5 2.6-30.7 13.2-36.2 16-5.5 2.9-8.5 2.8-12.8.8-4.4-2.1-31.7-13.1-36.7-15.5-2.5-1.2-3.8-2.2-3.8-3.1v-9.4s35.6-7.8 41.4-9.8c5.8-2.1 7.8-2.1 12.6-.3 4.9 1.8 34.2 7.1 39 8.8v9.3c.1.9-1 1.9-3.5 3.2z"/><path fill="#D82C20" d="M106.9 53.3c-5 2.6-30.7 13.2-36.2 16-5.5 2.9-8.5 2.8-12.8.8C53.5 68 26.2 57 21.2 54.6c-4.9-2.4-5-4-.2-5.9 4.8-1.9 32.1-12.6 37.8-14.6 5.8-2.1 7.8-2.1 12.6-.3 4.9 1.8 30.5 12 35.3 13.7 5 1.8 5.2 3.2.2 5.8z"/><path fill="#A41E11" d="M106.9 47.4c-5 2.6-30.7 13.2-36.2 16-5.5 2.9-8.5 2.8-12.8.8-4.4-2.1-31.7-13.2-36.7-15.5-2.5-1.2-3.8-2.2-3.8-3.1v-9.4s35.6-7.8 41.4-9.8c5.8-2.1 7.8-2.1 12.6-.3 4.9 1.8 34.2 7.1 39 8.8v9.3c.1.9-1 1.9-3.5 3.2z"/><path fill="#D82C20" d="M106.9 38c-5 2.6-30.7 13.2-36.2 16-5.5 2.9-8.5 2.8-12.8.8-4.3-2.1-31.7-13.1-36.6-15.5-4.9-2.4-5-4-.2-5.9 4.8-1.9 32.1-12.6 37.8-14.6 5.8-2.1 7.8-2.1 12.6-.3 4.9 1.8 30.5 12 35.3 13.7 4.9 1.7 5.1 3.2.1 5.8z"/><path fill="#A41E11" d="M106.9 31.5c-5 2.6-30.7 13.2-36.2 16-5.5 2.9-8.5 2.8-12.8.8-4.3-2.1-31.7-13.1-36.6-15.5-2.5-1.2-3.8-2.2-3.8-3.1v-9.4s35.6-7.8 41.4-9.8c5.8-2.1 7.8-2.1 12.6-.3 4.9 1.8 34.2 7.1 39 8.8v9.3c0 .8-1.1 1.9-3.6 3.2z"/><path fill="#D82C20" d="M106.9 22.1c-5 2.6-30.7 13.2-36.2 16-5.5 2.9-8.5 2.8-12.8.8-4.3-2.1-31.7-13.1-36.6-15.5s-5-4-.2-5.9c4.8-1.9 32.1-12.6 37.8-14.6C64.7.8 66.7.8 71.5 2.6c4.9 1.8 30.5 12 35.3 13.7 4.9 1.7 5.1 3.2.1 5.8z"/><path fill="#fff" d="M76.2 13l-8.1.8-1.8 4.4-2.9-4.9-9.3-.8L61 10l-2-3.8 6.5 2.5 6.1-2-1.7 4zM65.8 34.1l-15-6.3 21.6-3.3z"/><ellipse fill="#fff" cx="45" cy="19.9" rx="11.5" ry="4.5"/><path fill="#7A0C00" d="M85.7 14.2l12.8 5-12.8 5.1z"/><path fill="#AD2115" d="M71.6 19.8l14.1-5.6v10.1l-1.3.5z"/></svg>',
],
[
'id' => 'keydb',
'name' => 'KeyDB',
'description' => 'KeyDB is a database that offers high performance, low latency, and scalability for various data structures and workloads.',
'logo' => ' <div class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:fill-white fill-black bg-black/10 dark:bg-white/10"><svg version="1.1" width="180" height="60" id="svg1326" viewBox="0 0 544 182" sodipodi:docname="keydb.svg" inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><defs id="defs1330"/><sodipodi:namedview id="namedview1328" pagecolor="currentColor" bordercolor="#666666" borderopacity="1.0" inkscape:pageshadow="2" inkscape:pageopacity="0.00784314" inkscape:pagecheckerboard="true" showgrid="false" inkscape:zoom="1.5064209" inkscape:cx="374.06544" inkscape:cy="123.80338" inkscape:window-width="1536" inkscape:window-height="889" inkscape:window-x="-8" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:current-layer="svg1326" width="791px"/><path d="M 78.589334,169.85617 12.63903,131.77977 c -1.0281,-0.591 -1.6035,-1.6659 -1.6046,-2.7722 h -0.0134 V 52.833267 c 0,-1.2966 0.7688,-2.4135 1.8749,-2.9206 l 65.739904,-37.9545 c 1.0386,-0.5966 2.2717,-0.5456 3.2321,0.0264 l 65.951096,38.0761 c 1.0282,0.591 1.6042,1.6659 1.6047,2.7726 h 0.0134 v 76.174303 c 0,1.2962 -0.7685,2.4137 -1.8743,2.9202 l -65.740496,37.9554 c -1.0389,0.5964 -2.2717,0.5453 -3.233,-0.027 z M 17.44353,127.16987 v 0 l 62.785704,36.2488 62.785996,-36.2488 V 54.671267 l -62.785996,-36.2489 -62.785704,36.2489 z" style="fill:currentColor;fill-rule:evenodd" id="path1290"/><path d="M 80.229234,14.730167 14.23273,129.00757 h 131.9933 z" style="opacity:.88;fill:#ff0;fill-opacity:1;fill-rule:evenodd" id="path1292"/><path d="M 80.229234,21.136467 19.78603,125.79677 h 120.8861 z M 11.45983,127.40197 v 0 L 77.433934,13.163767 c 0.2713,-0.4856 0.6733,-0.9068 1.1895,-1.2056 1.531,-0.8864 3.4908,-0.3645 4.3778,1.1671 L 148.88863,127.21177 c 0.3464,0.5125 0.5485,1.1308 0.5485,1.7958 0,1.7733 -1.4378,3.2111 -3.2108,3.2111 H 14.23213 v -0.007 c -0.5459,5e-4 -1.099,-0.1386 -1.6052,-0.4318 -1.531,-0.8863 -2.0537,-2.8465 -1.1671,-4.3778 z" style="fill:currentColor;fill-rule:evenodd" id="path1294"/><path d="m 12.63903,55.605567 c -1.5309,-0.8799 -2.059,-2.8347 -1.1792,-4.3657 0.8802,-1.531 2.8347,-2.0591 4.3657,-1.1792 L 80.229234,87.229065 144.63323,50.060667 c 1.531,-0.8799 3.4855,-0.3518 4.3651,1.1792 0.8796,1.531 0.3517,3.4858 -1.1793,4.3657 L 81.867334,93.666065 c -0.9603,0.5723 -2.1931,0.6227 -3.2315,0.026 z" style="opacity:1;fill:currentColor;fill-rule:evenodd" id="path1296"/><path d="m 83.440034,167.11087 c 0,1.773 -1.4377,3.2111 -3.2108,3.2111 -1.7736,0 -3.2114,-1.4381 -3.2114,-3.2111 V 90.920065 c 0,-1.773 1.4378,-3.2111 3.2114,-3.2111 1.7731,0 3.2108,1.4381 3.2108,3.2111 z" style="fill:currentColor;fill-rule:evenodd" id="path1298"/><path d="m 146.22633,137.25697 c 4.5555,0 8.2491,-3.693 8.2491,-8.2494 0,-4.5564 -3.6936,-8.2491 -8.2491,-8.2491 -4.5564,0 -8.2503,3.6927 -8.2503,8.2491 0,4.5564 3.6939,8.2494 8.2503,8.2494 z" style="fill:currentColor;fill-rule:evenodd" id="path1300"/><path d="m 146.22633,61.082867 c 4.5555,0 8.2491,-3.6938 8.2491,-8.2496 0,-4.5562 -3.6936,-8.2494 -8.2491,-8.2494 -4.5564,0 -8.2503,3.6932 -8.2503,8.2494 0,4.5558 3.6939,8.2496 8.2503,8.2496 z" style="fill:currentColor;fill-rule:evenodd" id="path1302"/><path d="m 14.23213,61.082867 c 4.5561,0 8.25,-3.6938 8.25,-8.2496 0,-4.5562 -3.6939,-8.2494 -8.25,-8.2494 -4.5555003,0 -8.2494003,3.6932 -8.2494003,8.2494 0,4.5558 3.6939,8.2496 8.2494003,8.2496 z" style="fill:currentColor;fill-rule:evenodd" id="path1304"/><path d="m 14.23213,137.25697 c 4.5561,0 8.25,-3.693 8.25,-8.2494 0,-4.5564 -3.6939,-8.2491 -8.25,-8.2491 -4.5555003,0 -8.2494003,3.6927 -8.2494003,8.2491 0,4.5564 3.6939,8.2494 8.2494003,8.2494 z" style="fill:currentColor;fill-rule:evenodd" id="path1306"/><path d="m 80.229234,175.36027 c 4.5558,0 8.2497,-3.6933 8.2497,-8.2494 0,-4.5562 -3.6939,-8.2494 -8.2497,-8.2494 -4.5564,0 -8.2497,3.6932 -8.2497,8.2494 0,4.5561 3.6933,8.2494 8.2497,8.2494 z" style="fill:currentColor;fill-rule:evenodd" id="path1308"/><path d="m 80.229234,99.170065 c 4.5558,0 8.2497,-3.6938 8.2497,-8.25 0,-4.5561 -3.6939,-8.2494 -8.2497,-8.2494 -4.5564,0 -8.2497,3.6933 -8.2497,8.2494 0,4.5562 3.6933,8.25 8.2497,8.25 z" style="fill:currentColor;fill-rule:evenodd" id="path1310"/><path d="m 80.229234,22.979567 c 4.5558,0 8.2497,-3.6932 8.2497,-8.2494 0,-4.5561 -3.6939,-8.2492996 -8.2497,-8.2492996 -4.5564,0 -8.2497,3.6931996 -8.2497,8.2492996 0,4.5562 3.6933,8.2494 8.2497,8.2494 z" style="fill:currentColor;fill-rule:evenodd" id="path1312"/></svg></div>',
'logo' => ' <div class="w-[4.5rem] h-[4.5rem] p-2 transition-all duration-200 bg-black/10 dark:bg-white/10"><svg version="1.1" width="180" height="60" id="svg1326" viewBox="0 0 544 182" sodipodi:docname="keydb.svg" inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><defs id="defs1330"/><sodipodi:namedview id="namedview1328" pagecolor="currentColor" bordercolor="#666666" borderopacity="1.0" inkscape:pageshadow="2" inkscape:pageopacity="0.00784314" inkscape:pagecheckerboard="true" showgrid="false" inkscape:zoom="1.5064209" inkscape:cx="374.06544" inkscape:cy="123.80338" inkscape:window-width="1536" inkscape:window-height="889" inkscape:window-x="-8" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:current-layer="svg1326" width="791px"/><path d="M 78.589334,169.85617 12.63903,131.77977 c -1.0281,-0.591 -1.6035,-1.6659 -1.6046,-2.7722 h -0.0134 V 52.833267 c 0,-1.2966 0.7688,-2.4135 1.8749,-2.9206 l 65.739904,-37.9545 c 1.0386,-0.5966 2.2717,-0.5456 3.2321,0.0264 l 65.951096,38.0761 c 1.0282,0.591 1.6042,1.6659 1.6047,2.7726 h 0.0134 v 76.174303 c 0,1.2962 -0.7685,2.4137 -1.8743,2.9202 l -65.740496,37.9554 c -1.0389,0.5964 -2.2717,0.5453 -3.233,-0.027 z M 17.44353,127.16987 v 0 l 62.785704,36.2488 62.785996,-36.2488 V 54.671267 l -62.785996,-36.2489 -62.785704,36.2489 z" style="fill:currentColor;fill-rule:evenodd" id="path1290"/><path d="M 80.229234,14.730167 14.23273,129.00757 h 131.9933 z" style="opacity:.88;fill:#ff0;fill-opacity:1;fill-rule:evenodd" id="path1292"/><path d="M 80.229234,21.136467 19.78603,125.79677 h 120.8861 z M 11.45983,127.40197 v 0 L 77.433934,13.163767 c 0.2713,-0.4856 0.6733,-0.9068 1.1895,-1.2056 1.531,-0.8864 3.4908,-0.3645 4.3778,1.1671 L 148.88863,127.21177 c 0.3464,0.5125 0.5485,1.1308 0.5485,1.7958 0,1.7733 -1.4378,3.2111 -3.2108,3.2111 H 14.23213 v -0.007 c -0.5459,5e-4 -1.099,-0.1386 -1.6052,-0.4318 -1.531,-0.8863 -2.0537,-2.8465 -1.1671,-4.3778 z" style="fill:currentColor;fill-rule:evenodd" id="path1294"/><path d="m 12.63903,55.605567 c -1.5309,-0.8799 -2.059,-2.8347 -1.1792,-4.3657 0.8802,-1.531 2.8347,-2.0591 4.3657,-1.1792 L 80.229234,87.229065 144.63323,50.060667 c 1.531,-0.8799 3.4855,-0.3518 4.3651,1.1792 0.8796,1.531 0.3517,3.4858 -1.1793,4.3657 L 81.867334,93.666065 c -0.9603,0.5723 -2.1931,0.6227 -3.2315,0.026 z" style="opacity:1;fill:currentColor;fill-rule:evenodd" id="path1296"/><path d="m 83.440034,167.11087 c 0,1.773 -1.4377,3.2111 -3.2108,3.2111 -1.7736,0 -3.2114,-1.4381 -3.2114,-3.2111 V 90.920065 c 0,-1.773 1.4378,-3.2111 3.2114,-3.2111 1.7731,0 3.2108,1.4381 3.2108,3.2111 z" style="fill:currentColor;fill-rule:evenodd" id="path1298"/><path d="m 146.22633,137.25697 c 4.5555,0 8.2491,-3.693 8.2491,-8.2494 0,-4.5564 -3.6936,-8.2491 -8.2491,-8.2491 -4.5564,0 -8.2503,3.6927 -8.2503,8.2491 0,4.5564 3.6939,8.2494 8.2503,8.2494 z" style="fill:currentColor;fill-rule:evenodd" id="path1300"/><path d="m 146.22633,61.082867 c 4.5555,0 8.2491,-3.6938 8.2491,-8.2496 0,-4.5562 -3.6936,-8.2494 -8.2491,-8.2494 -4.5564,0 -8.2503,3.6932 -8.2503,8.2494 0,4.5558 3.6939,8.2496 8.2503,8.2496 z" style="fill:currentColor;fill-rule:evenodd" id="path1302"/><path d="m 14.23213,61.082867 c 4.5561,0 8.25,-3.6938 8.25,-8.2496 0,-4.5562 -3.6939,-8.2494 -8.25,-8.2494 -4.5555003,0 -8.2494003,3.6932 -8.2494003,8.2494 0,4.5558 3.6939,8.2496 8.2494003,8.2496 z" style="fill:currentColor;fill-rule:evenodd" id="path1304"/><path d="m 14.23213,137.25697 c 4.5561,0 8.25,-3.693 8.25,-8.2494 0,-4.5564 -3.6939,-8.2491 -8.25,-8.2491 -4.5555003,0 -8.2494003,3.6927 -8.2494003,8.2491 0,4.5564 3.6939,8.2494 8.2494003,8.2494 z" style="fill:currentColor;fill-rule:evenodd" id="path1306"/><path d="m 80.229234,175.36027 c 4.5558,0 8.2497,-3.6933 8.2497,-8.2494 0,-4.5562 -3.6939,-8.2494 -8.2497,-8.2494 -4.5564,0 -8.2497,3.6932 -8.2497,8.2494 0,4.5561 3.6933,8.2494 8.2497,8.2494 z" style="fill:currentColor;fill-rule:evenodd" id="path1308"/><path d="m 80.229234,99.170065 c 4.5558,0 8.2497,-3.6938 8.2497,-8.25 0,-4.5561 -3.6939,-8.2494 -8.2497,-8.2494 -4.5564,0 -8.2497,3.6933 -8.2497,8.2494 0,4.5562 3.6933,8.25 8.2497,8.25 z" style="fill:currentColor;fill-rule:evenodd" id="path1310"/><path d="m 80.229234,22.979567 c 4.5558,0 8.2497,-3.6932 8.2497,-8.2494 0,-4.5561 -3.6939,-8.2492996 -8.2497,-8.2492996 -4.5564,0 -8.2497,3.6931996 -8.2497,8.2492996 0,4.5562 3.6933,8.2494 8.2497,8.2494 z" style="fill:currentColor;fill-rule:evenodd" id="path1312"/></svg></div>',
],
[
'id' => 'dragonfly',
'name' => 'Dragonfly',
'description' => 'Dragonfly DB is a drop-in Redis replacement that delivers 25x more throughput and 12x faster snapshotting than Redis.',
'logo' => '<div class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark: opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:fill-white fill-black bg-black/10 dark:bg-white/10"><svg class="block dark:hidden" width="245" height="60" viewBox="0 0 210 50" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M22 0C3.883 0 0 3.883 0 22C0 40.117 3.883 44 22 44C40.117 44 44 40.117 44 22C44 3.883 40.117 0 22 0ZM19.5585 16.265C19.8224 16.406 20 16.7008 20 17C20 18.1046 20.8954 19 22 19C23.1046 19 24 18.1046 24 17C24 16.7008 24.1776 16.406 24.4414 16.265C25.0714 15.9283 25.5 15.2642 25.5 14.5C25.5 13.3954 24.6046 12.5 23.5 12.5C23.3071 12.5 23.1205 12.5273 22.944 12.5783C22.3661 12.7452 21.6339 12.7452 21.056 12.5783C20.8794 12.5273 20.6929 12.5 20.5 12.5C19.3954 12.5 18.5 13.3954 18.5 14.5C18.5 15.2642 18.9286 15.9283 19.5585 16.265ZM20.3087 18.8411C20.2282 18.8977 20.1644 18.978 20.1283 19.0744L19.6243 20.4185C19.5438 20.633 19.5395 20.8686 19.6119 21.0859L20.4253 23.526C20.4744 23.6733 20.5566 23.8087 20.687 23.893C20.9154 24.0408 21.3531 24.25 22 24.25C22.6468 24.25 23.0845 24.0408 23.3129 23.893C23.4433 23.8087 23.5255 23.6733 23.5746 23.526L24.388 21.0859C24.4604 20.8686 24.4561 20.633 24.3756 20.4185L23.8716 19.0744C23.8355 18.978 23.7717 18.8977 23.6912 18.8411C23.2461 19.2502 22.6522 19.5 22 19.5C21.3477 19.5 20.7538 19.2502 20.3087 18.8411ZM21 30L20.5105 24.3712C20.8174 24.5497 21.3156 24.75 22 24.75C22.6842 24.75 23.1825 24.5498 23.4895 24.3711L23 30C23 30 22.75 30.25 22 30.25C21.25 30.25 21 30 21 30ZM21.25 34L21.0055 30.5779L21.0331 30.5892C21.2547 30.6779 21.5686 30.75 22 30.75C22.4314 30.75 22.7453 30.6779 22.967 30.5892L22.9944 30.578L22.75 34C22.75 34 22.625 34.5 22 34.5C21.375 34.5 21.25 34 21.25 34ZM9.24995 20H19.2C18.8224 20.6294 18.9914 21.0678 19.1582 21.5005L19.1582 21.5005L19.1582 21.5006L19.1582 21.5006L19.1582 21.5006L19.1582 21.5006C19.1901 21.5835 19.222 21.6662 19.25 21.75L9.90851 24.4252C9.31202 24.5932 8.67362 24.3772 8.3018 23.8814L7.71391 23.0976C7.37302 22.643 7.31818 22.0349 7.57227 21.5267L7.92108 20.8291C8.17311 20.3251 8.68641 20.0048 9.24995 20ZM7.99998 19.2331L19.375 19.5C19.375 19.5 19.5546 19.0506 19.625 18.75C19.6491 18.647 19.6745 18.5441 19.6961 18.4586C19.7264 18.3386 19.6699 18.2154 19.5562 18.1662C18.2312 17.5935 10.5221 14.2625 9.23721 13.7331C8.73807 13.5197 8.16257 13.5926 7.73242 13.9238L6.58499 14.8072C6.20854 15.097 5.99872 15.5412 5.99998 16C6.00037 16.1456 6.02204 16.2928 6.06639 16.4369L6.62254 18.2331C6.80943 18.8405 7.36503 19.2067 7.99998 19.2331ZM34.75 20H24.8C25.1776 20.6294 25.0086 21.0679 24.8418 21.5005L24.8418 21.5005L24.8418 21.5006L24.8418 21.5006C24.8098 21.5835 24.7779 21.6662 24.75 21.75L34.0914 24.4252C34.6879 24.5932 35.3263 24.3772 35.6982 23.8814L36.286 23.0976C36.6269 22.643 36.6818 22.0349 36.4277 21.5267L36.0789 20.8291C35.8268 20.3251 35.3135 20.0048 34.75 20ZM36 19.2331L24.625 19.5C24.625 19.5 24.4453 19.0506 24.375 18.75C24.3509 18.647 24.3255 18.5441 24.3039 18.4586C24.2735 18.3386 24.3301 18.2154 24.4438 18.1662C25.7688 17.5935 33.4779 14.2625 34.7627 13.7331C35.2619 13.5197 35.8374 13.5926 36.2675 13.9238L37.415 14.8072C37.7914 15.097 38.0012 15.5412 38 16C37.9996 16.1456 37.9779 16.2928 37.9336 16.4369L37.3774 18.2331C37.1905 18.8405 36.6349 19.2067 36 19.2331Z" fill="black"/></svg><svg class="hidden dark:block" xmlns="http://www.w3.org/2000/svg" width="225" height="55" viewBox="0 0 378 88" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M44 0C7.766 0 0 7.766 0 44C0 80.234 7.766 88 44 88C80.234 88 88 80.234 88 44C88 7.766 80.234 0 44 0ZM39.1171 32.53C39.6448 32.8121 40 33.4016 40 34C40 36.2091 41.7909 38 44 38C46.2091 38 48 36.2091 48 34C48 33.4016 48.3552 32.8121 48.8829 32.53C50.1428 31.8566 51 30.5284 51 29C51 26.7909 49.2091 25 47 25C46.6142 25 46.2411 25.0546 45.8881 25.1566C44.7322 25.4904 43.2678 25.4904 42.1119 25.1566C41.7589 25.0546 41.3858 25 41 25C38.7909 25 37 26.7909 37 29C37 30.5284 37.8572 31.8566 39.1171 32.53ZM40.6174 37.6822C40.4565 37.7954 40.3289 37.9561 40.2566 38.1489L39.2486 40.837C39.0877 41.266 39.079 41.7371 39.2238 42.1717L40.8506 47.0521C40.9488 47.3466 41.1133 47.6174 41.374 47.786C41.8309 48.0815 42.7062 48.5 43.9999 48.5C45.2937 48.5 46.169 48.0815 46.6259 47.786C46.8866 47.6174 47.0511 47.3466 47.1492 47.0521L48.776 42.1717C48.9209 41.7371 48.9122 41.266 48.7513 40.837L47.7433 38.1489C47.671 37.9561 47.5434 37.7954 47.3825 37.6822C46.4922 38.5005 45.3044 39 43.9999 39C42.6955 39 41.5077 38.5005 40.6174 37.6822ZM42 60L41.0211 48.7423C41.6348 49.0994 42.6312 49.5 44 49.5C45.3684 49.5 46.365 49.0996 46.979 48.7423L46 60C46 60 45.5 60.5 44 60.5C42.5 60.5 42 60 42 60ZM42.5 68L42.0111 61.1559C42.0291 61.1635 42.0474 61.171 42.0662 61.1785C42.5095 61.3558 43.1373 61.5 44 61.5C44.8628 61.5 45.4906 61.3558 45.9339 61.1785C45.9526 61.171 45.9709 61.1635 45.9888 61.156L45.5 68C45.5 68 45.25 69 4 Line truncated
'logo' => '<div class="w-[4.5rem] h-[4.5rem] p-2 transition-all duration-200 dark: opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 bg-black/10 dark:bg-white/10"><svg class="block dark:hidden" width="245" height="60" viewBox="0 0 210 50" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M22 0C3.883 0 0 3.883 0 22C0 40.117 3.883 44 22 44C40.117 44 44 40.117 44 22C44 3.883 40.117 0 22 0ZM19.5585 16.265C19.8224 16.406 20 16.7008 20 17C20 18.1046 20.8954 19 22 19C23.1046 19 24 18.1046 24 17C24 16.7008 24.1776 16.406 24.4414 16.265C25.0714 15.9283 25.5 15.2642 25.5 14.5C25.5 13.3954 24.6046 12.5 23.5 12.5C23.3071 12.5 23.1205 12.5273 22.944 12.5783C22.3661 12.7452 21.6339 12.7452 21.056 12.5783C20.8794 12.5273 20.6929 12.5 20.5 12.5C19.3954 12.5 18.5 13.3954 18.5 14.5C18.5 15.2642 18.9286 15.9283 19.5585 16.265ZM20.3087 18.8411C20.2282 18.8977 20.1644 18.978 20.1283 19.0744L19.6243 20.4185C19.5438 20.633 19.5395 20.8686 19.6119 21.0859L20.4253 23.526C20.4744 23.6733 20.5566 23.8087 20.687 23.893C20.9154 24.0408 21.3531 24.25 22 24.25C22.6468 24.25 23.0845 24.0408 23.3129 23.893C23.4433 23.8087 23.5255 23.6733 23.5746 23.526L24.388 21.0859C24.4604 20.8686 24.4561 20.633 24.3756 20.4185L23.8716 19.0744C23.8355 18.978 23.7717 18.8977 23.6912 18.8411C23.2461 19.2502 22.6522 19.5 22 19.5C21.3477 19.5 20.7538 19.2502 20.3087 18.8411ZM21 30L20.5105 24.3712C20.8174 24.5497 21.3156 24.75 22 24.75C22.6842 24.75 23.1825 24.5498 23.4895 24.3711L23 30C23 30 22.75 30.25 22 30.25C21.25 30.25 21 30 21 30ZM21.25 34L21.0055 30.5779L21.0331 30.5892C21.2547 30.6779 21.5686 30.75 22 30.75C22.4314 30.75 22.7453 30.6779 22.967 30.5892L22.9944 30.578L22.75 34C22.75 34 22.625 34.5 22 34.5C21.375 34.5 21.25 34 21.25 34ZM9.24995 20H19.2C18.8224 20.6294 18.9914 21.0678 19.1582 21.5005L19.1582 21.5005L19.1582 21.5006L19.1582 21.5006L19.1582 21.5006L19.1582 21.5006C19.1901 21.5835 19.222 21.6662 19.25 21.75L9.90851 24.4252C9.31202 24.5932 8.67362 24.3772 8.3018 23.8814L7.71391 23.0976C7.37302 22.643 7.31818 22.0349 7.57227 21.5267L7.92108 20.8291C8.17311 20.3251 8.68641 20.0048 9.24995 20ZM7.99998 19.2331L19.375 19.5C19.375 19.5 19.5546 19.0506 19.625 18.75C19.6491 18.647 19.6745 18.5441 19.6961 18.4586C19.7264 18.3386 19.6699 18.2154 19.5562 18.1662C18.2312 17.5935 10.5221 14.2625 9.23721 13.7331C8.73807 13.5197 8.16257 13.5926 7.73242 13.9238L6.58499 14.8072C6.20854 15.097 5.99872 15.5412 5.99998 16C6.00037 16.1456 6.02204 16.2928 6.06639 16.4369L6.62254 18.2331C6.80943 18.8405 7.36503 19.2067 7.99998 19.2331ZM34.75 20H24.8C25.1776 20.6294 25.0086 21.0679 24.8418 21.5005L24.8418 21.5005L24.8418 21.5006L24.8418 21.5006C24.8098 21.5835 24.7779 21.6662 24.75 21.75L34.0914 24.4252C34.6879 24.5932 35.3263 24.3772 35.6982 23.8814L36.286 23.0976C36.6269 22.643 36.6818 22.0349 36.4277 21.5267L36.0789 20.8291C35.8268 20.3251 35.3135 20.0048 34.75 20ZM36 19.2331L24.625 19.5C24.625 19.5 24.4453 19.0506 24.375 18.75C24.3509 18.647 24.3255 18.5441 24.3039 18.4586C24.2735 18.3386 24.3301 18.2154 24.4438 18.1662C25.7688 17.5935 33.4779 14.2625 34.7627 13.7331C35.2619 13.5197 35.8374 13.5926 36.2675 13.9238L37.415 14.8072C37.7914 15.097 38.0012 15.5412 38 16C37.9996 16.1456 37.9779 16.2928 37.9336 16.4369L37.3774 18.2331C37.1905 18.8405 36.6349 19.2067 36 19.2331Z" fill="black"/></svg><svg class="hidden dark:block" xmlns="http://www.w3.org/2000/svg" width="225" height="55" viewBox="0 0 378 88" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M44 0C7.766 0 0 7.766 0 44C0 80.234 7.766 88 44 88C80.234 88 88 80.234 88 44C88 7.766 80.234 0 44 0ZM39.1171 32.53C39.6448 32.8121 40 33.4016 40 34C40 36.2091 41.7909 38 44 38C46.2091 38 48 36.2091 48 34C48 33.4016 48.3552 32.8121 48.8829 32.53C50.1428 31.8566 51 30.5284 51 29C51 26.7909 49.2091 25 47 25C46.6142 25 46.2411 25.0546 45.8881 25.1566C44.7322 25.4904 43.2678 25.4904 42.1119 25.1566C41.7589 25.0546 41.3858 25 41 25C38.7909 25 37 26.7909 37 29C37 30.5284 37.8572 31.8566 39.1171 32.53ZM40.6174 37.6822C40.4565 37.7954 40.3289 37.9561 40.2566 38.1489L39.2486 40.837C39.0877 41.266 39.079 41.7371 39.2238 42.1717L40.8506 47.0521C40.9488 47.3466 41.1133 47.6174 41.374 47.786C41.8309 48.0815 42.7062 48.5 43.9999 48.5C45.2937 48.5 46.169 48.0815 46.6259 47.786C46.8866 47.6174 47.0511 47.3466 47.1492 47.0521L48.776 42.1717C48.9209 41.7371 48.9122 41.266 48.7513 40.837L47.7433 38.1489C47.671 37.9561 47.5434 37.7954 47.3825 37.6822C46.4922 38.5005 45.3044 39 43.9999 39C42.6955 39 41.5077 38.5005 40.6174 37.6822ZM42 60L41.0211 48.7423C41.6348 49.0994 42.6312 49.5 44 49.5C45.3684 49.5 46.365 49.0996 46.979 48.7423L46 60C46 60 45.5 60.5 44 60.5C42.5 60.5 42 60 42 60ZM42.5 68L42.0111 61.1559C42.0291 61.1635 42.0474 61.171 42.0662 61.1785C42.5095 61.3558 43.1373 61.5 44 61.5C44.8628 61.5 45.4906 61.3558 45.9339 61.1785C45.9526 61.171 45.9709 61.1635 45.9888 61.156L45.5 68C45.5 68 45.25 69 44 69C42.75 69 42.5 68 42.5 68ZM18.4999 40H Line truncated
],
[
'id' => 'mongodb',
'name' => 'MongoDB',
'description' => 'MongoDB is a source-available, cross-platform, document-oriented database program.',
'logo' => '<svg class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:fill-white fill-black bg-black/10 dark:bg-white/10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" clip-rule="evenodd" fill="currentColor" d="M87.259 100.139c.169-.325.331-.612.469-.909.087-.19.221-.228.41-.223 1.133.032 2.266.067 3.4.078.963.01 1.928-.008 2.892-.019 1.086-.013 2.172-.07 3.257-.039 1.445.042 2.853.325 4.16.968 1.561.769 2.742 1.94 3.547 3.483a8.71 8.71 0 01.931 3.14c.172 1.608.059 3.179-.451 4.717-.632 1.906-1.832 3.365-3.499 4.458-1.283.841-2.69 1.338-4.198 1.622-1.596.301-3.197.204-4.798.209-1.756.007-3.511-.031-5.267-.051-.307-.003-.351-.061-.27-.354l.075-.27c.171-.538.263-.562.809-.652a2.83 2.83 0 001.087-.413c.184-.122.26-.44.332-.685.062-.214.065-.449.067-.675.025-3.425.051-6.849.065-10.272a44.077 44.077 0 00-.065-2.596c-.034-.605-.357-1.019-1.077-1.162-.56-.111-1.124-.197-1.687-.296l-.189-.059zm16.076 8.293c-.076-.682-.113-1.37-.235-2.042-.292-1.613-.998-3.018-2.238-4.119-2.005-1.779-4.419-2.053-6.949-1.841-.576.048-.7.245-.709.837-.014.84-.028 1.68-.029 2.52-.004 2.664-.004 5.328 0 7.992.001.758.009 1.516.031 2.272.024.774.305 1.429 1.063 1.729 1.195.473 2.452.529 3.706.336 2.003-.307 3.404-1.474 4.344-3.223.744-1.388.954-2.903 1.016-4.461zm4.869 9.071c-.024-.415.146-.758.356-1.073.057-.085.253-.081.388-.108l1.146-.227c.405-.086.618-.358.675-.755.038-.262.074-.527.077-.792a879.6 879.6 0 00.059-6.291c.01-2.1.002-4.2.002-6.3l-.009-.401c-.041-.675-.367-1.025-1.037-1.124l-1.453-.221c-.179-.024-.244-.11-.179-.269.112-.271.219-.552.377-.796.059-.09.258-.125.392-.122.694.01 1.388.062 2.082.061l6.041-.036c1.164-.001 2.288.202 3.332.759 1.149.612 1.792 1.559 1.976 2.849.192 1.355-.219 2.497-1.209 3.404-.407.374-.934.618-1.406.922l-.154.096c.438.161.855.3 1.261.466 1.188.487 2.133 1.248 2.633 2.463.395.959.395 1.959.161 2.953-.364 1.556-1.389 2.591-2.722 3.374-1.251.735-2.605 1.163-4.047 1.235-1.33.067-2.666.042-3.999.057l-.772.004a996.106 996.106 0 01-3.854-.096l-.117-.032zm5.537-6.089h.013c0 .658-.009 1.316.003 1.974.008.426-.007.864.085 1.274.138.613.418 1.166 1.106 1.342a6.671 6.671 0 002.818.124c1.177-.205 2.116-.795 2.631-1.916.382-.833.439-1.716.308-2.618-.174-1.188-.805-2.05-1.854-2.615-.688-.371-1.422-.598-2.204-.628-.876-.033-1.753-.035-2.629-.062-.246-.007-.28.118-.279.32.005.934.002 1.869.002 2.805zm1.865-4.475c.479-.024 1.021-.031 1.56-.085 1.032-.103 1.759-.622 2.138-1.609.193-.501.185-1.017.19-1.538.015-1.357-.777-2.469-2.066-2.929-.995-.355-2.021-.361-3.053-.333-.418.011-.605.194-.611.615l-.062 5.489c-.003.218.091.312.303.319l1.601.071z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="currentColor" d="M10.543 116.448l-.073.944c-.68 0-1.307-.005-1.934.002-1.181.012-2.362.031-3.544.048l-.114.007c-.169-.02-.476-.02-.484-.07-.05-.281-.034-.576-.021-.867.001-.033.116-.075.183-.091l.919-.205c.573-.149.775-.396.802-.988.031-.667.062-1.335.065-2.002.009-1.642-.001-3.282.006-4.924.001-.384-.132-.67-.49-.826a43.787 43.787 0 00-1.285-.546c-.204-.082-.469-.127-.445-.401.024-.279.281-.352.523-.407 1.002-.229 2.005-.452 3.004-.696.322-.079.63-.212 1.015-.346.02.208.057.406.053.604l-.059.941c-.001.106.054.248.133.307.048.037.209-.03.289-.092.854-.65 1.758-1.211 2.789-1.538 1.597-.507 2.968-.037 3.928 1.34.338.485.339.485.808.146.805-.585 1.647-1.101 2.589-1.441 2.068-.747 4.055.201 4.774 2.284.262.756.362 1.537.36 2.335l-.019 5.298c-.001.437.144.686.56.822.467.153.951.258 1.477.396l-.122.911c-.598 0-1.148-.004-1.698.001-1.344.012-2.688.019-4.031.05-.234.006-.295-.052-.307-.271-.039-.701-.045-.7.615-.858l.222-.057c.645-.176.86-.374.865-1.028.015-1.878.054-3.761-.041-5.635-.099-1.944-1.642-2.979-3.526-2.481a5.194 5.194 0 00-1.69.814c-.175.125-.208.269-.194.488.053.828.086 1.657.093 2.486.012 1.451-.006 2.902 0 4.354.002.588.203.813.784.949l.863.199.16.036c.012.276.023.552.01.828-.008.173-.142.188-.292.185-.839-.021-1.679-.049-2.518-.047-1.021.002-2.041.031-3.061.049h-.24c0-.293-.014-.573.01-.852.005-.067.123-.161.204-.182l1.006-.213c.427-.105.631-.324.655-.758.114-2.01.196-4.021.007-6.03a3.695 3.695 0 00-.326-1.145c-.515-1.138-1.674-1.613-3.011-1.271-.635.162-1.208.453-1.75.82a.795.795 0 00-.378.695c0 2.005.007 4.01.013 6.014l.011.773c.012.539.241.823.776.939.344.078.692.131 1.082.203z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="currentColor" d="M71.001 105.285c.155.754.152 1.432-.402 1.946-.55.511-1.246.339-1.925.225.063.358.133.662.167.97.247 2.289-.738 3.988-2.861 4.959-.802.366-1.653.502-2.522.572-.432.034-.81.364-.851.719-.042.36.184.73.636.838.533.127 1.089.163 1.636.226 1.174.134 2.361.195 3.521.405 1.754.316 2.733 1.847 2.424 3.609-.275 1.568-1.183 2.709-2.449 3.584-2.133 1.478-4.473 1.91-6.965 1.156-1.425-.432-2.43-1.369-2.777-2.885-.174-.759.011-1.446.582-1.961.679-.61 1.418-1.154 2.129-1.73l.23-.231-.264-.185c-.725-.344-1.305-.815-1.53-1.633-.077-. Line truncated
'logo' => '<svg class="w-[4.5rem] h-[4.5rem] p-2 transition-all duration-200 bg-black/10 dark:bg-white/10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" clip-rule="evenodd" fill="currentColor" d="M87.259 100.139c.169-.325.331-.612.469-.909.087-.19.221-.228.41-.223 1.133.032 2.266.067 3.4.078.963.01 1.928-.008 2.892-.019 1.086-.013 2.172-.07 3.257-.039 1.445.042 2.853.325 4.16.968 1.561.769 2.742 1.94 3.547 3.483a8.71 8.71 0 01.931 3.14c.172 1.608.059 3.179-.451 4.717-.632 1.906-1.832 3.365-3.499 4.458-1.283.841-2.69 1.338-4.198 1.622-1.596.301-3.197.204-4.798.209-1.756.007-3.511-.031-5.267-.051-.307-.003-.351-.061-.27-.354l.075-.27c.171-.538.263-.562.809-.652a2.83 2.83 0 001.087-.413c.184-.122.26-.44.332-.685.062-.214.065-.449.067-.675.025-3.425.051-6.849.065-10.272a44.077 44.077 0 00-.065-2.596c-.034-.605-.357-1.019-1.077-1.162-.56-.111-1.124-.197-1.687-.296l-.189-.059zm16.076 8.293c-.076-.682-.113-1.37-.235-2.042-.292-1.613-.998-3.018-2.238-4.119-2.005-1.779-4.419-2.053-6.949-1.841-.576.048-.7.245-.709.837-.014.84-.028 1.68-.029 2.52-.004 2.664-.004 5.328 0 7.992.001.758.009 1.516.031 2.272.024.774.305 1.429 1.063 1.729 1.195.473 2.452.529 3.706.336 2.003-.307 3.404-1.474 4.344-3.223.744-1.388.954-2.903 1.016-4.461zm4.869 9.071c-.024-.415.146-.758.356-1.073.057-.085.253-.081.388-.108l1.146-.227c.405-.086.618-.358.675-.755.038-.262.074-.527.077-.792a879.6 879.6 0 00.059-6.291c.01-2.1.002-4.2.002-6.3l-.009-.401c-.041-.675-.367-1.025-1.037-1.124l-1.453-.221c-.179-.024-.244-.11-.179-.269.112-.271.219-.552.377-.796.059-.09.258-.125.392-.122.694.01 1.388.062 2.082.061l6.041-.036c1.164-.001 2.288.202 3.332.759 1.149.612 1.792 1.559 1.976 2.849.192 1.355-.219 2.497-1.209 3.404-.407.374-.934.618-1.406.922l-.154.096c.438.161.855.3 1.261.466 1.188.487 2.133 1.248 2.633 2.463.395.959.395 1.959.161 2.953-.364 1.556-1.389 2.591-2.722 3.374-1.251.735-2.605 1.163-4.047 1.235-1.33.067-2.666.042-3.999.057l-.772.004a996.106 996.106 0 01-3.854-.096l-.117-.032zm5.537-6.089h.013c0 .658-.009 1.316.003 1.974.008.426-.007.864.085 1.274.138.613.418 1.166 1.106 1.342a6.671 6.671 0 002.818.124c1.177-.205 2.116-.795 2.631-1.916.382-.833.439-1.716.308-2.618-.174-1.188-.805-2.05-1.854-2.615-.688-.371-1.422-.598-2.204-.628-.876-.033-1.753-.035-2.629-.062-.246-.007-.28.118-.279.32.005.934.002 1.869.002 2.805zm1.865-4.475c.479-.024 1.021-.031 1.56-.085 1.032-.103 1.759-.622 2.138-1.609.193-.501.185-1.017.19-1.538.015-1.357-.777-2.469-2.066-2.929-.995-.355-2.021-.361-3.053-.333-.418.011-.605.194-.611.615l-.062 5.489c-.003.218.091.312.303.319l1.601.071z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="currentColor" d="M10.543 116.448l-.073.944c-.68 0-1.307-.005-1.934.002-1.181.012-2.362.031-3.544.048l-.114.007c-.169-.02-.476-.02-.484-.07-.05-.281-.034-.576-.021-.867.001-.033.116-.075.183-.091l.919-.205c.573-.149.775-.396.802-.988.031-.667.062-1.335.065-2.002.009-1.642-.001-3.282.006-4.924.001-.384-.132-.67-.49-.826a43.787 43.787 0 00-1.285-.546c-.204-.082-.469-.127-.445-.401.024-.279.281-.352.523-.407 1.002-.229 2.005-.452 3.004-.696.322-.079.63-.212 1.015-.346.02.208.057.406.053.604l-.059.941c-.001.106.054.248.133.307.048.037.209-.03.289-.092.854-.65 1.758-1.211 2.789-1.538 1.597-.507 2.968-.037 3.928 1.34.338.485.339.485.808.146.805-.585 1.647-1.101 2.589-1.441 2.068-.747 4.055.201 4.774 2.284.262.756.362 1.537.36 2.335l-.019 5.298c-.001.437.144.686.56.822.467.153.951.258 1.477.396l-.122.911c-.598 0-1.148-.004-1.698.001-1.344.012-2.688.019-4.031.05-.234.006-.295-.052-.307-.271-.039-.701-.045-.7.615-.858l.222-.057c.645-.176.86-.374.865-1.028.015-1.878.054-3.761-.041-5.635-.099-1.944-1.642-2.979-3.526-2.481a5.194 5.194 0 00-1.69.814c-.175.125-.208.269-.194.488.053.828.086 1.657.093 2.486.012 1.451-.006 2.902 0 4.354.002.588.203.813.784.949l.863.199.16.036c.012.276.023.552.01.828-.008.173-.142.188-.292.185-.839-.021-1.679-.049-2.518-.047-1.021.002-2.041.031-3.061.049h-.24c0-.293-.014-.573.01-.852.005-.067.123-.161.204-.182l1.006-.213c.427-.105.631-.324.655-.758.114-2.01.196-4.021.007-6.03a3.695 3.695 0 00-.326-1.145c-.515-1.138-1.674-1.613-3.011-1.271-.635.162-1.208.453-1.75.82a.795.795 0 00-.378.695c0 2.005.007 4.01.013 6.014l.011.773c.012.539.241.823.776.939.344.078.692.131 1.082.203z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="currentColor" d="M71.001 105.285c.155.754.152 1.432-.402 1.946-.55.511-1.246.339-1.925.225.063.358.133.662.167.97.247 2.289-.738 3.988-2.861 4.959-.802.366-1.653.502-2.522.572-.432.034-.81.364-.851.719-.042.36.184.73.636.838.533.127 1.089.163 1.636.226 1.174.134 2.361.195 3.521.405 1.754.316 2.733 1.847 2.424 3.609-.275 1.568-1.183 2.709-2.449 3.584-2.133 1.478-4.473 1.91-6.965 1.156-1.425-.432-2.43-1.369-2.777-2.885-.174-.759.011-1.446.582-1.961.679-.61 1.418-1.154 2.129-1.73l.23-.231-.264-.185c-.725-.344-1.305-.815-1.53-1.633-.077-.277.003-.459.238-.601.4-.241.798-.486 1.193-.735.186-.116.37-.236.543-.37.236-.18.215-.314-.067-.418-.656-.242-1.239 Line truncated
],
[
'id' => 'clickhouse',
'name' => 'ClickHouse',
'description' => 'ClickHouse is a column-oriented database that supports real-time analytics, business intelligence, observability, ML and GenAI, and more.',
'logo' => '<div class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:fill-white fill-black bg-black/10 dark:bg-white/10"><svg width="215" height="90" viewBox="0 0 100 43" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_378_10860)"><rect x="2.70837" y="2.875" width="2.24992" height="20.2493" rx="0.236664" fill="currentColor"/><rect x="7.2085" y="2.875" width="2.24992" height="20.2493" rx="0.236664" fill="currentColor"/><rect x="11.7086" y="2.875" width="2.24992" height="20.2493" rx="0.236664" fill="currentColor"/><rect x="16.2076" y="2.875" width="2.24992" height="20.2493" rx="0.236664" fill="currentColor"/><rect x="20.7087" y="10.7502" width="2.24992" height="4.49985" rx="0.236664" fill="currentColor"/></g></svg></div>',
'logo' => '<div class="w-[4.5rem] h-[4.5rem] p-2 transition-all duration-200 bg-black/10 dark:bg-white/10"><svg width="215" height="90" viewBox="0 0 100 43" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_378_10860)"><rect x="2.70837" y="2.875" width="2.24992" height="20.2493" rx="0.236664" fill="currentColor"/><rect x="7.2085" y="2.875" width="2.24992" height="20.2493" rx="0.236664" fill="currentColor"/><rect x="11.7086" y="2.875" width="2.24992" height="20.2493" rx="0.236664" fill="currentColor"/><rect x="16.2076" y="2.875" width="2.24992" height="20.2493" rx="0.236664" fill="currentColor"/><rect x="20.7087" y="10.7502" width="2.24992" height="4.49985" rx="0.236664" fill="currentColor"/></g></svg></div>',
],
];
+77 -12
View File
@@ -2,14 +2,16 @@
namespace App\Livewire\Project\Service;
use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class EditDomain extends Component
{
use SynchronizesModelData;
use AuthorizesRequests;
public $applicationId;
public ServiceApplication $application;
@@ -20,6 +22,13 @@ class EditDomain extends Component
public $forceSaveDomains = false;
public $showPortWarningModal = false;
public $forceRemovePort = false;
public $requiredPort = null;
#[Validate(['nullable'])]
public ?string $fqdn = null;
protected $rules = [
@@ -28,16 +37,25 @@ class EditDomain extends Component
public function mount()
{
$this->application = ServiceApplication::query()->findOrFail($this->applicationId);
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
$this->authorize('view', $this->application);
$this->syncFromModel();
$this->requiredPort = $this->application->getRequiredPort();
$this->syncData();
}
protected function getModelBindings(): array
public function syncData(bool $toModel = false): void
{
return [
'fqdn' => 'application.fqdn',
];
if ($toModel) {
$this->validate();
// Sync to model
$this->application->fqdn = $this->fqdn;
$this->application->save();
} else {
// Sync from model
$this->fqdn = $this->application->fqdn;
}
}
public function confirmDomainUsage()
@@ -47,6 +65,19 @@ class EditDomain extends Component
$this->submit();
}
public function confirmRemovePort()
{
$this->forceRemovePort = true;
$this->showPortWarningModal = false;
$this->submit();
}
public function cancelRemovePort()
{
$this->showPortWarningModal = false;
$this->syncData(); // Reset to original FQDN
}
public function submit()
{
try {
@@ -64,8 +95,8 @@ class EditDomain extends Component
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
// Sync to model for domain conflict check
$this->syncToModel();
// Sync to model for domain conflict check (without validation)
$this->application->fqdn = $this->fqdn;
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
@@ -80,10 +111,44 @@ class EditDomain extends Component
$this->forceSaveDomains = false;
}
// Check for required port
if (! $this->forceRemovePort) {
$requiredPort = $this->application->getRequiredPort();
if ($requiredPort !== null) {
// Check if all FQDNs have a port
$fqdns = str($this->fqdn)->trim()->explode(',');
$missingPort = false;
foreach ($fqdns as $fqdn) {
$fqdn = trim($fqdn);
if (empty($fqdn)) {
continue;
}
$port = ServiceApplication::extractPortFromUrl($fqdn);
if ($port === null) {
$missingPort = true;
break;
}
}
if ($missingPort) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
return;
}
}
} else {
// Reset the force flag after using it
$this->forceRemovePort = false;
}
$this->validate();
$this->application->save();
$this->application->refresh();
$this->syncFromModel();
$this->syncData();
updateCompose($this->application);
if (str($this->application->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
@@ -96,7 +161,7 @@ class EditDomain extends Component
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
$this->syncFromModel();
$this->syncData();
}
return handleError($e, $this);
+24 -11
View File
@@ -2,7 +2,6 @@
namespace App\Livewire\Project\Service;
use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
@@ -19,11 +18,12 @@ use App\Models\StandaloneRedis;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
class FileStorage extends Component
{
use AuthorizesRequests, SynchronizesModelData;
use AuthorizesRequests;
public LocalFileVolume $fileStorage;
@@ -37,8 +37,10 @@ class FileStorage extends Component
public bool $isReadOnly = false;
#[Validate(['nullable'])]
public ?string $content = null;
#[Validate(['required', 'boolean'])]
public bool $isBasedOnGit = false;
protected $rules = [
@@ -61,15 +63,24 @@ class FileStorage extends Component
}
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
$this->syncFromModel();
$this->syncData();
}
protected function getModelBindings(): array
public function syncData(bool $toModel = false): void
{
return [
'content' => 'fileStorage.content',
'isBasedOnGit' => 'fileStorage.is_based_on_git',
];
if ($toModel) {
$this->validate();
// Sync to model
$this->fileStorage->content = $this->content;
$this->fileStorage->is_based_on_git = $this->isBasedOnGit;
$this->fileStorage->save();
} else {
// Sync from model
$this->content = $this->fileStorage->content;
$this->isBasedOnGit = $this->fileStorage->is_based_on_git;
}
}
public function convertToDirectory()
@@ -96,7 +107,7 @@ class FileStorage extends Component
$this->authorize('update', $this->resource);
$this->fileStorage->loadStorageOnServer();
$this->syncFromModel();
$this->syncData();
$this->dispatch('success', 'File storage loaded from server.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -165,14 +176,16 @@ class FileStorage extends Component
if ($this->fileStorage->is_directory) {
$this->content = null;
}
$this->syncToModel();
// Sync component properties to model
$this->fileStorage->content = $this->content;
$this->fileStorage->is_based_on_git = $this->isBasedOnGit;
$this->fileStorage->save();
$this->fileStorage->saveStorageOnServer();
$this->dispatch('success', 'File updated.');
} catch (\Throwable $e) {
$this->fileStorage->setRawAttributes($original);
$this->fileStorage->save();
$this->syncFromModel();
$this->syncData();
return handleError($e, $this);
}
@@ -2,20 +2,19 @@
namespace App\Livewire\Project\Service;
use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\InstanceSettings;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class ServiceApplicationView extends Component
{
use AuthorizesRequests;
use SynchronizesModelData;
public ServiceApplication $application;
@@ -31,20 +30,34 @@ class ServiceApplicationView extends Component
public $forceSaveDomains = false;
public $showPortWarningModal = false;
public $forceRemovePort = false;
public $requiredPort = null;
#[Validate(['nullable'])]
public ?string $humanName = null;
#[Validate(['nullable'])]
public ?string $description = null;
#[Validate(['nullable'])]
public ?string $fqdn = null;
#[Validate(['string', 'nullable'])]
public ?string $image = null;
#[Validate(['required', 'boolean'])]
public bool $excludeFromStatus = false;
#[Validate(['nullable', 'boolean'])]
public bool $isLogDrainEnabled = false;
#[Validate(['nullable', 'boolean'])]
public bool $isGzipEnabled = false;
#[Validate(['nullable', 'boolean'])]
public bool $isStripprefixEnabled = false;
protected $rules = [
@@ -79,7 +92,15 @@ class ServiceApplicationView extends Component
return;
}
$this->syncToModel();
// Sync component properties to model
$this->application->human_name = $this->humanName;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->image = $this->image;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->save();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
@@ -114,24 +135,53 @@ class ServiceApplicationView extends Component
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
$this->syncFromModel();
$this->requiredPort = $this->application->getRequiredPort();
$this->syncData();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
protected function getModelBindings(): array
public function confirmRemovePort()
{
return [
'humanName' => 'application.human_name',
'description' => 'application.description',
'fqdn' => 'application.fqdn',
'image' => 'application.image',
'excludeFromStatus' => 'application.exclude_from_status',
'isLogDrainEnabled' => 'application.is_log_drain_enabled',
'isGzipEnabled' => 'application.is_gzip_enabled',
'isStripprefixEnabled' => 'application.is_stripprefix_enabled',
];
$this->forceRemovePort = true;
$this->showPortWarningModal = false;
$this->submit();
}
public function cancelRemovePort()
{
$this->showPortWarningModal = false;
$this->syncData(); // Reset to original FQDN
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Sync to model
$this->application->human_name = $this->humanName;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->image = $this->image;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->save();
} else {
// Sync from model
$this->humanName = $this->application->human_name;
$this->description = $this->application->description;
$this->fqdn = $this->application->fqdn;
$this->image = $this->application->image;
$this->excludeFromStatus = data_get($this->application, 'exclude_from_status', false);
$this->isLogDrainEnabled = data_get($this->application, 'is_log_drain_enabled', false);
$this->isGzipEnabled = data_get($this->application, 'is_gzip_enabled', true);
$this->isStripprefixEnabled = data_get($this->application, 'is_stripprefix_enabled', true);
}
}
public function convertToDatabase()
@@ -193,8 +243,15 @@ class ServiceApplicationView extends Component
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
// Sync to model for domain conflict check
$this->syncToModel();
// Sync to model for domain conflict check (without validation)
$this->application->human_name = $this->humanName;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->image = $this->image;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
@@ -209,10 +266,44 @@ class ServiceApplicationView extends Component
$this->forceSaveDomains = false;
}
// Check for required port
if (! $this->forceRemovePort) {
$requiredPort = $this->application->getRequiredPort();
if ($requiredPort !== null) {
// Check if all FQDNs have a port
$fqdns = str($this->fqdn)->trim()->explode(',');
$missingPort = false;
foreach ($fqdns as $fqdn) {
$fqdn = trim($fqdn);
if (empty($fqdn)) {
continue;
}
$port = ServiceApplication::extractPortFromUrl($fqdn);
if ($port === null) {
$missingPort = true;
break;
}
}
if ($missingPort) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
return;
}
}
} else {
// Reset the force flag after using it
$this->forceRemovePort = false;
}
$this->validate();
$this->application->save();
$this->application->refresh();
$this->syncFromModel();
$this->syncData();
updateCompose($this->application);
if (str($this->application->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
@@ -224,7 +315,7 @@ class ServiceApplicationView extends Component
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
$this->syncFromModel();
$this->syncData();
}
return handleError($e, $this);
+16 -6
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class StackForm extends Component
@@ -22,7 +23,7 @@ class StackForm extends Component
public string $dockerComposeRaw;
public string $dockerCompose;
public ?string $dockerCompose = null;
public ?bool $connectToDockerNetwork = null;
@@ -30,7 +31,7 @@ class StackForm extends Component
{
$baseRules = [
'dockerComposeRaw' => 'required',
'dockerCompose' => 'required',
'dockerCompose' => 'nullable',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'connectToDockerNetwork' => 'nullable',
@@ -140,18 +141,27 @@ class StackForm extends Component
$this->validate();
$this->syncData(true);
// Validate for command injection BEFORE saving to database
// Validate for command injection BEFORE any database operations
validateDockerComposeForInjection($this->service->docker_compose_raw);
$this->service->save();
$this->service->saveExtraFields($this->fields);
$this->service->parse();
// Use transaction to ensure atomicity - if parse fails, save is rolled back
DB::transaction(function () {
$this->service->save();
$this->service->saveExtraFields($this->fields);
$this->service->parse();
});
// Refresh and write files after a successful commit
$this->service->refresh();
$this->service->saveComposeConfigs();
$this->dispatch('refreshEnvs');
$this->dispatch('refreshServices');
$notify && $this->dispatch('success', 'Service saved.');
} catch (\Throwable $e) {
// On error, refresh from database to restore clean state
$this->service->refresh();
$this->syncData(false);
return handleError($e, $this);
} finally {
if (is_null($this->service->config_hash)) {
+2
View File
@@ -33,6 +33,8 @@ class GetLogs extends Component
public ?string $container = null;
public ?string $displayName = null;
public ?string $pull_request = null;
public ?bool $streamLogs = false;
+96 -25
View File
@@ -2,42 +2,54 @@
namespace App\Livewire\Project\Shared;
use App\Livewire\Concerns\SynchronizesModelData;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
class HealthChecks extends Component
{
use AuthorizesRequests;
use SynchronizesModelData;
public $resource;
// Explicit properties
#[Validate(['boolean'])]
public bool $healthCheckEnabled = false;
#[Validate(['string'])]
public string $healthCheckMethod;
#[Validate(['string'])]
public string $healthCheckScheme;
#[Validate(['string'])]
public string $healthCheckHost;
#[Validate(['nullable', 'string'])]
public ?string $healthCheckPort = null;
#[Validate(['string'])]
public string $healthCheckPath;
#[Validate(['integer'])]
public int $healthCheckReturnCode;
#[Validate(['nullable', 'string'])]
public ?string $healthCheckResponseText = null;
#[Validate(['integer', 'min:1'])]
public int $healthCheckInterval;
#[Validate(['integer', 'min:1'])]
public int $healthCheckTimeout;
#[Validate(['integer', 'min:1'])]
public int $healthCheckRetries;
#[Validate(['integer'])]
public int $healthCheckStartPeriod;
#[Validate(['boolean'])]
public bool $customHealthcheckFound = false;
protected $rules = [
@@ -56,36 +68,69 @@ class HealthChecks extends Component
'customHealthcheckFound' => 'boolean',
];
protected function getModelBindings(): array
{
return [
'healthCheckEnabled' => 'resource.health_check_enabled',
'healthCheckMethod' => 'resource.health_check_method',
'healthCheckScheme' => 'resource.health_check_scheme',
'healthCheckHost' => 'resource.health_check_host',
'healthCheckPort' => 'resource.health_check_port',
'healthCheckPath' => 'resource.health_check_path',
'healthCheckReturnCode' => 'resource.health_check_return_code',
'healthCheckResponseText' => 'resource.health_check_response_text',
'healthCheckInterval' => 'resource.health_check_interval',
'healthCheckTimeout' => 'resource.health_check_timeout',
'healthCheckRetries' => 'resource.health_check_retries',
'healthCheckStartPeriod' => 'resource.health_check_start_period',
'customHealthcheckFound' => 'resource.custom_healthcheck_found',
];
}
public function mount()
{
$this->authorize('view', $this->resource);
$this->syncFromModel();
$this->syncData();
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Sync to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
$this->resource->health_check_port = $this->healthCheckPort;
$this->resource->health_check_path = $this->healthCheckPath;
$this->resource->health_check_return_code = $this->healthCheckReturnCode;
$this->resource->health_check_response_text = $this->healthCheckResponseText;
$this->resource->health_check_interval = $this->healthCheckInterval;
$this->resource->health_check_timeout = $this->healthCheckTimeout;
$this->resource->health_check_retries = $this->healthCheckRetries;
$this->resource->health_check_start_period = $this->healthCheckStartPeriod;
$this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
} else {
// Sync from model
$this->healthCheckEnabled = $this->resource->health_check_enabled;
$this->healthCheckMethod = $this->resource->health_check_method;
$this->healthCheckScheme = $this->resource->health_check_scheme;
$this->healthCheckHost = $this->resource->health_check_host;
$this->healthCheckPort = $this->resource->health_check_port;
$this->healthCheckPath = $this->resource->health_check_path;
$this->healthCheckReturnCode = $this->resource->health_check_return_code;
$this->healthCheckResponseText = $this->resource->health_check_response_text;
$this->healthCheckInterval = $this->resource->health_check_interval;
$this->healthCheckTimeout = $this->resource->health_check_timeout;
$this->healthCheckRetries = $this->resource->health_check_retries;
$this->healthCheckStartPeriod = $this->resource->health_check_start_period;
$this->customHealthcheckFound = $this->resource->custom_healthcheck_found;
}
}
public function instantSave()
{
$this->authorize('update', $this->resource);
$this->syncToModel();
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
$this->resource->health_check_port = $this->healthCheckPort;
$this->resource->health_check_path = $this->healthCheckPath;
$this->resource->health_check_return_code = $this->healthCheckReturnCode;
$this->resource->health_check_response_text = $this->healthCheckResponseText;
$this->resource->health_check_interval = $this->healthCheckInterval;
$this->resource->health_check_timeout = $this->healthCheckTimeout;
$this->resource->health_check_retries = $this->healthCheckRetries;
$this->resource->health_check_start_period = $this->healthCheckStartPeriod;
$this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
}
@@ -96,7 +141,20 @@ class HealthChecks extends Component
$this->authorize('update', $this->resource);
$this->validate();
$this->syncToModel();
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
$this->resource->health_check_port = $this->healthCheckPort;
$this->resource->health_check_path = $this->healthCheckPath;
$this->resource->health_check_return_code = $this->healthCheckReturnCode;
$this->resource->health_check_response_text = $this->healthCheckResponseText;
$this->resource->health_check_interval = $this->healthCheckInterval;
$this->resource->health_check_timeout = $this->healthCheckTimeout;
$this->resource->health_check_retries = $this->healthCheckRetries;
$this->resource->health_check_start_period = $this->healthCheckStartPeriod;
$this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
} catch (\Throwable $e) {
@@ -111,7 +169,20 @@ class HealthChecks extends Component
$wasEnabled = $this->healthCheckEnabled;
$this->healthCheckEnabled = ! $this->healthCheckEnabled;
$this->syncToModel();
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
$this->resource->health_check_port = $this->healthCheckPort;
$this->resource->health_check_path = $this->healthCheckPath;
$this->resource->health_check_return_code = $this->healthCheckReturnCode;
$this->resource->health_check_response_text = $this->healthCheckResponseText;
$this->resource->health_check_interval = $this->healthCheckInterval;
$this->resource->health_check_timeout = $this->healthCheckTimeout;
$this->resource->health_check_retries = $this->healthCheckRetries;
$this->resource->health_check_start_period = $this->healthCheckStartPeriod;
$this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
if ($this->healthCheckEnabled && ! $wasEnabled && $this->resource->isRunning()) {
@@ -34,11 +34,14 @@ class Add extends Component
public ?string $container = '';
public int $timeout = 300;
protected $rules = [
'name' => 'required|string',
'command' => 'required|string',
'frequency' => 'required|string',
'container' => 'nullable|string',
'timeout' => 'required|integer|min:60|max:3600',
];
protected $validationAttributes = [
@@ -46,6 +49,7 @@ class Add extends Component
'command' => 'command',
'frequency' => 'frequency',
'container' => 'container',
'timeout' => 'timeout',
];
public function mount()
@@ -103,6 +107,7 @@ class Add extends Component
$task->command = $this->command;
$task->frequency = $this->frequency;
$task->container = $this->container;
$task->timeout = $this->timeout;
$task->team_id = currentTeam()->id;
switch ($this->type) {
@@ -130,5 +135,6 @@ class Add extends Component
$this->command = '';
$this->frequency = '';
$this->container = '';
$this->timeout = 300;
}
}
@@ -40,6 +40,9 @@ class Show extends Component
#[Validate(['string', 'nullable'])]
public ?string $container = null;
#[Validate(['integer', 'required', 'min:60', 'max:3600'])]
public $timeout = 300;
#[Locked]
public ?string $application_uuid;
@@ -99,6 +102,7 @@ class Show extends Component
$this->task->command = str($this->command)->trim()->value();
$this->task->frequency = str($this->frequency)->trim()->value();
$this->task->container = str($this->container)->trim()->value();
$this->task->timeout = (int) $this->timeout;
$this->task->save();
} else {
$this->isEnabled = $this->task->enabled;
@@ -106,6 +110,7 @@ class Show extends Component
$this->command = $this->task->command;
$this->frequency = $this->task->frequency;
$this->container = $this->task->container;
$this->timeout = $this->task->timeout ?? 300;
}
}
@@ -30,6 +30,60 @@ class CloudProviderTokens extends Component
$this->tokens = CloudProviderToken::ownedByCurrentTeam()->get();
}
public function validateToken(int $tokenId)
{
try {
$token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId);
$this->authorize('view', $token);
if ($token->provider === 'hetzner') {
$isValid = $this->validateHetznerToken($token->token);
if ($isValid) {
$this->dispatch('success', 'Hetzner token is valid.');
} else {
$this->dispatch('error', 'Hetzner token validation failed. Please check the token.');
}
} elseif ($token->provider === 'digitalocean') {
$isValid = $this->validateDigitalOceanToken($token->token);
if ($isValid) {
$this->dispatch('success', 'DigitalOcean token is valid.');
} else {
$this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.');
}
} else {
$this->dispatch('error', 'Unknown provider.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function validateHetznerToken(string $token): bool
{
try {
$response = \Illuminate\Support\Facades\Http::withToken($token)
->timeout(10)
->get('https://api.hetzner.cloud/v1/servers?per_page=1');
return $response->successful();
} catch (\Throwable $e) {
return false;
}
}
private function validateDigitalOceanToken(string $token): bool
{
try {
$response = \Illuminate\Support\Facades\Http::withToken($token)
->timeout(10)
->get('https://api.digitalocean.com/v2/account');
return $response->successful();
} catch (\Throwable $e) {
return false;
}
}
public function deleteToken(int $tokenId)
{
try {
+6 -1
View File
@@ -561,7 +561,12 @@ class ByHetzner extends Component
$server->save();
if ($this->from_onboarding) {
// When in onboarding, use wire:navigate for proper modal handling
// Complete the boarding when server is successfully created via Hetzner
currentTeam()->update([
'show_boarding' => false,
]);
refreshSession();
return $this->redirect(route('server.show', $server->uuid));
}
-32
View File
@@ -7,7 +7,6 @@ use App\Models\Server;
use App\Models\Team;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -39,25 +38,12 @@ class ByIp extends Component
public int $port = 22;
public bool $is_swarm_manager = false;
public bool $is_swarm_worker = false;
public $selected_swarm_cluster = null;
public bool $is_build_server = false;
#[Locked]
public Collection $swarm_managers;
public function mount()
{
$this->name = generate_random_name();
$this->private_key_id = $this->private_keys->first()?->id;
$this->swarm_managers = Server::isUsable()->get()->where('settings.is_swarm_manager', true);
if ($this->swarm_managers->count() > 0) {
$this->selected_swarm_cluster = $this->swarm_managers->first()->id;
}
}
protected function rules(): array
@@ -72,9 +58,6 @@ class ByIp extends Component
'ip' => 'required|string',
'user' => 'required|string',
'port' => 'required|integer|between:1,65535',
'is_swarm_manager' => 'required|boolean',
'is_swarm_worker' => 'required|boolean',
'selected_swarm_cluster' => 'nullable|integer',
'is_build_server' => 'required|boolean',
];
}
@@ -94,11 +77,6 @@ class ByIp extends Component
'port.required' => 'The Port field is required.',
'port.integer' => 'The Port field must be an integer.',
'port.between' => 'The Port field must be between 1 and 65535.',
'is_swarm_manager.required' => 'The Swarm Manager field is required.',
'is_swarm_manager.boolean' => 'The Swarm Manager field must be true or false.',
'is_swarm_worker.required' => 'The Swarm Worker field is required.',
'is_swarm_worker.boolean' => 'The Swarm Worker field must be true or false.',
'selected_swarm_cluster.integer' => 'The Swarm Cluster field must be an integer.',
'is_build_server.required' => 'The Build Server field is required.',
'is_build_server.boolean' => 'The Build Server field must be true or false.',
]);
@@ -140,9 +118,6 @@ class ByIp extends Component
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
];
if ($this->is_swarm_worker) {
$payload['swarm_cluster'] = $this->selected_swarm_cluster;
}
if ($this->is_build_server) {
data_forget($payload, 'proxy');
}
@@ -150,13 +125,6 @@ class ByIp extends Component
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($this->is_build_server) {
$this->is_swarm_manager = false;
$this->is_swarm_worker = false;
} else {
$server->settings->is_swarm_manager = $this->is_swarm_manager;
$server->settings->is_swarm_worker = $this->is_swarm_worker;
}
$server->settings->is_build_server = $this->is_build_server;
$server->settings->save();
+2 -22
View File
@@ -85,19 +85,8 @@ class Advanced extends Component
// Handle allowed IPs with subnet support and 0.0.0.0 special case
$this->allowed_ips = str($this->allowed_ips)->replaceEnd(',', '')->trim();
// Check if user entered 0.0.0.0 or left field empty (both allow access from anywhere)
$allowsFromAnywhere = false;
if (empty($this->allowed_ips)) {
$allowsFromAnywhere = true;
} elseif ($this->allowed_ips === '0.0.0.0' || str_contains($this->allowed_ips, '0.0.0.0')) {
$allowsFromAnywhere = true;
}
// Check if it's 0.0.0.0 (allow all) or empty
if ($this->allowed_ips === '0.0.0.0' || empty($this->allowed_ips)) {
// Keep as is - empty means no restriction, 0.0.0.0 means allow all
} else {
// Validate and clean up the entries
// Only validate and clean up if we have IPs and it's not 0.0.0.0 (allow all)
if (! empty($this->allowed_ips) && ! in_array('0.0.0.0', array_map('trim', explode(',', $this->allowed_ips)))) {
$invalidEntries = [];
$validEntries = str($this->allowed_ips)->trim()->explode(',')->map(function ($entry) use (&$invalidEntries) {
$entry = str($entry)->trim()->toString();
@@ -133,7 +122,6 @@ class Advanced extends Component
return;
}
// Also check if we have no valid entries after filtering
if ($validEntries->isEmpty()) {
$this->dispatch('error', 'No valid IP addresses or subnets provided');
@@ -144,14 +132,6 @@ class Advanced extends Component
}
$this->instantSave();
// Show security warning if allowing access from anywhere
if ($allowsFromAnywhere) {
$message = empty($this->allowed_ips)
? 'Empty IP allowlist allows API access from anywhere.<br><br>This is not recommended for production environments!'
: 'Using 0.0.0.0 allows API access from anywhere.<br><br>This is not recommended for production environments!';
$this->dispatch('warning', $message);
}
} catch (\Exception $e) {
return handleError($e, $this);
}
+5
View File
@@ -35,6 +35,9 @@ class Index extends Component
#[Validate('required|string|timezone')]
public string $instance_timezone;
#[Validate('nullable|string|max:50')]
public ?string $dev_helper_version = null;
public array $domainConflicts = [];
public bool $showDomainConflictModal = false;
@@ -60,6 +63,7 @@ class Index extends Component
$this->public_ipv4 = $this->settings->public_ipv4;
$this->public_ipv6 = $this->settings->public_ipv6;
$this->instance_timezone = $this->settings->instance_timezone;
$this->dev_helper_version = $this->settings->dev_helper_version;
}
#[Computed]
@@ -81,6 +85,7 @@ class Index extends Component
$this->settings->public_ipv4 = $this->public_ipv4;
$this->settings->public_ipv6 = $this->public_ipv6;
$this->settings->instance_timezone = $this->instance_timezone;
$this->settings->dev_helper_version = $this->dev_helper_version;
if ($isSave) {
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
+80 -4
View File
@@ -29,7 +29,16 @@ class SettingsOauth extends Component
return redirect()->route('home');
}
$this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) {
$carry[$setting->provider] = $setting;
$carry[$setting->provider] = [
'id' => $setting->id,
'provider' => $setting->provider,
'enabled' => $setting->enabled,
'client_id' => $setting->client_id,
'client_secret' => $setting->client_secret,
'redirect_uri' => $setting->redirect_uri,
'tenant' => $setting->tenant,
'base_url' => $setting->base_url,
];
return $carry;
}, []);
@@ -38,16 +47,83 @@ class SettingsOauth extends Component
private function updateOauthSettings(?string $provider = null)
{
if ($provider) {
$oauth = $this->oauth_settings_map[$provider];
$oauthData = $this->oauth_settings_map[$provider];
$oauth = OauthSetting::find($oauthData['id']);
if (! $oauth) {
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
}
$oauth->fill([
'enabled' => $oauthData['enabled'],
'client_id' => $oauthData['client_id'],
'client_secret' => $oauthData['client_secret'],
'redirect_uri' => $oauthData['redirect_uri'],
'tenant' => $oauthData['tenant'],
'base_url' => $oauthData['base_url'],
]);
if (! $oauth->couldBeEnabled()) {
$oauth->update(['enabled' => false]);
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
}
$oauth->save();
// Update the array with fresh data
$this->oauth_settings_map[$provider] = [
'id' => $oauth->id,
'provider' => $oauth->provider,
'enabled' => $oauth->enabled,
'client_id' => $oauth->client_id,
'client_secret' => $oauth->client_secret,
'redirect_uri' => $oauth->redirect_uri,
'tenant' => $oauth->tenant,
'base_url' => $oauth->base_url,
];
$this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!');
} else {
foreach (array_values($this->oauth_settings_map) as &$setting) {
$setting->save();
$errors = [];
foreach (array_values($this->oauth_settings_map) as $settingData) {
$oauth = OauthSetting::find($settingData['id']);
if (! $oauth) {
$errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted.";
continue;
}
$oauth->fill([
'enabled' => $settingData['enabled'],
'client_id' => $settingData['client_id'],
'client_secret' => $settingData['client_secret'],
'redirect_uri' => $settingData['redirect_uri'],
'tenant' => $settingData['tenant'],
'base_url' => $settingData['base_url'],
]);
if ($settingData['enabled'] && ! $oauth->couldBeEnabled()) {
$oauth->enabled = false;
$errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled.";
}
$oauth->save();
// Update the array with fresh data
$this->oauth_settings_map[$oauth->provider] = [
'id' => $oauth->id,
'provider' => $oauth->provider,
'enabled' => $oauth->enabled,
'client_id' => $oauth->client_id,
'client_secret' => $oauth->client_secret,
'redirect_uri' => $oauth->redirect_uri,
'tenant' => $oauth->tenant,
'base_url' => $oauth->base_url,
];
}
if (! empty($errors)) {
$this->dispatch('error', implode('<br/>', $errors));
}
}
}
+52 -48
View File
@@ -47,19 +47,19 @@ class Change extends Component
public int $customPort;
public int $appId;
public ?int $appId = null;
public int $installationId;
public ?int $installationId = null;
public string $clientId;
public ?string $clientId = null;
public string $clientSecret;
public ?string $clientSecret = null;
public string $webhookSecret;
public ?string $webhookSecret = null;
public bool $isSystemWide;
public int $privateKeyId;
public ?int $privateKeyId = null;
public ?string $contents = null;
@@ -78,16 +78,16 @@ class Change extends Component
'htmlUrl' => 'required|string',
'customUser' => 'required|string',
'customPort' => 'required|int',
'appId' => 'required|int',
'installationId' => 'required|int',
'clientId' => 'required|string',
'clientSecret' => 'required|string',
'webhookSecret' => 'required|string',
'appId' => 'nullable|int',
'installationId' => 'nullable|int',
'clientId' => 'nullable|string',
'clientSecret' => 'nullable|string',
'webhookSecret' => 'nullable|string',
'isSystemWide' => 'required|bool',
'contents' => 'nullable|string',
'metadata' => 'nullable|string',
'pullRequests' => 'nullable|string',
'privateKeyId' => 'required|int',
'privateKeyId' => 'nullable|int',
];
public function boot()
@@ -148,47 +148,48 @@ class Change extends Component
try {
$this->authorize('view', $this->github_app);
// Validate required fields before attempting to fetch permissions
$missingFields = [];
if (! $this->github_app->app_id) {
$missingFields[] = 'App ID';
}
if (! $this->github_app->private_key_id) {
$missingFields[] = 'Private Key';
}
if (! empty($missingFields)) {
$fieldsList = implode(', ', $missingFields);
$this->dispatch('error', "Cannot fetch permissions. Please set the following required fields first: {$fieldsList}");
return;
}
// Verify the private key exists and is accessible
if (! $this->github_app->privateKey) {
$this->dispatch('error', 'Private Key not found. Please select a valid private key.');
return;
}
GithubAppPermissionJob::dispatchSync($this->github_app);
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->dispatch('success', 'Github App permissions updated.');
} catch (\Throwable $e) {
// Provide better error message for unsupported key formats
$errorMessage = $e->getMessage();
if (str_contains($errorMessage, 'DECODER routines::unsupported') ||
str_contains($errorMessage, 'parse your key')) {
$this->dispatch('error', 'The selected private key format is not supported for GitHub Apps. <br><br>Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY). <br><br>OpenSSH format keys (BEGIN OPENSSH PRIVATE KEY) are not supported.');
return;
}
return handleError($e, $this);
}
}
// public function check()
// {
// Need administration:read:write permission
// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#list-self-hosted-runners-for-a-repository
// $github_access_token = generateGithubInstallationToken($this->github_app);
// $repositories = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/installation/repositories?per_page=100");
// $runners_by_repository = collect([]);
// $repositories = $repositories->json()['repositories'];
// foreach ($repositories as $repository) {
// $runners_downloads = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/downloads");
// $runners = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners");
// $token = Http::withHeaders([
// 'Authorization' => "Bearer $github_access_token",
// 'Accept' => 'application/vnd.github+json'
// ])->withBody(null)->post("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/registration-token");
// $token = $token->json();
// $remove_token = Http::withHeaders([
// 'Authorization' => "Bearer $github_access_token",
// 'Accept' => 'application/vnd.github+json'
// ])->withBody(null)->post("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/remove-token");
// $remove_token = $remove_token->json();
// $runners_by_repository->put($repository['full_name'], [
// 'token' => $token,
// 'remove_token' => $remove_token,
// 'runners' => $runners->json(),
// 'runners_downloads' => $runners_downloads->json()
// ]);
// }
// }
public function mount()
{
try {
@@ -340,10 +341,13 @@ class Change extends Component
$this->authorize('update', $this->github_app);
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->github_app->app_id = '1234567890';
$this->github_app->installation_id = '1234567890';
$this->github_app->app_id = 1234567890;
$this->github_app->installation_id = 1234567890;
$this->github_app->save();
$this->dispatch('success', 'Github App updated.');
// Redirect to avoid Livewire morphing issues when view structure changes
return redirect()->route('source.github.show', ['github_app_uuid' => $this->github_app->uuid])
->with('success', 'Github App updated. You can now configure the details.');
}
public function instantSave()
+1 -3
View File
@@ -50,11 +50,9 @@ class Create extends Component
'html_url' => $this->html_url,
'custom_user' => $this->custom_user,
'custom_port' => $this->custom_port,
'is_system_wide' => $this->is_system_wide,
'team_id' => currentTeam()->id,
];
if (isCloud()) {
$payload['is_system_wide'] = $this->is_system_wide;
}
$github_app = GithubApp::create($payload);
if (session('from')) {
session(['from' => session('from') + ['source_id' => $github_app->id]]);
+78 -2
View File
@@ -120,8 +120,9 @@ class Application extends BaseModel
protected $appends = ['server_status'];
protected $casts = [
'custom_network_aliases' => 'array',
'http_basic_auth_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
protected static function booted()
@@ -175,6 +176,39 @@ class Application extends BaseModel
if (count($payload) > 0) {
$application->forceFill($payload);
}
// Buildpack switching cleanup logic
if ($application->isDirty('build_pack')) {
$originalBuildPack = $application->getOriginal('build_pack');
// Clear Docker Compose specific data when switching away from dockercompose
if ($originalBuildPack === 'dockercompose') {
$application->docker_compose_domains = null;
$application->docker_compose_raw = null;
// Remove SERVICE_FQDN_* and SERVICE_URL_* environment variables
$application->environment_variables()
->where(function ($q) {
$q->where('key', 'LIKE', 'SERVICE_FQDN_%')
->orWhere('key', 'LIKE', 'SERVICE_URL_%');
})
->delete();
$application->environment_variables_preview()
->where(function ($q) {
$q->where('key', 'LIKE', 'SERVICE_FQDN_%')
->orWhere('key', 'LIKE', 'SERVICE_URL_%');
})
->delete();
}
// Clear Dockerfile specific data when switching away from dockerfile
if ($originalBuildPack === 'dockerfile') {
$application->dockerfile = null;
$application->dockerfile_location = null;
$application->dockerfile_target_build = null;
$application->custom_healthcheck_found = false;
}
}
});
static::created(function ($application) {
ApplicationSetting::create([
@@ -253,6 +287,30 @@ class Application extends BaseModel
return null;
}
if (is_string($value) && $this->isJson($value)) {
$decoded = json_decode($value, true);
// Return as comma-separated string, not array
return is_array($decoded) ? implode(',', $decoded) : $value;
}
return $value;
}
);
}
/**
* Get custom_network_aliases as an array
*/
public function customNetworkAliasesArray(): Attribute
{
return Attribute::make(
get: function () {
$value = $this->getRawOriginal('custom_network_aliases');
if (is_null($value)) {
return null;
}
if (is_string($value) && $this->isJson($value)) {
return json_decode($value, true);
}
@@ -749,6 +807,24 @@ class Application extends BaseModel
return $this->settings->is_static ? [80] : $this->ports_exposes_array;
}
public function detectPortFromEnvironment(?bool $isPreview = false): ?int
{
$envVars = $isPreview
? $this->environment_variables_preview
: $this->environment_variables;
$portVar = $envVars->firstWhere('key', 'PORT');
if ($portVar && $portVar->real_value) {
$portValue = trim($portVar->real_value);
if (is_numeric($portValue)) {
return (int) $portValue;
}
}
return null;
}
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
@@ -957,7 +1033,7 @@ class Application extends BaseModel
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels.$this->settings->use_build_secrets);
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
} else {
+7 -1
View File
@@ -7,8 +7,14 @@ use Illuminate\Database\Eloquent\Model;
class ApplicationSetting extends Model
{
protected $cast = [
protected $casts = [
'is_static' => 'boolean',
'is_spa' => 'boolean',
'is_build_server_enabled' => 'boolean',
'is_preserve_repository_enabled' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
'is_container_label_readonly_enabled' => 'boolean',
'use_build_secrets' => 'boolean',
'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean',
'is_debug_enabled' => 'boolean',
+15 -1
View File
@@ -12,6 +12,7 @@ class GithubApp extends BaseModel
protected $casts = [
'is_public' => 'boolean',
'is_system_wide' => 'boolean',
'type' => 'string',
];
@@ -27,7 +28,20 @@ class GithubApp extends BaseModel
if ($applications_count > 0) {
throw new \Exception('You cannot delete this GitHub App because it is in use by '.$applications_count.' application(s). Delete them first.');
}
$github_app->privateKey()->delete();
$privateKey = $github_app->privateKey;
if ($privateKey) {
// Check if key is used by anything EXCEPT this GitHub app
$isUsedElsewhere = $privateKey->servers()->exists()
|| $privateKey->applications()->exists()
|| $privateKey->githubApps()->where('id', '!=', $github_app->id)->exists()
|| $privateKey->gitlabApps()->exists();
if (! $isUsedElsewhere) {
$privateKey->delete();
} else {
}
}
});
}
-9
View File
@@ -2,7 +2,6 @@
namespace App\Models;
use App\Jobs\PullHelperImageJob;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Spatie\Url\Url;
@@ -35,14 +34,6 @@ class InstanceSettings extends Model
protected static function booted(): void
{
static::updated(function ($settings) {
if ($settings->wasChanged('helper_version')) {
Server::chunkById(100, function ($servers) {
foreach ($servers as $server) {
PullHelperImageJob::dispatch($server);
}
});
}
// Clear trusted hosts cache when FQDN changes
if ($settings->wasChanged('fqdn')) {
\Cache::forget('instance_settings_fqdn_host');
+8
View File
@@ -12,6 +12,14 @@ class ScheduledTask extends BaseModel
protected $guarded = [];
protected function casts(): array
{
return [
'enabled' => 'boolean',
'timeout' => 'integer',
];
}
public function service()
{
return $this->belongsTo(Service::class);
+10
View File
@@ -8,6 +8,16 @@ class ScheduledTaskExecution extends BaseModel
{
protected $guarded = [];
protected function casts(): array
{
return [
'started_at' => 'datetime',
'finished_at' => 'datetime',
'retry_count' => 'integer',
'duration' => 'decimal:2',
];
}
public function scheduledTask(): BelongsTo
{
return $this->belongsTo(ScheduledTask::class);
+30
View File
@@ -1184,6 +1184,31 @@ class Service extends BaseModel
return data_get($service, 'documentation', config('constants.urls.docs'));
}
/**
* Get the required port for this service from the template definition.
*/
public function getRequiredPort(): ?int
{
try {
$services = get_service_templates();
$serviceName = str($this->name)->beforeLast('-')->value();
$service = data_get($services, $serviceName, []);
$port = data_get($service, 'port');
return $port ? (int) $port : null;
} catch (\Throwable) {
return null;
}
}
/**
* Check if this service requires a port to function correctly.
*/
public function requiresPort(): bool
{
return $this->getRequiredPort() !== null;
}
public function applications()
{
return $this->hasMany(ServiceApplication::class);
@@ -1262,6 +1287,11 @@ class Service extends BaseModel
public function saveComposeConfigs()
{
// Guard against null or empty docker_compose
if (! $this->docker_compose) {
return;
}
$workdir = $this->workdir();
instant_remote_process([
+125
View File
@@ -109,6 +109,11 @@ class ServiceApplication extends BaseModel
return $this->morphMany(LocalFileVolume::class, 'resource');
}
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
public function fqdns(): Attribute
{
return Attribute::make(
@@ -118,6 +123,53 @@ class ServiceApplication extends BaseModel
);
}
/**
* Extract port number from a given FQDN URL.
* Returns null if no port is specified.
*/
public static function extractPortFromUrl(string $url): ?int
{
try {
// Ensure URL has a scheme for proper parsing
if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) {
$url = 'http://'.$url;
}
$parsed = parse_url($url);
$port = $parsed['port'] ?? null;
return $port ? (int) $port : null;
} catch (\Throwable) {
return null;
}
}
/**
* Check if all FQDNs have a port specified.
*/
public function allFqdnsHavePort(): bool
{
if (is_null($this->fqdn) || $this->fqdn === '') {
return false;
}
$fqdns = explode(',', $this->fqdn);
foreach ($fqdns as $fqdn) {
$fqdn = trim($fqdn);
if (empty($fqdn)) {
continue;
}
$port = self::extractPortFromUrl($fqdn);
if ($port === null) {
return false;
}
}
return true;
}
public function getFilesFromServer(bool $isInit = false)
{
getFilesystemVolumesFromServer($this, $isInit);
@@ -127,4 +179,77 @@ class ServiceApplication extends BaseModel
{
return false;
}
/**
* Get the required port for this service application.
* Extracts port from SERVICE_URL_* or SERVICE_FQDN_* environment variables
* stored at the Service level, filtering by normalized container name.
* Falls back to service-level port if no port-specific variable is found.
*/
public function getRequiredPort(): ?int
{
try {
// Normalize container name same way as variable creation
// (uppercase, replace - and . with _)
$normalizedName = str($this->name)
->upper()
->replace('-', '_')
->replace('.', '_')
->value();
// Get all environment variables from the service
$serviceEnvVars = $this->service->environment_variables()->get();
// Look for SERVICE_FQDN_* or SERVICE_URL_* variables that match this container
foreach ($serviceEnvVars as $envVar) {
$key = str($envVar->key);
// Check if this is a SERVICE_FQDN_* or SERVICE_URL_* variable
if (! $key->startsWith('SERVICE_FQDN_') && ! $key->startsWith('SERVICE_URL_')) {
continue;
}
// Extract the part after SERVICE_FQDN_ or SERVICE_URL_
if ($key->startsWith('SERVICE_FQDN_')) {
$suffix = $key->after('SERVICE_FQDN_');
} else {
$suffix = $key->after('SERVICE_URL_');
}
// Check if this variable starts with our normalized container name
// Format: {NORMALIZED_NAME}_{PORT} or just {NORMALIZED_NAME}
if (! $suffix->startsWith($normalizedName)) {
\Log::debug('[ServiceApplication::getRequiredPort] Suffix does not match container', [
'expected_start' => $normalizedName,
'actual_suffix' => $suffix->value(),
]);
continue;
}
// Check if there's a port suffix after the container name
// The suffix should be exactly NORMALIZED_NAME or NORMALIZED_NAME_PORT
$afterName = $suffix->after($normalizedName)->value();
// If there's content after the name, it should start with underscore
if ($afterName !== '' && str($afterName)->startsWith('_')) {
// Extract port: _3210 -> 3210
$port = str($afterName)->after('_')->value();
// Validate that the extracted port is numeric
if (is_numeric($port)) {
\Log::debug('[ServiceApplication::getRequiredPort] MATCH FOUND - Returning port', [
'port' => (int) $port,
]);
return (int) $port;
}
}
}
// Fall back to service-level port if no port-specific variable is found
$fallbackPort = $this->service->getRequiredPort();
return $fallbackPort;
} catch (\Throwable $e) {
return null;
}
}
}
+4
View File
@@ -84,6 +84,10 @@ class ServiceDatabase extends BaseModel
$image = str($this->image)->before(':');
if ($image->contains('supabase/postgres')) {
$finalImage = 'supabase/postgres';
} elseif ($image->contains('timescale')) {
$finalImage = 'postgresql';
} elseif ($image->contains('pgvector')) {
$finalImage = 'postgresql';
} elseif ($image->contains('postgres') || $image->contains('postgis')) {
$finalImage = 'postgresql';
} else {
+5 -1
View File
@@ -243,10 +243,14 @@ class StandaloneClickhouse extends BaseModel
return new Attribute(
get: function () {
if ($this->is_public && $this->public_port) {
$serverIp = $this->destination->server->getIp;
if (empty($serverIp)) {
return null;
}
$encodedUser = rawurlencode($this->clickhouse_admin_user);
$encodedPass = rawurlencode($this->clickhouse_admin_password);
return "clickhouse://{$encodedUser}:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/{$this->clickhouse_db}";
return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->clickhouse_db}";
}
return null;
+5 -1
View File
@@ -249,9 +249,13 @@ class StandaloneDragonfly extends BaseModel
return new Attribute(
get: function () {
if ($this->is_public && $this->public_port) {
$serverIp = $this->destination->server->getIp;
if (empty($serverIp)) {
return null;
}
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$encodedPass = rawurlencode($this->dragonfly_password);
$url = "{$scheme}://:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/0";
$url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
$url .= '?cacert=/etc/ssl/certs/coolify-ca.crt';
+5 -1
View File
@@ -249,9 +249,13 @@ class StandaloneKeydb extends BaseModel
return new Attribute(
get: function () {
if ($this->is_public && $this->public_port) {
$serverIp = $this->destination->server->getIp;
if (empty($serverIp)) {
return null;
}
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$encodedPass = rawurlencode($this->keydb_password);
$url = "{$scheme}://:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/0";
$url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
$url .= '?cacert=/etc/ssl/certs/coolify-ca.crt';
+5 -1
View File
@@ -239,10 +239,14 @@ class StandaloneMariadb extends BaseModel
return new Attribute(
get: function () {
if ($this->is_public && $this->public_port) {
$serverIp = $this->destination->server->getIp;
if (empty($serverIp)) {
return null;
}
$encodedUser = rawurlencode($this->mariadb_user);
$encodedPass = rawurlencode($this->mariadb_password);
return "mysql://{$encodedUser}:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/{$this->mariadb_database}";
return "mysql://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->mariadb_database}";
}
return null;
+5 -1
View File
@@ -269,9 +269,13 @@ class StandaloneMongodb extends BaseModel
return new Attribute(
get: function () {
if ($this->is_public && $this->public_port) {
$serverIp = $this->destination->server->getIp;
if (empty($serverIp)) {
return null;
}
$encodedUser = rawurlencode($this->mongo_initdb_root_username);
$encodedPass = rawurlencode($this->mongo_initdb_root_password);
$url = "mongodb://{$encodedUser}:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/?directConnection=true";
$url = "mongodb://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/?directConnection=true";
if ($this->enable_ssl) {
$url .= '&tls=true&tlsCAFile=/etc/mongo/certs/ca.pem';
if (in_array($this->ssl_mode, ['verify-full'])) {
+5 -1
View File
@@ -251,9 +251,13 @@ class StandaloneMysql extends BaseModel
return new Attribute(
get: function () {
if ($this->is_public && $this->public_port) {
$serverIp = $this->destination->server->getIp;
if (empty($serverIp)) {
return null;
}
$encodedUser = rawurlencode($this->mysql_user);
$encodedPass = rawurlencode($this->mysql_password);
$url = "mysql://{$encodedUser}:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/{$this->mysql_database}";
$url = "mysql://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->mysql_database}";
if ($this->enable_ssl) {
$url .= "?ssl-mode={$this->ssl_mode}";
if (in_array($this->ssl_mode, ['VERIFY_CA', 'VERIFY_IDENTITY'])) {
+5 -1
View File
@@ -246,9 +246,13 @@ class StandalonePostgresql extends BaseModel
return new Attribute(
get: function () {
if ($this->is_public && $this->public_port) {
$serverIp = $this->destination->server->getIp;
if (empty($serverIp)) {
return null;
}
$encodedUser = rawurlencode($this->postgres_user);
$encodedPass = rawurlencode($this->postgres_password);
$url = "postgres://{$encodedUser}:{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/{$this->postgres_db}";
$url = "postgres://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->postgres_db}";
if ($this->enable_ssl) {
$url .= "?sslmode={$this->ssl_mode}";
if (in_array($this->ssl_mode, ['verify-ca', 'verify-full'])) {
+5 -1
View File
@@ -253,11 +253,15 @@ class StandaloneRedis extends BaseModel
return new Attribute(
get: function () {
if ($this->is_public && $this->public_port) {
$serverIp = $this->destination->server->getIp;
if (empty($serverIp)) {
return null;
}
$redis_version = $this->getRedisVersion();
$username_part = version_compare($redis_version, '6.0', '>=') ? rawurlencode($this->redis_username).':' : '';
$encodedPass = rawurlencode($this->redis_password);
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$url = "{$scheme}://{$username_part}{$encodedPass}@{$this->destination->server->getIp}:{$this->public_port}/0";
$url = "{$scheme}://{$username_part}{$encodedPass}@{$serverIp}:{$this->public_port}/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
$url .= '?cacert=/etc/ssl/certs/coolify-ca.crt';
@@ -101,6 +101,38 @@ class EmailChannel
$mailer->send($email);
}
} catch (\Resend\Exceptions\ErrorException $e) {
// Map HTTP status codes to user-friendly messages
$userMessage = match ($e->getErrorCode()) {
403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.',
401 => 'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.',
429 => 'Resend rate limit exceeded. Please try again in a few minutes.',
400 => 'Email validation failed: '.$e->getErrorMessage(),
default => 'Failed to send email via Resend: '.$e->getErrorMessage(),
};
// Log detailed error for admin debugging (redact sensitive data)
$emailSettings = $notifiable->emailNotificationSettings ?? instanceSettings();
data_set($emailSettings, 'smtp_password', '********');
data_set($emailSettings, 'resend_api_key', '********');
send_internal_notification(sprintf(
"Resend Error\nStatus Code: %s\nMessage: %s\nNotification: %s\nEmail Settings:\n%s",
$e->getErrorCode(),
$e->getErrorMessage(),
get_class($notification),
json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
));
// Don't report expected errors (invalid keys, validation) to Sentry
if (in_array($e->getErrorCode(), [403, 401, 400])) {
throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e));
}
throw new \Exception($userMessage, $e->getCode(), $e);
} catch (\Resend\Exceptions\TransporterException $e) {
send_internal_notification("Resend Transport Error: {$e->getMessage()}");
throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.');
} catch (\Throwable $e) {
// Check if this is a Resend domain verification error on cloud instances
if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) {
@@ -16,10 +16,7 @@ class ServerPatchCheck extends CustomEmailNotification
public function __construct(public Server $server, public array $patchData)
{
$this->onQueue('high');
$this->serverUrl = route('server.security.patches', ['server_uuid' => $this->server->uuid]);
if (isDev()) {
$this->serverUrl = 'https://staging-but-dev.coolify.io/server/'.$this->server->uuid.'/security/patches';
}
$this->serverUrl = base_url().'/server/'.$this->server->uuid.'/security/patches';
}
public function via(object $notifiable): array
+8 -2
View File
@@ -127,13 +127,19 @@ class FortifyServiceProvider extends ServiceProvider
});
RateLimiter::for('forgot-password', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
// Use real client IP (not spoofable forwarded headers)
$realIp = $request->server('REMOTE_ADDR') ?? $request->ip();
return Limit::perMinute(5)->by($realIp);
});
RateLimiter::for('login', function (Request $request) {
$email = (string) $request->email;
// Use email + real client IP (not spoofable forwarded headers)
// server('REMOTE_ADDR') gives the actual connecting IP before proxy headers
$realIp = $request->server('REMOTE_ADDR') ?? $request->ip();
return Limit::perMinute(5)->by($email.$request->ip());
return Limit::perMinute(5)->by($email.'|'.$realIp);
});
RateLimiter::for('two-factor', function (Request $request) {
+14 -1
View File
@@ -219,9 +219,22 @@ trait ExecuteRemoteCommand
$process_result = $process->wait();
if ($process_result->exitCode() !== 0) {
if (! $ignore_errors) {
// Check if deployment was cancelled while command was running
if (isset($this->application_deployment_queue)) {
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
throw new \RuntimeException('Deployment cancelled by user', 69420);
}
}
// Don't immediately set to FAILED - let the retry logic handle it
// This prevents premature status changes during retryable SSH errors
throw new \RuntimeException($process_result->errorOutput());
$error = $process_result->errorOutput();
if (empty($error)) {
$error = $process_result->output() ?: 'Command failed with no error output';
}
$redactedCommand = $this->redact_sensitive_info($command);
throw new \RuntimeException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}");
}
}
}
+1
View File
@@ -97,6 +97,7 @@ function sharedDataApplications()
'start_command' => 'string|nullable',
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
'custom_network_aliases' => 'string|nullable',
'base_directory' => 'string|nullable',
'publish_directory' => 'string|nullable',
'health_check_enabled' => 'boolean',
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more