Merge branch 'next' into ts-template

This commit is contained in:
majcek210 2025-11-08 15:47:45 +01:00 committed by GitHub
commit 6effccd1fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
204 changed files with 10343 additions and 2614 deletions

2
.coderabbit.yaml Normal file
View file

@ -0,0 +1,2 @@
reviews:
review_status: false

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

View file

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

View file

@ -4,6 +4,10 @@ on:
schedule:
- cron: '0 2 * * *'
permissions:
issues: write
pull-requests: write
jobs:
manage-stale:
runs-on: ubuntu-latest

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:

View file

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

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]')

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

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']

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

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

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

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

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

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

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
.gitignore vendored
View file

@ -37,3 +37,4 @@ scripts/load-test/*
docker/coolify-realtime/node_modules
.DS_Store
CHANGELOG.md
/.workspaces

View file

@ -4,80 +4,164 @@ # Changelog
## [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 @@ ### 🚀 Features
- 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 @@ ### 🐛 Bug Fixes
- 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 @@ ### 💼 Other
- 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 @@ ### 🚜 Refactor
- 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 @@ ### 🎨 Styling
### 🧪 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

View file

@ -146,6 +146,7 @@ ### Livewire Component Structure
- 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/`)

View file

@ -66,10 +66,9 @@ ## Big Sponsors
* [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

View file

@ -105,7 +105,7 @@ public function handle(StandaloneClickhouse $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker 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.'";

View file

@ -192,7 +192,7 @@ public function handle(StandaloneDragonfly $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker 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.'";

View file

@ -208,7 +208,7 @@ public function handle(StandaloneKeydb $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker 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.'";

View file

@ -209,7 +209,7 @@ public function handle(StandaloneMariadb $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker 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.'";

View file

@ -260,7 +260,7 @@ public function handle(StandaloneMongodb $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker 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) {

View file

@ -210,7 +210,7 @@ public function handle(StandaloneMysql $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker 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";

View file

@ -223,7 +223,7 @@ public function handle(StandalonePostgresql $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker 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 @@ public function handle(StandalonePostgresql $database)
}
$this->commands[] = "echo 'Database started.'";
ray($this->commands);
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}

View file

@ -205,7 +205,7 @@ public function handle(StandaloneRedis $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker 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.'";

View file

@ -49,7 +49,7 @@ private function stopContainer($database, string $containerName, int $timeout =
{
$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);
}

View file

@ -20,7 +20,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
$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';

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 @@ public function handle(Server $server)
}
$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 @@ public function handle(Server $server)
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}";
}
}

View file

@ -7,9 +7,9 @@
class CleanupRedis extends Command
{
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup}';
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 $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, and related data)';
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)';
public function handle()
{
@ -56,6 +56,13 @@ public function handle()
$deletedCount += $overlappingCleaned;
}
// Clean up stale cache locks (WithoutOverlapping middleware)
if ($this->option('clear-locks')) {
$this->info('Cleaning up stale cache locks...');
$locksCleaned = $this->cleanupCacheLocks($dryRun);
$deletedCount += $locksCleaned;
}
if ($dryRun) {
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
} else {
@ -273,4 +280,56 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun)
return $cleanedCount;
}
private function cleanupCacheLocks(bool $dryRun): int
{
$cleanedCount = 0;
// Use the default Redis connection (database 0) where cache locks are stored
$redis = Redis::connection('default');
// Get all keys matching WithoutOverlapping lock pattern
$allKeys = $redis->keys('*');
$lockKeys = [];
foreach ($allKeys as $key) {
// Match cache lock keys: they contain 'laravel-queue-overlap'
if (preg_match('/overlap/i', $key)) {
$lockKeys[] = $key;
}
}
if (empty($lockKeys)) {
$this->info(' No cache locks found.');
return 0;
}
$this->info(' Found '.count($lockKeys).' cache lock(s)');
foreach ($lockKeys as $lockKey) {
// Check TTL to identify stale locks
$ttl = $redis->ttl($lockKey);
// TTL = -1 means no expiration (stale lock!)
// TTL = -2 means key doesn't exist
// TTL > 0 means lock is valid and will expire
if ($ttl === -1) {
if ($dryRun) {
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
} else {
$redis->del($lockKey);
$this->info(" ✓ Deleted STALE lock: {$lockKey}");
}
$cleanedCount++;
} elseif ($ttl > 0) {
$this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}");
}
}
if ($cleanedCount === 0) {
$this->info(' No stale locks found (all locks have expiration set)');
}
return $cleanedCount;
}
}

View file

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

View file

@ -73,7 +73,7 @@ public function handle()
$this->cleanupUnusedNetworkFromCoolifyProxy();
try {
$this->call('cleanup:redis');
$this->call('cleanup:redis', ['--clear-locks' => true]);
} catch (\Throwable $e) {
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
}

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 @@ private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny
'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 @@ public function handle()
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 @@ public function handle()
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;
}

View file

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

View file

@ -1893,7 +1893,6 @@ public function logs_by_uuid(Request $request)
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 @@ public function delete_by_uuid(Request $request)
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 @@ public function action_deploy(Request $request)
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);

View file

@ -1619,6 +1619,18 @@ public function create_database(Request $request, NewDatabaseTypes $type)
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 @@ public function create_database(Request $request, NewDatabaseTypes $type)
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 @@ public function delete_by_uuid(Request $request)
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 @@ public function delete_backup_by_uuid(Request $request)
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 @@ public function delete_execution_by_uuid(Request $request)
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) {

View file

@ -649,10 +649,10 @@ public function delete_by_uuid(Request $request)
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([

View file

@ -459,7 +459,7 @@ private function decide_what_to_do()
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);
@ -517,6 +517,10 @@ private function deploy_dockerimage_buildpack()
$this->generate_image_names();
$this->prepare_builder_image();
$this->generate_compose_file();
// Save runtime environment variables (including empty .env file if no variables defined)
$this->save_runtime_environment_variables();
$this->rolling_update();
}
@ -1004,7 +1008,7 @@ private function just_restart()
$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()
@ -1222,9 +1226,9 @@ private function save_runtime_environment_variables()
// Handle empty environment variables
if ($environment_variables->isEmpty()) {
// For Docker Compose, we need to create an empty .env file
// For Docker Compose and Docker Image, we need to create an empty .env file
// because we always reference it in the compose file
if ($this->build_pack === 'dockercompose') {
if ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerimage') {
$this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).');
// Create empty .env file
@ -1628,7 +1632,7 @@ private function health_check()
return;
}
if ($this->application->custom_healthcheck_found) {
$this->application_deployment_queue->addLogEntry('Custom healthcheck found, skipping default healthcheck.');
$this->application_deployment_queue->addLogEntry('Custom healthcheck found in Dockerfile.');
}
if ($this->container_name) {
$counter = 1;
@ -1776,9 +1780,8 @@ private function create_workdir()
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);
@ -2318,8 +2321,8 @@ private function generate_compose_file()
$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' => [
@ -2354,16 +2357,22 @@ private function generate_compose_file()
];
// Always use .env file
$docker_compose['services'][$this->container_name]['env_file'] = ['.env'];
$docker_compose['services'][$this->container_name]['healthcheck'] = [
'test' => [
'CMD-SHELL',
$this->generate_healthcheck_commands(),
],
'interval' => $this->application->health_check_interval.'s',
'timeout' => $this->application->health_check_timeout.'s',
'retries' => $this->application->health_check_retries,
'start_period' => $this->application->health_check_start_period.'s',
];
// Only add Coolify healthcheck if no custom HEALTHCHECK found in Dockerfile
// If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used
// If healthcheck is disabled, no healthcheck will be added
if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) {
$docker_compose['services'][$this->container_name]['healthcheck'] = [
'test' => [
'CMD-SHELL',
$this->generate_healthcheck_commands(),
],
'interval' => $this->application->health_check_interval.'s',
'timeout' => $this->application->health_check_timeout.'s',
'retries' => $this->application->health_check_retries,
'start_period' => $this->application->health_check_start_period.'s',
];
}
if (! is_null($this->application->limits_cpuset)) {
data_set($docker_compose, 'services.'.$this->container_name.'.cpuset', $this->application->limits_cpuset);
@ -3013,9 +3022,7 @@ private function stop_running_container(bool $force = false)
$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->failDeployment();
$this->graceful_shutdown_container($this->container_name);
}
}
@ -3219,6 +3226,20 @@ private function generate_secrets_hash($variables)
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) {
@ -3231,6 +3252,18 @@ private function add_build_env_variables_to_dockerfile()
'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()
@ -3239,9 +3272,9 @@ private function add_build_env_variables_to_dockerfile()
->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
@ -3251,9 +3284,7 @@ private function add_build_env_variables_to_dockerfile()
->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
@ -3263,9 +3294,9 @@ private function add_build_env_variables_to_dockerfile()
->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
@ -3275,15 +3306,23 @@ private function add_build_env_variables_to_dockerfile()
->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"));
@ -3649,42 +3688,116 @@ private function checkForCancellation(): void
}
}
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;
}
$this->updateDeploymentStatus($status);
$this->handleStatusTransition($status);
queue_next_deployment($this->application);
}
/**
* 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->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
return true;
}
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);
}
return false;
}
/**
* Update the deployment status in the database.
*/
private function updateDeploymentStatus(ApplicationDeploymentStatus $status): void
{
$this->application_deployment_queue->update([
'status' => $status,
'status' => $status->value,
]);
}
queue_next_deployment($this->application);
/**
* 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,
};
}
if ($status === ApplicationDeploymentStatus::FINISHED->value) {
event(new ApplicationConfigurationChanged($this->application->team()->id));
/**
* 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->application->environment->project->team?->notify(new DeploymentSuccess($this->application, $this->deployment_uuid, $this->preview));
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.
*/
private function failDeployment(): void
{
$this->transitionToStatus(ApplicationDeploymentStatus::FAILED);
}
public function failed(Throwable $exception): void
{
$this->next(ApplicationDeploymentStatus::FAILED->value);
$this->failDeployment();
$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');

View file

@ -653,9 +653,8 @@ private function upload_to_s3(): void
private function getFullImageName(): string
{
$settings = instanceSettings();
$helperImage = config('constants.coolify.helper_image');
$latestVersion = $settings->helper_version;
$latestVersion = getHelperVersion();
return "{$helperImage}:{$latestVersion}";
}

View file

@ -24,7 +24,7 @@ public function __construct(public Server $server)
public function handle(): void
{
$helperImage = config('constants.coolify.helper_image');
$latest_version = instanceSettings()->helper_version;
$latest_version = getHelperVersion();
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
}
}

View file

@ -52,7 +52,8 @@ public function middleware(): array
{
return [
(new WithoutOverlapping('scheduled-job-manager'))
->releaseAfter(60), // Release the lock after 60 seconds if job fails
->expireAfter(60) // Lock expires after 1 minute to prevent stale locks
->dontRelease(), // Don't re-queue on lock conflict
];
}

View file

@ -107,7 +107,7 @@ public function mount()
if ($this->selectedServerType === 'remote') {
if ($this->privateKeys->isEmpty()) {
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
$this->privateKeys = PrivateKey::ownedAndOnlySShKeys(['name'])->where('id', '!=', 0)->get();
}
if ($this->servers->isEmpty()) {
$this->servers = Server::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
@ -186,7 +186,7 @@ public function setServerType(string $type)
return $this->validateServer('localhost');
} elseif ($this->selectedServerType === 'remote') {
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
$this->privateKeys = PrivateKey::ownedAndOnlySShKeys(['name'])->where('id', '!=', 0)->get();
// Auto-select first key if available for better UX
if ($this->privateKeys->count() > 0) {
$this->selectedExistingPrivateKey = $this->privateKeys->first()->id;

View file

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

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 @@
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 @@ protected function rules(): array
'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 @@ protected function messages(): array
'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 @@ protected function messages(): array
}
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 @@ public function mount()
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 @@ public function mount()
// 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 @@ public function instantSave()
$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 @@ public function loadComposeFile($isInit = false, $showToast = true)
// 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 @@ public function generateDomain(string $serviceName)
public function updatedBaseDirectory()
{
if ($this->build_pack === 'dockercompose') {
if ($this->buildPack === 'dockercompose') {
$this->loadComposeFile();
}
}
@ -522,24 +628,24 @@ public function updatedBuildPack()
} 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->portsExposes = '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);
@ -562,9 +668,9 @@ public function updatedBuildPack()
$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 +687,10 @@ public function getWildcardDomain()
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 +704,11 @@ public function generateNginxConfiguration($type = 'static')
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 +718,15 @@ public function generateNginxConfiguration($type = 'static')
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 +822,7 @@ public function submit($showToaster = true)
$this->dispatch('warning', __('warning.sslipdomain'));
}
$this->syncToModel();
$this->syncData(toModel: true);
if ($this->application->isDirty('redirect')) {
$this->setRedirect();
@ -737,42 +842,42 @@ public function submit($showToaster = true)
$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 +909,11 @@ public function submit($showToaster = true)
$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 {

View file

@ -58,6 +58,11 @@ public function checkStatus()
}
}
public function manualCheckStatus()
{
$this->checkStatus();
}
public function force_deploy_without_cache()
{
$this->authorize('deploy', $this->application);

View file

@ -62,6 +62,11 @@ public function checkStatus()
}
}
public function manualCheckStatus()
{
$this->checkStatus();
}
public function mount()
{
$this->parameters = get_route_parameters();

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 @@ public function render()
{
return view('livewire.project.index');
}
public function navigateToProject($projectUuid)
{
$project = collect($this->projects)->firstWhere('uuid', $projectUuid);
return $this->redirect($project->navigateTo(), navigate: false);
}
}

File diff suppressed because one or more lines are too long

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->service->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 @@ public function confirmDomainUsage()
$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 @@ public function submit()
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,45 @@ public function submit()
$this->forceSaveDomains = false;
}
// Check for required port
if (! $this->forceRemovePort) {
$service = $this->application->service;
$requiredPort = $service->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 +162,7 @@ public function submit()
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
$this->syncFromModel();
$this->syncData();
}
return handleError($e, $this);

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 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 @@ public function mount()
}
$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 @@ public function loadStorageOnServer()
$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 @@ public function submit()
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);
}

View file

@ -54,6 +54,11 @@ public function checkStatus()
}
}
public function manualCheckStatus()
{
$this->checkStatus();
}
public function serviceChecked()
{
try {

View file

@ -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 @@ public function instantSaveAdvanced()
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 @@ public function mount()
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
$this->syncFromModel();
$this->requiredPort = $this->application->service->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 @@ public function submit()
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,45 @@ public function submit()
$this->forceSaveDomains = false;
}
// Check for required port
if (! $this->forceRemovePort) {
$service = $this->application->service;
$requiredPort = $service->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 +316,7 @@ public function submit()
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
$this->syncFromModel();
$this->syncData();
}
return handleError($e, $this);

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;

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 @@ public function submit()
$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 @@ public function toggleHealthcheck()
$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()) {

View file

@ -30,6 +30,60 @@ public function loadTokens()
$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 {

View file

@ -74,12 +74,16 @@ class ByHetzner extends Component
#[Locked]
public Collection $saved_cloud_init_scripts;
public bool $from_onboarding = false;
public function mount()
{
$this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens();
$this->loadSavedCloudInitScripts();
$this->server_name = generate_random_name();
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id;
}
@ -131,7 +135,7 @@ public function handleTokenAdded($tokenId)
public function handlePrivateKeyCreated($keyId)
{
// Refresh private keys list
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
// Auto-select the new key
$this->private_key_id = $keyId;
@ -246,12 +250,6 @@ private function loadHetznerData(string $token)
// Get images and sort by name
$images = $hetznerService->getImages();
ray('Raw images from Hetzner API', [
'total_count' => count($images),
'types' => collect($images)->pluck('type')->unique()->values(),
'sample' => array_slice($images, 0, 3),
]);
$this->images = collect($images)
->filter(function ($image) {
// Only system images
@ -269,20 +267,8 @@ private function loadHetznerData(string $token)
->sortBy('name')
->values()
->toArray();
ray('Filtered images', [
'filtered_count' => count($this->images),
'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(),
]);
// Load SSH keys from Hetzner
$this->hetznerSshKeys = $hetznerService->getSshKeys();
ray('Hetzner SSH Keys', [
'total_count' => count($this->hetznerSshKeys),
'keys' => $this->hetznerSshKeys,
]);
$this->loading_data = false;
} catch (\Throwable $e) {
$this->loading_data = false;
@ -299,9 +285,9 @@ private function getCpuVendorInfo(array $serverType): ?string
} elseif (str_starts_with($name, 'cpx')) {
return 'AMD EPYC™';
} elseif (str_starts_with($name, 'cx')) {
return 'Intel® Xeon®';
return 'Intel®/AMD';
} elseif (str_starts_with($name, 'cax')) {
return 'Ampere® Altra®';
return 'Ampere®';
}
return null;
@ -574,6 +560,16 @@ public function submit()
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($this->from_onboarding) {
// Complete the boarding when server is successfully created via Hetzner
currentTeam()->update([
'show_boarding' => false,
]);
refreshSession();
return $this->redirect(route('server.show', $server->uuid));
}
return redirect()->route('server.show', $server->uuid);
} catch (\Throwable $e) {
return handleError($e, $this);

View file

@ -7,7 +7,6 @@
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 @@ protected function rules(): array
'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 @@ protected function messages(): array
'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 @@ public function submit()
'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 @@ public function submit()
$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();

View file

@ -85,19 +85,8 @@ public function submit()
// 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 @@ public function submit()
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 @@ public function submit()
}
$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);
}

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 @@ public function mount()
$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 @@ public function instantSave($isSave = true)
$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!');

View file

@ -29,7 +29,16 @@ public function mount()
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 @@ public function mount()
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));
}
}
}

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 @@ public function checkPermissions()
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 @@ public function createGithubAppManually()
$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()

View file

@ -50,11 +50,9 @@ public function createGitHubApp()
'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]]);

View file

@ -120,7 +120,6 @@ class Application extends BaseModel
protected $appends = ['server_status'];
protected $casts = [
'custom_network_aliases' => 'array',
'http_basic_auth_password' => 'encrypted',
];
@ -253,6 +252,30 @@ public function customNetworkAliases(): Attribute
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);
}
@ -957,7 +980,7 @@ public function isLogDrainEnabled()
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels.$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 {
@ -1804,7 +1827,22 @@ public function getFilesFromServer(bool $isInit = false)
public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false)
{
$dockerfile = str($dockerfile)->trim()->explode("\n");
if (str($dockerfile)->contains('HEALTHCHECK') && ($this->isHealthcheckDisabled() || $isInit)) {
$hasHealthcheck = str($dockerfile)->contains('HEALTHCHECK');
// Always check if healthcheck was removed, regardless of health_check_enabled setting
if (! $hasHealthcheck && $this->custom_healthcheck_found) {
// HEALTHCHECK was removed from Dockerfile, reset to defaults
$this->custom_healthcheck_found = false;
$this->health_check_interval = 5;
$this->health_check_timeout = 5;
$this->health_check_retries = 10;
$this->health_check_start_period = 5;
$this->save();
return;
}
if ($hasHealthcheck && ($this->isHealthcheckDisabled() || $isInit)) {
$healthcheckCommand = null;
$lines = $dockerfile->toArray();
foreach ($lines as $line) {

View file

@ -7,8 +7,14 @@
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',

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 @@ protected static function booted(): void
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 {
}
}
});
}

View file

@ -88,6 +88,16 @@ public static function ownedByCurrentTeam(array $select = ['*'])
return self::whereTeamId($teamId)->select($selectArray->all());
}
public static function ownedAndOnlySShKeys(array $select = ['*'])
{
$teamId = currentTeam()->id;
$selectArray = collect($select)->concat(['id']);
return self::whereTeamId($teamId)
->where('is_git_related', false)
->select($selectArray->all());
}
public static function validatePrivateKey($privateKey)
{
try {

View file

@ -1184,6 +1184,31 @@ public function documentation()
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);

View file

@ -118,6 +118,53 @@ public function fqdns(): Attribute
);
}
/**
* 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);

View file

@ -243,10 +243,14 @@ protected function externalDbUrl(): Attribute
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;

View file

@ -249,9 +249,13 @@ protected function externalDbUrl(): Attribute
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';

View file

@ -249,9 +249,13 @@ protected function externalDbUrl(): Attribute
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';

View file

@ -239,10 +239,14 @@ protected function externalDbUrl(): Attribute
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;

View file

@ -269,9 +269,13 @@ protected function externalDbUrl(): Attribute
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'])) {

View file

@ -251,9 +251,13 @@ protected function externalDbUrl(): Attribute
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'])) {

View file

@ -246,9 +246,13 @@ protected function externalDbUrl(): Attribute
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'])) {

View file

@ -253,11 +253,15 @@ protected function externalDbUrl(): Attribute
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';

View file

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

View file

@ -127,13 +127,19 @@ public function boot(): void
});
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) {

View file

@ -88,7 +88,14 @@ public function getImages(): array
public function getServerTypes(): array
{
return $this->requestPaginated('get', '/server_types', 'server_types');
$types = $this->requestPaginated('get', '/server_types', 'server_types');
// Filter out entries where "deprecated" is explicitly true
$filtered = array_filter($types, function ($type) {
return ! (isset($type['deprecated']) && $type['deprecated'] === true);
});
return array_values($filtered);
}
public function getSshKeys(): array

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

View file

@ -51,6 +51,8 @@
const SPECIFIC_SERVICES = [
'quay.io/minio/minio',
'minio/minio',
'ghcr.io/coollabsio/minio',
'coollabsio/minio',
'svhd/logto',
];

View file

@ -41,7 +41,13 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth
$database = new StandaloneRedis;
$database->uuid = (new Cuid2);
$database->name = 'redis-database-'.$database->uuid;
$redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
if ($otherData && isset($otherData['redis_password'])) {
$redis_password = $otherData['redis_password'];
unset($otherData['redis_password']);
}
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();

View file

@ -1073,6 +1073,9 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
}
$yaml_compose = Yaml::parse($compose);
foreach ($yaml_compose['services'] as $service_name => $service) {
if (! isset($service['volumes'])) {
continue;
}
foreach ($service['volumes'] as $volume_name => $volume) {
if (data_get($volume, 'type') === 'bind' && data_get($volume, 'content')) {
unset($yaml_compose['services'][$service_name]['volumes'][$volume_name]['content']);

View file

@ -358,6 +358,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
{
$uuid = data_get($resource, 'uuid');
$compose = data_get($resource, 'docker_compose_raw');
// Store original compose for later use to update docker_compose_raw with content removed
$originalCompose = $compose;
if (! $compose) {
return collect([]);
}
@ -1162,13 +1164,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$environment = $environment->filter(function ($value, $key) {
return ! str($key)->startsWith('SERVICE_FQDN_');
})->map(function ($value, $key) use ($resource) {
// if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
if (str($value)->isEmpty()) {
if ($resource->environment_variables()->where('key', $key)->exists()) {
$value = $resource->environment_variables()->where('key', $key)->first()->value;
} else {
$value = null;
// Preserve empty strings and null values with correct Docker Compose semantics:
// - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy")
// - Null: Variable is unset/removed from container environment (may inherit from host)
if ($value === null) {
// User explicitly wants variable unset - respect that
// NEVER override from database - null means "inherit from environment"
// Keep as null (will be excluded from container environment)
} elseif ($value === '') {
// Empty string - allow database override for backward compatibility
$dbEnv = $resource->environment_variables()->where('key', $key)->first();
// Only use database override if it exists AND has a non-empty value
if ($dbEnv && str($dbEnv->value)->isNotEmpty()) {
$value = $dbEnv->value;
}
// Otherwise keep empty string as-is
}
return $value;
@ -1297,7 +1307,46 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
return array_search($key, $customOrder);
});
$resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
// Remove empty top-level sections (volumes, networks, configs, secrets)
// Keep only non-empty sections to match Docker Compose best practices
$topLevel = $topLevel->filter(function ($value, $key) {
// Always keep 'services' section
if ($key === 'services') {
return true;
}
// Keep section only if it has content
return $value instanceof Collection ? $value->isNotEmpty() : ! empty($value);
});
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
$resource->docker_compose = $cleanedCompose;
// Update docker_compose_raw to remove content: from volumes only
// This keeps the original user input clean while preventing content reapplication
// Parse the original compose again to create a clean version without Coolify additions
try {
$originalYaml = Yaml::parse($originalCompose);
// Remove content, isDirectory, and is_directory from all volume definitions
if (isset($originalYaml['services'])) {
foreach ($originalYaml['services'] as $serviceName => &$service) {
if (isset($service['volumes'])) {
foreach ($service['volumes'] as $key => &$volume) {
if (is_array($volume)) {
unset($volume['content']);
unset($volume['isDirectory']);
unset($volume['is_directory']);
}
}
}
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (\Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage());
}
data_forget($resource, 'environment_variables');
data_forget($resource, 'environment_variables_preview');
$resource->save();
@ -1309,6 +1358,8 @@ function serviceParser(Service $resource): Collection
{
$uuid = data_get($resource, 'uuid');
$compose = data_get($resource, 'docker_compose_raw');
// Store original compose for later use to update docker_compose_raw with content removed
$originalCompose = $compose;
if (! $compose) {
return collect([]);
}
@ -1558,21 +1609,22 @@ function serviceParser(Service $resource): Collection
]);
}
if (substr_count(str($key)->value(), '_') === 3) {
$newKey = str($key)->beforeLast('_');
// For port-specific variables (e.g., SERVICE_FQDN_UMAMI_3000),
// keep the port suffix in the key and use the URL with port
$resource->environment_variables()->updateOrCreate([
'key' => $newKey->value(),
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'value' => $fqdnWithPort,
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
'key' => $newKey->value(),
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'value' => $urlWithPort,
'is_preview' => false,
]);
}
@ -2091,13 +2143,21 @@ function serviceParser(Service $resource): Collection
$environment = $environment->filter(function ($value, $key) {
return ! str($key)->startsWith('SERVICE_FQDN_');
})->map(function ($value, $key) use ($resource) {
// if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
if (str($value)->isEmpty()) {
if ($resource->environment_variables()->where('key', $key)->exists()) {
$value = $resource->environment_variables()->where('key', $key)->first()->value;
} else {
$value = null;
// Preserve empty strings and null values with correct Docker Compose semantics:
// - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy")
// - Null: Variable is unset/removed from container environment (may inherit from host)
if ($value === null) {
// User explicitly wants variable unset - respect that
// NEVER override from database - null means "inherit from environment"
// Keep as null (will be excluded from container environment)
} elseif ($value === '') {
// Empty string - allow database override for backward compatibility
$dbEnv = $resource->environment_variables()->where('key', $key)->first();
// Only use database override if it exists AND has a non-empty value
if ($dbEnv && str($dbEnv->value)->isNotEmpty()) {
$value = $dbEnv->value;
}
// Otherwise keep empty string as-is
}
return $value;
@ -2220,7 +2280,46 @@ function serviceParser(Service $resource): Collection
return array_search($key, $customOrder);
});
$resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
// Remove empty top-level sections (volumes, networks, configs, secrets)
// Keep only non-empty sections to match Docker Compose best practices
$topLevel = $topLevel->filter(function ($value, $key) {
// Always keep 'services' section
if ($key === 'services') {
return true;
}
// Keep section only if it has content
return $value instanceof Collection ? $value->isNotEmpty() : ! empty($value);
});
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
$resource->docker_compose = $cleanedCompose;
// Update docker_compose_raw to remove content: from volumes only
// This keeps the original user input clean while preventing content reapplication
// Parse the original compose again to create a clean version without Coolify additions
try {
$originalYaml = Yaml::parse($originalCompose);
// Remove content, isDirectory, and is_directory from all volume definitions
if (isset($originalYaml['services'])) {
foreach ($originalYaml['services'] as $serviceName => &$service) {
if (isset($service['volumes'])) {
foreach ($service['volumes'] as $key => &$volume) {
if (is_array($volume)) {
unset($volume['content']);
unset($volume['isDirectory']);
unset($volume['is_directory']);
}
}
}
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (\Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage());
}
data_forget($resource, 'environment_variables');
data_forget($resource, 'environment_variables_preview');
$resource->save();

View file

@ -2879,6 +2879,18 @@ function instanceSettings()
return InstanceSettings::get();
}
function getHelperVersion(): string
{
$settings = instanceSettings();
// In development mode, use the dev_helper_version if set, otherwise fallback to config
if (isDev() && ! empty($settings->dev_helper_version)) {
return $settings->dev_helper_version;
}
return config('constants.coolify.helper_version');
}
function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id)
{
$server = Server::find($server_id)->where('team_id', $team_id)->first();

View file

@ -2,8 +2,8 @@
return [
'coolify' => [
'version' => '4.0.0-beta.437',
'helper_version' => '1.0.11',
'version' => '4.0.0-beta.443',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
@ -12,7 +12,7 @@
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'),
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
'releases_url' => 'https://cdn.coollabs.io/coolify/releases.json',
'releases_url' => 'https://cdn.coolify.io/releases.json',
],
'urls' => [

View file

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

View file

@ -104,7 +104,7 @@ services:
networks:
- coolify
minio:
image: minio/minio:latest
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
pull_policy: always
container_name: coolify-minio
command: server /data --console-address ":9001"

View file

@ -10,7 +10,7 @@ ARG DOCKER_BUILDX_VERSION=0.25.0
# https://github.com/buildpacks/pack/releases
ARG PACK_VERSION=0.38.2
# https://github.com/railwayapp/nixpacks/releases
ARG NIXPACKS_VERSION=1.40.0
ARG NIXPACKS_VERSION=1.41.0
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z

View file

@ -27,7 +27,8 @@ RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \
WORKDIR /var/www/html
COPY --chown=www-data:www-data composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --no-plugins --no-scripts --prefer-dist
RUN --mount=type=cache,target=/tmp/cache \
COMPOSER_CACHE_DIR=/tmp/cache composer install --no-dev --no-interaction --no-plugins --no-scripts --prefer-dist
USER www-data
@ -38,7 +39,8 @@ FROM node:24-alpine AS static-assets
WORKDIR /app
COPY package*.json vite.config.js postcss.config.cjs ./
RUN npm ci
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
@ -72,8 +74,9 @@ RUN apk add --no-cache gnupg && \
curl -fSsL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /usr/share/keyrings/postgresql.gpg
# Install system dependencies
RUN apk upgrade
RUN apk add --no-cache \
RUN --mount=type=cache,target=/var/cache/apk \
apk upgrade && \
apk add --no-cache \
postgresql${POSTGRES_VERSION}-client \
openssh-client \
git \

5
jean.json Normal file
View file

@ -0,0 +1,5 @@
{
"scripts": {
"setup": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json"
}
}

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.435"
"version": "4.0.0-beta.443"
},
"nightly": {
"version": "4.0.0-beta.436"
"version": "4.0.0-beta.444"
},
"helper": {
"version": "1.0.11"

98
package-lock.json generated
View file

@ -22,7 +22,7 @@
"pusher-js": "8.4.0",
"tailwind-scrollbar": "4.0.2",
"tailwindcss": "4.1.10",
"vite": "6.3.6",
"vite": "6.4.1",
"vue": "3.5.16"
}
},
@ -916,7 +916,8 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.10",
@ -1158,6 +1159,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.3",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.3",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.10",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz",
@ -1371,8 +1432,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
@ -1535,6 +1595,7 @@
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
@ -1549,6 +1610,7 @@
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@ -1567,6 +1629,7 @@
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
}
@ -2328,7 +2391,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2405,7 +2467,6 @@
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"tweetnacl": "^1.0.3"
}
@ -2490,6 +2551,7 @@
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
@ -2506,6 +2568,7 @@
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@ -2524,6 +2587,7 @@
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
@ -2538,6 +2602,7 @@
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@ -2586,8 +2651,7 @@
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz",
"integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
@ -2604,11 +2668,11 @@
}
},
"node_modules/tar": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz",
"integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==",
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
"integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==",
"dev": true,
"license": "ISC",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
@ -2651,12 +2715,11 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -2756,7 +2819,6 @@
"integrity": "sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.16",
"@vue/compiler-sfc": "3.5.16",
@ -2779,6 +2841,7 @@
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@ -2800,6 +2863,7 @@
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=0.4.0"
}

View file

@ -16,7 +16,7 @@
"pusher-js": "8.4.0",
"tailwind-scrollbar": "4.0.2",
"tailwindcss": "4.1.10",
"vite": "6.3.6",
"vite": "6.4.1",
"vue": "3.5.16"
},
"dependencies": {

View file

@ -0,0 +1,4 @@
<svg width="240" height="240" viewBox="0 0 240 240" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M240 224.762C240 233.012 233.25 239.762 225 239.762H15C6.75 239.762 0 233.012 0 224.762V134.762C0 126.512 4.77 114.993 10.61 109.153L109.39 10.3725C115.22 4.5425 124.77 4.5425 130.6 10.3725L229.39 109.162C235.22 114.992 240 126.522 240 134.772V224.772V224.762Z" fill="#F2F4F9"/>
<path d="M229.39 109.153L130.61 10.3725C124.78 4.5425 115.23 4.5425 109.4 10.3725L10.61 109.153C4.78 114.983 0 126.512 0 134.762V224.762C0 233.012 6.75 239.762 15 239.762H107.27L66.64 199.132C64.55 199.852 62.32 200.262 60 200.262C48.7 200.262 39.5 191.062 39.5 179.762C39.5 168.462 48.7 159.262 60 159.262C71.3 159.262 80.5 168.462 80.5 179.762C80.5 182.092 80.09 184.322 79.37 186.412L111 218.042V102.162C104.2 98.8225 99.5 91.8425 99.5 83.7725C99.5 72.4725 108.7 63.2725 120 63.2725C131.3 63.2725 140.5 72.4725 140.5 83.7725C140.5 91.8425 135.8 98.8225 129 102.162V183.432L160.46 151.972C159.84 150.012 159.5 147.932 159.5 145.772C159.5 134.472 168.7 125.272 180 125.272C191.3 125.272 200.5 134.472 200.5 145.772C200.5 157.072 191.3 166.272 180 166.272C177.5 166.272 175.12 165.802 172.91 164.982L129 208.892V239.772H225C233.25 239.772 240 233.022 240 224.772V134.772C240 126.522 235.23 115.002 229.39 109.162V109.153Z" fill="#18BCF2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/svgs/metamcp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

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