Merge branch 'next' into patch-1
This commit is contained in:
commit
5363310466
226 changed files with 13047 additions and 2781 deletions
|
|
@ -267,18 +267,365 @@ For complete documentation, see **[form-components.mdc](mdc:.cursor/rules/form-c
|
|||
|
||||
## Form Handling Patterns
|
||||
|
||||
### Livewire Component Data Synchronization Pattern
|
||||
|
||||
**IMPORTANT**: All Livewire components must use the **manual `syncData()` pattern** for synchronizing component properties with Eloquent models.
|
||||
|
||||
#### Property Naming Convention
|
||||
- **Component properties**: Use camelCase (e.g., `$gitRepository`, `$isStatic`)
|
||||
- **Database columns**: Use snake_case (e.g., `git_repository`, `is_static`)
|
||||
- **View bindings**: Use camelCase matching component properties (e.g., `id="gitRepository"`)
|
||||
|
||||
#### The syncData() Method Pattern
|
||||
|
||||
```php
|
||||
use Livewire\Attributes\Validate;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
class MyComponent extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Application $application;
|
||||
|
||||
// Properties with validation attributes
|
||||
#[Validate(['required'])]
|
||||
public string $name;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $description = null;
|
||||
|
||||
#[Validate(['boolean', 'required'])]
|
||||
public bool $isStatic = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('view', $this->application);
|
||||
$this->syncData(); // Load from model
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
||||
// Sync TO model (camelCase → snake_case)
|
||||
$this->application->name = $this->name;
|
||||
$this->application->description = $this->description;
|
||||
$this->application->is_static = $this->isStatic;
|
||||
|
||||
$this->application->save();
|
||||
} else {
|
||||
// Sync FROM model (snake_case → camelCase)
|
||||
$this->name = $this->application->name;
|
||||
$this->description = $this->application->description;
|
||||
$this->isStatic = $this->application->is_static;
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
$this->syncData(toModel: true); // Save to model
|
||||
$this->dispatch('success', 'Saved successfully.');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Validation with #[Validate] Attributes
|
||||
|
||||
All component properties should have `#[Validate]` attributes:
|
||||
|
||||
```php
|
||||
// Boolean properties
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isEnabled = false;
|
||||
|
||||
// Required strings
|
||||
#[Validate(['string', 'required'])]
|
||||
public string $name;
|
||||
|
||||
// Nullable strings
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $description = null;
|
||||
|
||||
// With constraints
|
||||
#[Validate(['integer', 'min:1'])]
|
||||
public int $timeout;
|
||||
```
|
||||
|
||||
#### Benefits of syncData() Pattern
|
||||
|
||||
- **Explicit Control**: Clear visibility of what's being synchronized
|
||||
- **Type Safety**: #[Validate] attributes provide compile-time validation info
|
||||
- **Easy Debugging**: Single method to check for data flow issues
|
||||
- **Maintainability**: All sync logic in one place
|
||||
- **Flexibility**: Can add custom logic (encoding, transformations, etc.)
|
||||
|
||||
#### Creating New Form Components with syncData()
|
||||
|
||||
#### Step-by-Step Component Creation Guide
|
||||
|
||||
**Step 1: Define properties in camelCase with #[Validate] attributes**
|
||||
```php
|
||||
use Livewire\Attributes\Validate;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class MyFormComponent extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
// The model we're syncing with
|
||||
public Application $application;
|
||||
|
||||
// Component properties in camelCase with validation
|
||||
#[Validate(['string', 'required'])]
|
||||
public string $name;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $gitRepository = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $installCommand = null;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isStatic = false;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Implement syncData() method**
|
||||
```php
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
||||
// Sync TO model (component camelCase → database snake_case)
|
||||
$this->application->name = $this->name;
|
||||
$this->application->git_repository = $this->gitRepository;
|
||||
$this->application->install_command = $this->installCommand;
|
||||
$this->application->is_static = $this->isStatic;
|
||||
|
||||
$this->application->save();
|
||||
} else {
|
||||
// Sync FROM model (database snake_case → component camelCase)
|
||||
$this->name = $this->application->name;
|
||||
$this->gitRepository = $this->application->git_repository;
|
||||
$this->installCommand = $this->application->install_command;
|
||||
$this->isStatic = $this->application->is_static;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Implement mount() to load initial data**
|
||||
```php
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('view', $this->application);
|
||||
$this->syncData(); // Load data from model to component properties
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Implement action methods with authorization**
|
||||
```php
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$this->syncData(toModel: true); // Save component properties to model
|
||||
$this->dispatch('success', 'Settings saved.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$this->syncData(toModel: true); // Save component properties to model
|
||||
$this->dispatch('success', 'Changes saved successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Create Blade view with camelCase bindings**
|
||||
```blade
|
||||
<div>
|
||||
<form wire:submit="submit">
|
||||
<x-forms.input
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="name"
|
||||
label="Name"
|
||||
required />
|
||||
|
||||
<x-forms.input
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="gitRepository"
|
||||
label="Git Repository" />
|
||||
|
||||
<x-forms.input
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="installCommand"
|
||||
label="Install Command" />
|
||||
|
||||
<x-forms.checkbox
|
||||
instantSave
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
id="isStatic"
|
||||
label="Static Site" />
|
||||
|
||||
<x-forms.button
|
||||
canGate="update"
|
||||
:canResource="$application"
|
||||
type="submit">
|
||||
Save Changes
|
||||
</x-forms.button>
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
- Use `wire:model="camelCase"` and `id="camelCase"` in Blade views
|
||||
- Component properties are camelCase, database columns are snake_case
|
||||
- Always include authorization checks (`authorize()`, `canGate`, `canResource`)
|
||||
- Use `instantSave` for checkboxes that save immediately without form submission
|
||||
|
||||
#### Special Patterns
|
||||
|
||||
**Pattern 1: Related Models (e.g., Application → Settings)**
|
||||
```php
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
||||
// Sync main model
|
||||
$this->application->name = $this->name;
|
||||
$this->application->save();
|
||||
|
||||
// Sync related model
|
||||
$this->application->settings->is_static = $this->isStatic;
|
||||
$this->application->settings->save();
|
||||
} else {
|
||||
// From main model
|
||||
$this->name = $this->application->name;
|
||||
|
||||
// From related model
|
||||
$this->isStatic = $this->application->settings->is_static;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern 2: Custom Encoding/Decoding**
|
||||
```php
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
||||
// Encode before saving
|
||||
$this->application->custom_labels = base64_encode($this->customLabels);
|
||||
$this->application->save();
|
||||
} else {
|
||||
// Decode when loading
|
||||
$this->customLabels = $this->application->parseContainerLabels();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern 3: Error Rollback**
|
||||
```php
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
$original = $this->model->getOriginal();
|
||||
|
||||
try {
|
||||
$this->syncData(toModel: true);
|
||||
$this->dispatch('success', 'Saved successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
// Rollback on error
|
||||
$this->model->setRawAttributes($original);
|
||||
$this->model->save();
|
||||
$this->syncData(); // Reload from model
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Property Type Patterns
|
||||
|
||||
**Required Strings**
|
||||
```php
|
||||
#[Validate(['string', 'required'])]
|
||||
public string $name; // No ?, no default, always has value
|
||||
```
|
||||
|
||||
**Nullable Strings**
|
||||
```php
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $description = null; // ?, = null, can be empty
|
||||
```
|
||||
|
||||
**Booleans**
|
||||
```php
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isEnabled = false; // Always has default value
|
||||
```
|
||||
|
||||
**Integers with Constraints**
|
||||
```php
|
||||
#[Validate(['integer', 'min:1'])]
|
||||
public int $timeout; // Required
|
||||
|
||||
#[Validate(['integer', 'min:1', 'nullable'])]
|
||||
public ?int $port = null; // Nullable
|
||||
```
|
||||
|
||||
#### Testing Checklist
|
||||
|
||||
After creating a new component with syncData(), verify:
|
||||
|
||||
- [ ] All checkboxes save correctly (especially `instantSave` ones)
|
||||
- [ ] All form inputs persist to database
|
||||
- [ ] Custom encoded fields (like labels) display correctly if applicable
|
||||
- [ ] Form validation works for all fields
|
||||
- [ ] No console errors in browser
|
||||
- [ ] Authorization checks work (`@can` directives and `authorize()` calls)
|
||||
- [ ] Error rollback works if exceptions occur
|
||||
- [ ] Related models save correctly if applicable (e.g., Application + ApplicationSetting)
|
||||
|
||||
#### Common Pitfalls to Avoid
|
||||
|
||||
1. **snake_case in component properties**: Always use camelCase for component properties (e.g., `$gitRepository` not `$git_repository`)
|
||||
2. **Missing #[Validate] attributes**: Every property should have validation attributes for type safety
|
||||
3. **Forgetting to call syncData()**: Must call `syncData()` in `mount()` to load initial data
|
||||
4. **Missing authorization**: Always use `authorize()` in methods and `canGate`/`canResource` in views
|
||||
5. **View binding mismatch**: Use camelCase in Blade (e.g., `id="gitRepository"` not `id="git_repository"`)
|
||||
6. **wire:model vs wire:model.live**: Use `.live` for `instantSave` checkboxes to avoid timing issues
|
||||
7. **Validation sync**: If using `rules()` method, keep it in sync with `#[Validate]` attributes
|
||||
8. **Related models**: Don't forget to save both main and related models in syncData() method
|
||||
|
||||
### Livewire Forms
|
||||
```php
|
||||
class ServerCreateForm extends Component
|
||||
{
|
||||
public $name;
|
||||
public $ip;
|
||||
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|min:3',
|
||||
'ip' => 'required|ip',
|
||||
];
|
||||
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->validate();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ on:
|
|||
schedule:
|
||||
- cron: '0 1 * * *'
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
discussions: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
lock-threads:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -13,5 +18,5 @@ jobs:
|
|||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-inactive-days: '30'
|
||||
pr-inactive-days: '30'
|
||||
discussion-inactive-days: '30'
|
||||
pr-inactive-days: '30'
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ on:
|
|||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
manage-stale:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
15
.github/workflows/chore-pr-comments.yml
vendored
15
.github/workflows/chore-pr-comments.yml
vendored
|
|
@ -3,20 +3,13 @@ on:
|
|||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
actions: none
|
||||
checks: none
|
||||
deployments: none
|
||||
issues: none
|
||||
packages: none
|
||||
repository-projects: none
|
||||
security-events: none
|
||||
statuses: none
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ on:
|
|||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
remove-labels-and-assignees:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
79
.github/workflows/claude-code-review.yml
vendored
79
.github/workflows/claude-code-review.yml
vendored
|
|
@ -1,79 +0,0 @@
|
|||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
if: false
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||
# model: "claude-opus-4-1-20250805"
|
||||
|
||||
# Direct prompt for automated review (no @claude mention needed)
|
||||
direct_prompt: |
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Be constructive and helpful in your feedback.
|
||||
|
||||
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
|
||||
# use_sticky_comment: true
|
||||
|
||||
# Optional: Customize review based on file types
|
||||
# direct_prompt: |
|
||||
# Review this PR focusing on:
|
||||
# - For TypeScript files: Type safety and proper interface usage
|
||||
# - For API endpoints: Security, input validation, and error handling
|
||||
# - For React components: Performance, accessibility, and best practices
|
||||
# - For tests: Coverage, edge cases, and test quality
|
||||
|
||||
# Optional: Different prompts for different authors
|
||||
# direct_prompt: |
|
||||
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
|
||||
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
|
||||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
|
||||
|
||||
# Optional: Add specific tools for running tests or linting
|
||||
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
|
||||
|
||||
# Optional: Skip review for certain conditions
|
||||
# if: |
|
||||
# !contains(github.event.pull_request.title, '[skip-review]') &&
|
||||
# !contains(github.event.pull_request.title, '[WIP]')
|
||||
|
||||
65
.github/workflows/claude.yml
vendored
65
.github/workflows/claude.yml
vendored
|
|
@ -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
|
||||
9
.github/workflows/cleanup-ghcr-untagged.yml
vendored
9
.github/workflows/cleanup-ghcr-untagged.yml
vendored
|
|
@ -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']
|
||||
|
|
|
|||
89
.github/workflows/coolify-helper-next.yml
vendored
89
.github/workflows/coolify-helper-next.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
88
.github/workflows/coolify-helper.yml
vendored
88
.github/workflows/coolify-helper.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
81
.github/workflows/coolify-production-build.yml
vendored
81
.github/workflows/coolify-production-build.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
89
.github/workflows/coolify-realtime-next.yml
vendored
89
.github/workflows/coolify-realtime-next.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
89
.github/workflows/coolify-realtime.yml
vendored
89
.github/workflows/coolify-realtime.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
93
.github/workflows/coolify-staging-build.yml
vendored
93
.github/workflows/coolify-staging-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
84
.github/workflows/coolify-testing-host.yml
vendored
84
.github/workflows/coolify-testing-host.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -37,3 +37,4 @@ scripts/load-test/*
|
|||
docker/coolify-realtime/node_modules
|
||||
.DS_Store
|
||||
CHANGELOG.md
|
||||
/.workspaces
|
||||
|
|
|
|||
258
CHANGELOG.md
258
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/`)
|
||||
|
|
|
|||
|
|
@ -70,10 +70,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
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class GetContainersStatus
|
||||
|
|
@ -28,6 +29,8 @@ class GetContainersStatus
|
|||
|
||||
protected ?Collection $applicationContainerStatuses;
|
||||
|
||||
protected ?Collection $applicationContainerRestartCounts;
|
||||
|
||||
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
||||
{
|
||||
$this->containers = $containers;
|
||||
|
|
@ -136,6 +139,18 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
if ($containerName) {
|
||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||
}
|
||||
|
||||
// Track restart counts for applications
|
||||
$restartCount = data_get($container, 'RestartCount', 0);
|
||||
if (! isset($this->applicationContainerRestartCounts)) {
|
||||
$this->applicationContainerRestartCounts = collect();
|
||||
}
|
||||
if (! $this->applicationContainerRestartCounts->has($applicationId)) {
|
||||
$this->applicationContainerRestartCounts->put($applicationId, collect());
|
||||
}
|
||||
if ($containerName) {
|
||||
$this->applicationContainerRestartCounts->get($applicationId)->put($containerName, $restartCount);
|
||||
}
|
||||
} else {
|
||||
// Notify user that this container should not be there.
|
||||
}
|
||||
|
|
@ -291,7 +306,24 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
continue;
|
||||
}
|
||||
|
||||
$application->update(['status' => 'exited']);
|
||||
// If container was recently restarting (crash loop), keep it as degraded for a grace period
|
||||
// This prevents false "exited" status during the brief moment between container removal and recreation
|
||||
$recentlyRestarted = $application->restart_count > 0 &&
|
||||
$application->last_restart_at &&
|
||||
$application->last_restart_at->greaterThan(now()->subSeconds(30));
|
||||
|
||||
if ($recentlyRestarted) {
|
||||
// Keep it as degraded if it was recently in a crash loop
|
||||
$application->update(['status' => 'degraded (unhealthy)']);
|
||||
} else {
|
||||
// Reset restart count when application exits completely
|
||||
$application->update([
|
||||
'status' => 'exited',
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
$notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews);
|
||||
foreach ($notRunningApplicationPreviews as $previewId) {
|
||||
|
|
@ -340,22 +372,56 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
continue;
|
||||
}
|
||||
|
||||
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
|
||||
if ($aggregatedStatus) {
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $aggregatedStatus) {
|
||||
$application->update(['status' => $aggregatedStatus]);
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
// Track restart counts first
|
||||
$maxRestartCount = 0;
|
||||
if (isset($this->applicationContainerRestartCounts) && $this->applicationContainerRestartCounts->has($applicationId)) {
|
||||
$containerRestartCounts = $this->applicationContainerRestartCounts->get($applicationId);
|
||||
$maxRestartCount = $containerRestartCounts->max() ?? 0;
|
||||
}
|
||||
|
||||
// Wrap all database updates in a transaction to ensure consistency
|
||||
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
|
||||
$previousRestartCount = $application->restart_count ?? 0;
|
||||
|
||||
if ($maxRestartCount > $previousRestartCount) {
|
||||
// Restart count increased - this is a crash restart
|
||||
$application->update([
|
||||
'restart_count' => $maxRestartCount,
|
||||
'last_restart_at' => now(),
|
||||
'last_restart_type' => 'crash',
|
||||
]);
|
||||
|
||||
// Send notification
|
||||
$containerName = $application->name;
|
||||
$projectUuid = data_get($application, 'environment.project.uuid');
|
||||
$environmentName = data_get($application, 'environment.name');
|
||||
$applicationUuid = data_get($application, 'uuid');
|
||||
|
||||
if ($projectUuid && $applicationUuid && $environmentName) {
|
||||
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
|
||||
} else {
|
||||
$url = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate status after tracking restart counts
|
||||
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount);
|
||||
if ($aggregatedStatus) {
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $aggregatedStatus) {
|
||||
$application->update(['status' => $aggregatedStatus]);
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ServiceChecked::dispatch($this->server->team->id);
|
||||
}
|
||||
|
||||
private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
|
||||
private function aggregateApplicationStatus($application, Collection $containerStatuses, int $maxRestartCount = 0): ?string
|
||||
{
|
||||
// Parse docker compose to check for excluded containers
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
|
|
@ -413,6 +479,11 @@ private function aggregateApplicationStatus($application, Collection $containerS
|
|||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
// If container is exited but has restart count > 0, it's in a crash loop
|
||||
if ($hasExited && $maxRestartCount > 0) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
|
@ -421,7 +492,7 @@ private function aggregateApplicationStatus($application, Collection $containerS
|
|||
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
}
|
||||
|
||||
// All containers are exited
|
||||
// All containers are exited with no restart count - truly stopped
|
||||
return 'exited (unhealthy)';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Jobs\PullHelperImageJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Sleep;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
|
@ -50,7 +49,9 @@ public function handle($manual_update = false)
|
|||
|
||||
private function update()
|
||||
{
|
||||
PullHelperImageJob::dispatch($this->server);
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$latest_version = getHelperVersion();
|
||||
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
|
||||
|
||||
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
|
||||
instant_remote_process(["docker pull -q $image"], $this->server, false);
|
||||
|
|
|
|||
|
|
@ -20,18 +20,23 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
|
|||
}
|
||||
$service->saveComposeConfigs();
|
||||
$service->isConfigurationChanged(save: true);
|
||||
$commands[] = 'cd '.$service->workdir();
|
||||
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
|
||||
$workdir = $service->workdir();
|
||||
// $commands[] = "cd {$workdir}";
|
||||
$commands[] = "echo 'Saved configuration files to {$workdir}.'";
|
||||
// Ensure .env exists in the correct directory before docker compose tries to load it
|
||||
// This is defensive programming - saveComposeConfigs() already creates it,
|
||||
// but we guarantee it here in case of any edge cases or manual deployments
|
||||
$commands[] = "touch {$workdir}/.env";
|
||||
if ($pullLatestImages) {
|
||||
$commands[] = "echo 'Pulling images.'";
|
||||
$commands[] = 'docker compose pull';
|
||||
$commands[] = "docker compose --project-directory {$workdir} pull";
|
||||
}
|
||||
if ($service->networks()->count() > 0) {
|
||||
$commands[] = "echo 'Creating Docker network.'";
|
||||
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
|
||||
}
|
||||
$commands[] = 'echo Starting service.';
|
||||
$commands[] = 'docker compose up -d --remove-orphans --force-recreate --build';
|
||||
$commands[] = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --remove-orphans --force-recreate --build";
|
||||
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
|
||||
if (data_get($service, 'connect_to_docker_network')) {
|
||||
$compose = data_get($service, 'docker_compose', []);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
class CleanupRedis extends Command
|
||||
{
|
||||
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks}';
|
||||
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks} {--restart : Aggressive cleanup mode for system restart (marks all processing jobs as failed)}';
|
||||
|
||||
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)';
|
||||
|
||||
|
|
@ -63,6 +63,14 @@ public function handle()
|
|||
$deletedCount += $locksCleaned;
|
||||
}
|
||||
|
||||
// Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
|
||||
$isRestart = $this->option('restart');
|
||||
if ($isRestart || $this->option('clear-locks')) {
|
||||
$this->info($isRestart ? 'Cleaning up stuck jobs (RESTART MODE - aggressive)...' : 'Checking for stuck jobs (runtime mode - conservative)...');
|
||||
$jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart);
|
||||
$deletedCount += $jobsCleaned;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
|
||||
} else {
|
||||
|
|
@ -332,4 +340,130 @@ private function cleanupCacheLocks(bool $dryRun): int
|
|||
|
||||
return $cleanedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stuck jobs based on mode (restart vs runtime).
|
||||
*
|
||||
* @param mixed $redis Redis connection
|
||||
* @param string $prefix Horizon prefix
|
||||
* @param bool $dryRun Dry run mode
|
||||
* @param bool $isRestart Restart mode (aggressive) vs runtime mode (conservative)
|
||||
* @return int Number of jobs cleaned
|
||||
*/
|
||||
private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $isRestart): int
|
||||
{
|
||||
$cleanedCount = 0;
|
||||
$now = time();
|
||||
|
||||
// Get all keys with the horizon prefix
|
||||
$cursor = 0;
|
||||
$keys = [];
|
||||
do {
|
||||
$result = $redis->scan($cursor, ['match' => '*', 'count' => 100]);
|
||||
|
||||
// Guard against scan() returning false
|
||||
if ($result === false) {
|
||||
$this->error('Redis scan failed, stopping key retrieval');
|
||||
break;
|
||||
}
|
||||
|
||||
$cursor = $result[0];
|
||||
$keys = array_merge($keys, $result[1]);
|
||||
} while ($cursor !== 0);
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
||||
$type = $redis->command('type', [$keyWithoutPrefix]);
|
||||
|
||||
// Only process hash-type keys (individual jobs)
|
||||
if ($type !== 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $redis->command('hgetall', [$keyWithoutPrefix]);
|
||||
$status = data_get($data, 'status');
|
||||
$payload = data_get($data, 'payload');
|
||||
|
||||
// Only process jobs in "processing" or "reserved" state
|
||||
if (! in_array($status, ['processing', 'reserved'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse job payload to get job class and started time
|
||||
$payloadData = json_decode($payload, true);
|
||||
|
||||
// Check for JSON decode errors
|
||||
if ($payloadData === null || json_last_error() !== JSON_ERROR_NONE) {
|
||||
$errorMsg = json_last_error_msg();
|
||||
$truncatedPayload = is_string($payload) ? substr($payload, 0, 200) : 'non-string payload';
|
||||
$this->error("Failed to decode job payload for {$keyWithoutPrefix}: {$errorMsg}. Payload: {$truncatedPayload}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$jobClass = data_get($payloadData, 'displayName', 'Unknown');
|
||||
|
||||
// Prefer reserved_at (when job started processing), fallback to created_at
|
||||
$reservedAt = (int) data_get($data, 'reserved_at', 0);
|
||||
$createdAt = (int) data_get($data, 'created_at', 0);
|
||||
$startTime = $reservedAt ?: $createdAt;
|
||||
|
||||
// If we can't determine when the job started, skip it
|
||||
if (! $startTime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate how long the job has been processing
|
||||
$processingTime = $now - $startTime;
|
||||
|
||||
$shouldFail = false;
|
||||
$reason = '';
|
||||
|
||||
if ($isRestart) {
|
||||
// RESTART MODE: Mark ALL processing/reserved jobs as failed
|
||||
// Safe because all workers are dead on restart
|
||||
$shouldFail = true;
|
||||
$reason = 'System restart - all workers terminated';
|
||||
} else {
|
||||
// RUNTIME MODE: Only mark truly stuck jobs as failed
|
||||
// Be conservative to avoid killing legitimate long-running jobs
|
||||
|
||||
// Skip ApplicationDeploymentJob entirely (has dynamic_timeout, can run 2+ hours)
|
||||
if (str_contains($jobClass, 'ApplicationDeploymentJob')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip DatabaseBackupJob (large backups can take hours)
|
||||
if (str_contains($jobClass, 'DatabaseBackupJob')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other jobs, only fail if processing > 12 hours
|
||||
if ($processingTime > 43200) { // 12 hours
|
||||
$shouldFail = true;
|
||||
$reason = 'Processing for more than 12 hours';
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldFail) {
|
||||
if ($dryRun) {
|
||||
$this->warn(" Would mark as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1)." min) - {$reason}");
|
||||
} else {
|
||||
// Mark job as failed
|
||||
$redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
|
||||
$redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
|
||||
$redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]);
|
||||
|
||||
$this->info(" ✓ Marked as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1).' min) - '.$reason);
|
||||
}
|
||||
$cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($cleanedCount === 0) {
|
||||
$this->info($isRestart ? ' No jobs to clean up' : ' No stuck jobs found (all jobs running normally)');
|
||||
}
|
||||
|
||||
return $cleanedCount;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,9 +222,14 @@ private function cleanup_stucked_resources()
|
|||
try {
|
||||
$scheduled_backups = ScheduledDatabaseBackup::all();
|
||||
foreach ($scheduled_backups as $scheduled_backup) {
|
||||
if (! $scheduled_backup->server()) {
|
||||
echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
|
||||
$scheduled_backup->delete();
|
||||
try {
|
||||
$server = $scheduled_backup->server();
|
||||
if (! $server) {
|
||||
echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
|
||||
$scheduled_backup->delete();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error checking server for scheduledbackup {$scheduled_backup->id}: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -416,7 +421,7 @@ private function cleanup_stucked_resources()
|
|||
foreach ($serviceApplications as $service) {
|
||||
if (! data_get($service, 'service')) {
|
||||
echo 'ServiceApplication without service: '.$service->name.'\n';
|
||||
DeleteResourceJob::dispatch($service);
|
||||
$service->forceDelete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -429,7 +434,7 @@ private function cleanup_stucked_resources()
|
|||
foreach ($serviceDatabases as $service) {
|
||||
if (! data_get($service, 'service')) {
|
||||
echo 'ServiceDatabase without service: '.$service->name.'\n';
|
||||
DeleteResourceJob::dispatch($service);
|
||||
$service->forceDelete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
219
app/Console/Commands/Cloud/RestoreDatabase.php
Normal file
219
app/Console/Commands/Cloud/RestoreDatabase.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,9 @@
|
|||
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
|
|
@ -45,6 +48,44 @@ public function init()
|
|||
} else {
|
||||
echo "Instance already initialized.\n";
|
||||
}
|
||||
|
||||
// Clean up stuck jobs and stale locks on development startup
|
||||
try {
|
||||
echo "Cleaning up Redis (stuck jobs and stale locks)...\n";
|
||||
Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
|
||||
echo "Redis cleanup completed.\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:redis: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
if ($updatedTaskCount > 0) {
|
||||
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
if ($updatedBackupCount > 0) {
|
||||
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
CheckHelperImageJob::dispatch();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ public function handle()
|
|||
]);
|
||||
}
|
||||
$output = 'Because of an error, the backup of the database '.$db->name.' failed.';
|
||||
$this->mail = (new BackupFailed($backup, $db, $output))->toMail();
|
||||
$this->mail = (new BackupFailed($backup, $db, $output, $backup->database_name ?? 'unknown'))->toMail();
|
||||
$this->sendEmail();
|
||||
break;
|
||||
case 'backup-success':
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@
|
|||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
|
@ -73,7 +76,7 @@ public function handle()
|
|||
$this->cleanupUnusedNetworkFromCoolifyProxy();
|
||||
|
||||
try {
|
||||
$this->call('cleanup:redis', ['--clear-locks' => true]);
|
||||
$this->call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
|
||||
}
|
||||
|
|
@ -86,6 +89,7 @@ public function handle()
|
|||
$this->call('cleanup:stucked-resources');
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n";
|
||||
echo "Continuing with initialization - cleanup errors will not prevent Coolify from starting\n";
|
||||
}
|
||||
try {
|
||||
$updatedCount = ApplicationDeploymentQueue::whereIn('status', [
|
||||
|
|
@ -102,6 +106,34 @@ public function handle()
|
|||
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
if ($updatedTaskCount > 0) {
|
||||
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
if ($updatedBackupCount > 0) {
|
||||
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$localhost = $this->servers->where('id', 0)->first();
|
||||
if ($localhost) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
791
app/Console/Commands/UpdateServiceVersions.php
Normal file
791
app/Console/Commands/UpdateServiceVersions.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/Exceptions/DeploymentException.php
Normal file
32
app/Exceptions/DeploymentException.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Exception for expected deployment failures caused by user/application errors.
|
||||
* These are not Coolify bugs and should not be logged to laravel.log.
|
||||
* Examples: Nixpacks detection failures, missing Dockerfiles, invalid configs, etc.
|
||||
*/
|
||||
class DeploymentException extends Exception
|
||||
{
|
||||
/**
|
||||
* Create a new deployment exception instance.
|
||||
*
|
||||
* @param string $message
|
||||
* @param int $code
|
||||
*/
|
||||
public function __construct($message = '', $code = 0, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from another exception, preserving its message and stack trace.
|
||||
*/
|
||||
public static function fromException(\Throwable $exception): static
|
||||
{
|
||||
return new static($exception->getMessage(), $exception->getCode(), $exception);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ class Handler extends ExceptionHandler
|
|||
protected $dontReport = [
|
||||
ProcessException::class,
|
||||
NonReportableException::class,
|
||||
DeploymentException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -246,6 +246,40 @@ public function manual(Request $request)
|
|||
if ($action === 'closed') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
// Cancel any active deployments for this PR immediately
|
||||
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->whereIn('status', [
|
||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($activeDeployment) {
|
||||
try {
|
||||
// Mark deployment as cancelled
|
||||
$activeDeployment->update([
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Add cancellation log entry
|
||||
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||
|
||||
// Check if helper container exists and kill it
|
||||
$deployment_uuid = $activeDeployment->deployment_uuid;
|
||||
$server = $application->destination->server;
|
||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
|
||||
$activeDeployment->addLogEntry('Deployment container stopped.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Silently handle errors during deployment cancellation
|
||||
}
|
||||
}
|
||||
|
||||
DeleteResourceJob::dispatch($found);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
|
|
@ -481,6 +515,42 @@ public function normal(Request $request)
|
|||
if ($action === 'closed' || $action === 'close') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
// Cancel any active deployments for this PR immediately
|
||||
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->whereIn('status', [
|
||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($activeDeployment) {
|
||||
try {
|
||||
// Mark deployment as cancelled
|
||||
$activeDeployment->update([
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Add cancellation log entry
|
||||
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||
|
||||
// Check if helper container exists and kill it
|
||||
$deployment_uuid = $activeDeployment->deployment_uuid;
|
||||
$server = $application->destination->server;
|
||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
|
||||
$activeDeployment->addLogEntry('Deployment container stopped.');
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// Silently handle errors during deployment cancellation
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any deployed containers
|
||||
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
|
||||
if ($containers->isNotEmpty()) {
|
||||
$containers->each(function ($container) use ($application) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Enums\ProcessStatus;
|
||||
use App\Events\ApplicationConfigurationChanged;
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
|
|
@ -31,7 +32,6 @@
|
|||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Sleep;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Throwable;
|
||||
|
|
@ -341,20 +341,42 @@ public function handle(): void
|
|||
$this->fail($e);
|
||||
throw $e;
|
||||
} finally {
|
||||
$this->application_deployment_queue->update([
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
]);
|
||||
|
||||
if ($this->use_build_server) {
|
||||
$this->server = $this->build_server;
|
||||
} else {
|
||||
$this->write_deployment_configurations();
|
||||
// Wrap cleanup operations in try-catch to prevent exceptions from interfering
|
||||
// with Laravel's job failure handling and status updates
|
||||
try {
|
||||
$this->application_deployment_queue->update([
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
// Log but don't fail - finished_at is not critical
|
||||
\Log::warning('Failed to update finished_at for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
|
||||
$this->graceful_shutdown_container($this->deployment_uuid);
|
||||
try {
|
||||
if ($this->use_build_server) {
|
||||
$this->server = $this->build_server;
|
||||
} else {
|
||||
$this->write_deployment_configurations();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Log but don't fail - configuration writing errors shouldn't prevent status updates
|
||||
$this->application_deployment_queue->addLogEntry('Warning: Failed to write deployment configurations: '.$e->getMessage(), 'stderr');
|
||||
}
|
||||
|
||||
ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
|
||||
try {
|
||||
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
|
||||
$this->graceful_shutdown_container($this->deployment_uuid);
|
||||
} catch (Exception $e) {
|
||||
// Log but don't fail - container cleanup errors are expected when container is already gone
|
||||
\Log::warning('Failed to shutdown container '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
|
||||
} catch (Exception $e) {
|
||||
// Log but don't fail - event dispatch errors shouldn't prevent status updates
|
||||
\Log::warning('Failed to dispatch ServiceStatusChanged for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -459,7 +481,7 @@ 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);
|
||||
|
|
@ -954,7 +976,7 @@ private function push_to_docker_registry()
|
|||
} catch (Exception $e) {
|
||||
$this->application_deployment_queue->addLogEntry('Failed to push image to docker registry. Please check debug logs for more information.');
|
||||
if ($forceFail) {
|
||||
throw new RuntimeException($e->getMessage(), 69420);
|
||||
throw new DeploymentException(get_class($e).': '.$e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1008,7 +1030,7 @@ 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()
|
||||
|
|
@ -1146,6 +1168,18 @@ private function generate_runtime_environment_variables()
|
|||
foreach ($runtime_environment_variables as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
}
|
||||
|
||||
// Check for PORT environment variable mismatch with ports_exposes
|
||||
if ($this->build_pack !== 'dockercompose') {
|
||||
$detectedPort = $this->application->detectPortFromEnvironment(false);
|
||||
if ($detectedPort && ! empty($ports) && ! in_array($detectedPort, $ports)) {
|
||||
$this->application_deployment_queue->addLogEntry(
|
||||
"Warning: PORT environment variable ({$detectedPort}) does not match configured ports_exposes: ".implode(',', $ports).'. It could case "bad gateway" or "no server" errors. Check the "General" page to fix it.',
|
||||
'stderr'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add PORT if not exists, use the first port as default
|
||||
if ($this->build_pack !== 'dockercompose') {
|
||||
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
|
||||
|
|
@ -1576,123 +1610,131 @@ private function laravel_finetunes()
|
|||
|
||||
private function rolling_update()
|
||||
{
|
||||
$this->checkForCancellation();
|
||||
if ($this->server->isSwarm()) {
|
||||
$this->application_deployment_queue->addLogEntry('Rolling update started.');
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "docker stack deploy --detach=true --with-registry-auth -c {$this->workdir}{$this->docker_compose_location} {$this->application->uuid}"),
|
||||
],
|
||||
);
|
||||
$this->application_deployment_queue->addLogEntry('Rolling update completed.');
|
||||
} else {
|
||||
if ($this->use_build_server) {
|
||||
$this->write_deployment_configurations();
|
||||
$this->server = $this->original_server;
|
||||
}
|
||||
if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
if (count($this->application->ports_mappings_array) > 0) {
|
||||
$this->application_deployment_queue->addLogEntry('Application has ports mapped to the host system, rolling update is not supported.');
|
||||
}
|
||||
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
|
||||
$this->application_deployment_queue->addLogEntry('Consistent container name feature enabled, rolling update is not supported.');
|
||||
}
|
||||
if (str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom internal name is set, rolling update is not supported.');
|
||||
}
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$this->application->settings->is_consistent_container_name_enabled = true;
|
||||
$this->application_deployment_queue->addLogEntry('Pull request deployment, rolling update is not supported.');
|
||||
}
|
||||
if (str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom IP address is set, rolling update is not supported.');
|
||||
}
|
||||
$this->stop_running_container(force: true);
|
||||
$this->start_by_compose_file();
|
||||
} else {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
try {
|
||||
$this->checkForCancellation();
|
||||
if ($this->server->isSwarm()) {
|
||||
$this->application_deployment_queue->addLogEntry('Rolling update started.');
|
||||
$this->start_by_compose_file();
|
||||
$this->health_check();
|
||||
$this->stop_running_container();
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "docker stack deploy --detach=true --with-registry-auth -c {$this->workdir}{$this->docker_compose_location} {$this->application->uuid}"),
|
||||
],
|
||||
);
|
||||
$this->application_deployment_queue->addLogEntry('Rolling update completed.');
|
||||
} else {
|
||||
if ($this->use_build_server) {
|
||||
$this->write_deployment_configurations();
|
||||
$this->server = $this->original_server;
|
||||
}
|
||||
if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
if (count($this->application->ports_mappings_array) > 0) {
|
||||
$this->application_deployment_queue->addLogEntry('Application has ports mapped to the host system, rolling update is not supported.');
|
||||
}
|
||||
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
|
||||
$this->application_deployment_queue->addLogEntry('Consistent container name feature enabled, rolling update is not supported.');
|
||||
}
|
||||
if (str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom internal name is set, rolling update is not supported.');
|
||||
}
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$this->application->settings->is_consistent_container_name_enabled = true;
|
||||
$this->application_deployment_queue->addLogEntry('Pull request deployment, rolling update is not supported.');
|
||||
}
|
||||
if (str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom IP address is set, rolling update is not supported.');
|
||||
}
|
||||
$this->stop_running_container(force: true);
|
||||
$this->start_by_compose_file();
|
||||
} else {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
$this->application_deployment_queue->addLogEntry('Rolling update started.');
|
||||
$this->start_by_compose_file();
|
||||
$this->health_check();
|
||||
$this->stop_running_container();
|
||||
$this->application_deployment_queue->addLogEntry('Rolling update completed.');
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
throw new DeploymentException('Rolling update failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function health_check()
|
||||
{
|
||||
if ($this->server->isSwarm()) {
|
||||
// Implement healthcheck for swarm
|
||||
} else {
|
||||
if ($this->application->isHealthcheckDisabled() && $this->application->custom_healthcheck_found === false) {
|
||||
$this->newVersionIsHealthy = true;
|
||||
try {
|
||||
if ($this->server->isSwarm()) {
|
||||
// Implement healthcheck for swarm
|
||||
} else {
|
||||
if ($this->application->isHealthcheckDisabled() && $this->application->custom_healthcheck_found === false) {
|
||||
$this->newVersionIsHealthy = true;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->application->custom_healthcheck_found) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom healthcheck found in Dockerfile.');
|
||||
}
|
||||
if ($this->container_name) {
|
||||
$counter = 1;
|
||||
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
|
||||
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
|
||||
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
|
||||
return;
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
|
||||
$sleeptime = 0;
|
||||
while ($sleeptime < $this->application->health_check_start_period) {
|
||||
Sleep::for(1)->seconds();
|
||||
$sleeptime++;
|
||||
if ($this->application->custom_healthcheck_found) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom healthcheck found in Dockerfile.');
|
||||
}
|
||||
while ($counter <= $this->application->health_check_retries) {
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
"docker inspect --format='{{json .State.Health.Status}}' {$this->container_name}",
|
||||
'hidden' => true,
|
||||
'save' => 'health_check',
|
||||
'append' => false,
|
||||
],
|
||||
[
|
||||
"docker inspect --format='{{json .State.Health.Log}}' {$this->container_name}",
|
||||
'hidden' => true,
|
||||
'save' => 'health_check_logs',
|
||||
'append' => false,
|
||||
],
|
||||
);
|
||||
$this->application_deployment_queue->addLogEntry("Attempt {$counter} of {$this->application->health_check_retries} | Healthcheck status: {$this->saved_outputs->get('health_check')}");
|
||||
$health_check_logs = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'Output', '(no logs)');
|
||||
if (empty($health_check_logs)) {
|
||||
$health_check_logs = '(no logs)';
|
||||
if ($this->container_name) {
|
||||
$counter = 1;
|
||||
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
|
||||
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
|
||||
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
|
||||
}
|
||||
$health_check_return_code = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'ExitCode', '(no return code)');
|
||||
if ($health_check_logs !== '(no logs)' || $health_check_return_code !== '(no return code)') {
|
||||
$this->application_deployment_queue->addLogEntry("Healthcheck logs: {$health_check_logs} | Return code: {$health_check_return_code}");
|
||||
}
|
||||
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'healthy') {
|
||||
$this->newVersionIsHealthy = true;
|
||||
$this->application->update(['status' => 'running']);
|
||||
$this->application_deployment_queue->addLogEntry('New container is healthy.');
|
||||
break;
|
||||
}
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
|
||||
$this->newVersionIsHealthy = false;
|
||||
$this->query_logs();
|
||||
break;
|
||||
}
|
||||
$counter++;
|
||||
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
|
||||
$sleeptime = 0;
|
||||
while ($sleeptime < $this->application->health_check_interval) {
|
||||
while ($sleeptime < $this->application->health_check_start_period) {
|
||||
Sleep::for(1)->seconds();
|
||||
$sleeptime++;
|
||||
}
|
||||
}
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'starting') {
|
||||
$this->query_logs();
|
||||
while ($counter <= $this->application->health_check_retries) {
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
"docker inspect --format='{{json .State.Health.Status}}' {$this->container_name}",
|
||||
'hidden' => true,
|
||||
'save' => 'health_check',
|
||||
'append' => false,
|
||||
],
|
||||
[
|
||||
"docker inspect --format='{{json .State.Health.Log}}' {$this->container_name}",
|
||||
'hidden' => true,
|
||||
'save' => 'health_check_logs',
|
||||
'append' => false,
|
||||
],
|
||||
);
|
||||
$this->application_deployment_queue->addLogEntry("Attempt {$counter} of {$this->application->health_check_retries} | Healthcheck status: {$this->saved_outputs->get('health_check')}");
|
||||
$health_check_logs = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'Output', '(no logs)');
|
||||
if (empty($health_check_logs)) {
|
||||
$health_check_logs = '(no logs)';
|
||||
}
|
||||
$health_check_return_code = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'ExitCode', '(no return code)');
|
||||
if ($health_check_logs !== '(no logs)' || $health_check_return_code !== '(no return code)') {
|
||||
$this->application_deployment_queue->addLogEntry("Healthcheck logs: {$health_check_logs} | Return code: {$health_check_return_code}");
|
||||
}
|
||||
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'healthy') {
|
||||
$this->newVersionIsHealthy = true;
|
||||
$this->application->update(['status' => 'running']);
|
||||
$this->application_deployment_queue->addLogEntry('New container is healthy.');
|
||||
break;
|
||||
}
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
|
||||
$this->newVersionIsHealthy = false;
|
||||
$this->query_logs();
|
||||
break;
|
||||
}
|
||||
$counter++;
|
||||
$sleeptime = 0;
|
||||
while ($sleeptime < $this->application->health_check_interval) {
|
||||
Sleep::for(1)->seconds();
|
||||
$sleeptime++;
|
||||
}
|
||||
}
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'starting') {
|
||||
$this->query_logs();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
throw new DeploymentException('Health check failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1780,9 +1822,8 @@ 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);
|
||||
|
|
@ -1790,7 +1831,7 @@ private function prepare_builder_image(bool $firstTry = true)
|
|||
$env_flags = $this->generate_docker_env_flags_for_secrets();
|
||||
if ($this->use_build_server) {
|
||||
if ($this->dockerConfigFileExists === 'NOK') {
|
||||
throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
|
||||
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
|
||||
}
|
||||
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
||||
} else {
|
||||
|
|
@ -2056,7 +2097,7 @@ private function generate_nixpacks_confs()
|
|||
if ($this->saved_outputs->get('nixpacks_type')) {
|
||||
$this->nixpacks_type = $this->saved_outputs->get('nixpacks_type');
|
||||
if (str($this->nixpacks_type)->isEmpty()) {
|
||||
throw new RuntimeException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers');
|
||||
throw new DeploymentException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2322,8 +2363,8 @@ 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' => [
|
||||
|
|
@ -3001,55 +3042,66 @@ private function graceful_shutdown_container(string $containerName)
|
|||
|
||||
private function stop_running_container(bool $force = false)
|
||||
{
|
||||
$this->application_deployment_queue->addLogEntry('Removing old containers.');
|
||||
if ($this->newVersionIsHealthy || $force) {
|
||||
if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
||||
$this->graceful_shutdown_container($this->container_name);
|
||||
} else {
|
||||
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
|
||||
if ($this->pull_request_id === 0) {
|
||||
$containers = $containers->filter(function ($container) {
|
||||
return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id);
|
||||
try {
|
||||
$this->application_deployment_queue->addLogEntry('Removing old containers.');
|
||||
if ($this->newVersionIsHealthy || $force) {
|
||||
if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
||||
$this->graceful_shutdown_container($this->container_name);
|
||||
} else {
|
||||
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
|
||||
if ($this->pull_request_id === 0) {
|
||||
$containers = $containers->filter(function ($container) {
|
||||
return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id);
|
||||
});
|
||||
}
|
||||
$containers->each(function ($container) {
|
||||
$this->graceful_shutdown_container(data_get($container, 'Names'));
|
||||
});
|
||||
}
|
||||
$containers->each(function ($container) {
|
||||
$this->graceful_shutdown_container(data_get($container, 'Names'));
|
||||
});
|
||||
} else {
|
||||
if ($this->application->dockerfile || $this->application->build_pack === 'dockerfile' || $this->application->build_pack === 'dockerimage') {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
$this->application_deployment_queue->addLogEntry("WARNING: Dockerfile or Docker Image based deployment detected. The healthcheck needs a curl or wget command to check the health of the application. Please make sure that it is available in the image or turn off healthcheck on Coolify's UI.");
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry('New container is not healthy, rolling back to the old container.');
|
||||
$this->failDeployment();
|
||||
$this->graceful_shutdown_container($this->container_name);
|
||||
}
|
||||
} else {
|
||||
if ($this->application->dockerfile || $this->application->build_pack === 'dockerfile' || $this->application->build_pack === 'dockerimage') {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
$this->application_deployment_queue->addLogEntry("WARNING: Dockerfile or Docker Image based deployment detected. The healthcheck needs a curl or wget command to check the health of the application. Please make sure that it is available in the image or turn off healthcheck on Coolify's UI.");
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry('New container is not healthy, rolling back to the old container.');
|
||||
$this->application_deployment_queue->update([
|
||||
'status' => ApplicationDeploymentStatus::FAILED->value,
|
||||
]);
|
||||
$this->graceful_shutdown_container($this->container_name);
|
||||
} catch (Exception $e) {
|
||||
throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function start_by_compose_file()
|
||||
{
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
|
||||
try {
|
||||
// Ensure .env file exists before docker compose tries to load it (defensive programming)
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} pull"), 'hidden' => true],
|
||||
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} up --build -d"), 'hidden' => true],
|
||||
["touch {$this->configuration_dir}/.env", 'hidden' => true],
|
||||
);
|
||||
} else {
|
||||
if ($this->use_build_server) {
|
||||
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
|
||||
$this->execute_remote_command(
|
||||
["{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true],
|
||||
[executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} pull"), 'hidden' => true],
|
||||
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} up --build -d"), 'hidden' => true],
|
||||
);
|
||||
} else {
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up --build -d"), 'hidden' => true],
|
||||
);
|
||||
if ($this->use_build_server) {
|
||||
$this->execute_remote_command(
|
||||
["{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true],
|
||||
);
|
||||
} else {
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up --build -d"), 'hidden' => true],
|
||||
);
|
||||
}
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry('New container started.');
|
||||
} catch (Exception $e) {
|
||||
throw new DeploymentException("Failed to start container: {$e->getMessage()}", $e->getCode(), $e);
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry('New container started.');
|
||||
}
|
||||
|
||||
private function analyzeBuildTimeVariables($variables)
|
||||
|
|
@ -3229,6 +3281,20 @@ 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) {
|
||||
|
|
@ -3241,6 +3307,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()
|
||||
|
|
@ -3249,9 +3327,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
|
||||
|
|
@ -3261,9 +3339,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
|
||||
|
|
@ -3273,9 +3349,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
|
||||
|
|
@ -3285,15 +3361,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"));
|
||||
|
|
@ -3608,7 +3692,7 @@ private function run_pre_deployment_command()
|
|||
return;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
|
||||
throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
|
||||
}
|
||||
|
||||
private function run_post_deployment_command()
|
||||
|
|
@ -3644,7 +3728,7 @@ private function run_post_deployment_command()
|
|||
return;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('Post-deployment command: Could not find a valid container. Is the container name correct?');
|
||||
throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -3655,51 +3739,153 @@ private function checkForCancellation(): void
|
|||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
|
||||
throw new \RuntimeException('Deployment cancelled by user', 69420);
|
||||
throw new DeploymentException('Deployment cancelled by user', 69420);
|
||||
}
|
||||
}
|
||||
|
||||
private function next(string $status)
|
||||
/**
|
||||
* Transition deployment to a new status with proper validation and side effects.
|
||||
* This is the single source of truth for status transitions.
|
||||
*/
|
||||
private function transitionToStatus(ApplicationDeploymentStatus $status): void
|
||||
{
|
||||
// Refresh to get latest status
|
||||
$this->application_deployment_queue->refresh();
|
||||
|
||||
// Never allow changing status from FAILED or CANCELLED_BY_USER to anything else
|
||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
|
||||
$this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview));
|
||||
|
||||
if ($this->isInTerminalState()) {
|
||||
return;
|
||||
}
|
||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
// Job was cancelled, stop execution
|
||||
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
|
||||
throw new \RuntimeException('Deployment cancelled by user', 69420);
|
||||
}
|
||||
|
||||
$this->application_deployment_queue->update([
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
$this->updateDeploymentStatus($status);
|
||||
$this->handleStatusTransition($status);
|
||||
queue_next_deployment($this->application);
|
||||
}
|
||||
|
||||
if ($status === ApplicationDeploymentStatus::FINISHED->value) {
|
||||
event(new ApplicationConfigurationChanged($this->application->team()->id));
|
||||
/**
|
||||
* Check if deployment is in a terminal state (FAILED or CANCELLED).
|
||||
* Terminal states cannot be changed.
|
||||
*/
|
||||
private function isInTerminalState(): bool
|
||||
{
|
||||
$this->application_deployment_queue->refresh();
|
||||
|
||||
if (! $this->only_this_server) {
|
||||
$this->deploy_to_additional_destinations();
|
||||
}
|
||||
$this->application->environment->project->team?->notify(new DeploymentSuccess($this->application, $this->deployment_uuid, $this->preview));
|
||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
|
||||
throw new DeploymentException('Deployment cancelled by user', 69420);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the deployment status in the database.
|
||||
*/
|
||||
private function updateDeploymentStatus(ApplicationDeploymentStatus $status): void
|
||||
{
|
||||
$this->application_deployment_queue->update([
|
||||
'status' => $status->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute status-specific side effects (events, notifications, additional deployments).
|
||||
*/
|
||||
private function handleStatusTransition(ApplicationDeploymentStatus $status): void
|
||||
{
|
||||
match ($status) {
|
||||
ApplicationDeploymentStatus::FINISHED => $this->handleSuccessfulDeployment(),
|
||||
ApplicationDeploymentStatus::FAILED => $this->handleFailedDeployment(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle side effects when deployment succeeds.
|
||||
*/
|
||||
private function handleSuccessfulDeployment(): void
|
||||
{
|
||||
event(new ApplicationConfigurationChanged($this->application->team()->id));
|
||||
|
||||
if (! $this->only_this_server) {
|
||||
$this->deploy_to_additional_destinations();
|
||||
}
|
||||
|
||||
$this->sendDeploymentNotification(DeploymentSuccess::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle side effects when deployment fails.
|
||||
*/
|
||||
private function handleFailedDeployment(): void
|
||||
{
|
||||
$this->sendDeploymentNotification(DeploymentFailed::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send deployment status notification to the team.
|
||||
*/
|
||||
private function sendDeploymentNotification(string $notificationClass): void
|
||||
{
|
||||
$this->application->environment->project->team?->notify(
|
||||
new $notificationClass($this->application, $this->deployment_uuid, $this->preview)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete deployment successfully.
|
||||
* Sends success notification and triggers additional deployments if needed.
|
||||
*/
|
||||
private function completeDeployment(): void
|
||||
{
|
||||
$this->transitionToStatus(ApplicationDeploymentStatus::FINISHED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail the deployment.
|
||||
* Sends failure notification and queues next deployment.
|
||||
*/
|
||||
protected function failDeployment(): void
|
||||
{
|
||||
$this->transitionToStatus(ApplicationDeploymentStatus::FAILED);
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
$this->next(ApplicationDeploymentStatus::FAILED->value);
|
||||
$this->application_deployment_queue->addLogEntry('Oops something is not okay, are you okay? 😢', 'stderr');
|
||||
if (str($exception->getMessage())->isNotEmpty()) {
|
||||
$this->application_deployment_queue->addLogEntry($exception->getMessage(), 'stderr');
|
||||
$this->failDeployment();
|
||||
|
||||
// Log comprehensive error information
|
||||
$errorMessage = $exception->getMessage() ?: 'Unknown error occurred';
|
||||
$errorCode = $exception->getCode();
|
||||
$errorClass = get_class($exception);
|
||||
|
||||
$this->application_deployment_queue->addLogEntry('========================================', 'stderr');
|
||||
$this->application_deployment_queue->addLogEntry("Deployment failed: {$errorMessage}", 'stderr');
|
||||
$this->application_deployment_queue->addLogEntry("Error type: {$errorClass}", 'stderr', hidden: true);
|
||||
$this->application_deployment_queue->addLogEntry("Error code: {$errorCode}", 'stderr', hidden: true);
|
||||
|
||||
// Log the exception file and line for debugging
|
||||
$this->application_deployment_queue->addLogEntry("Location: {$exception->getFile()}:{$exception->getLine()}", 'stderr', hidden: true);
|
||||
|
||||
// Log previous exceptions if they exist (for chained exceptions)
|
||||
$previous = $exception->getPrevious();
|
||||
if ($previous) {
|
||||
$this->application_deployment_queue->addLogEntry('Caused by:', 'stderr', hidden: true);
|
||||
$previousMessage = $previous->getMessage() ?: 'No message';
|
||||
$previousClass = get_class($previous);
|
||||
$this->application_deployment_queue->addLogEntry(" {$previousClass}: {$previousMessage}", 'stderr', hidden: true);
|
||||
$this->application_deployment_queue->addLogEntry(" at {$previous->getFile()}:{$previous->getLine()}", 'stderr', hidden: true);
|
||||
}
|
||||
|
||||
// Log first few lines of stack trace for debugging
|
||||
$trace = $exception->getTraceAsString();
|
||||
$traceLines = explode("\n", $trace);
|
||||
$this->application_deployment_queue->addLogEntry('Stack trace (first 5 lines):', 'stderr', hidden: true);
|
||||
foreach (array_slice($traceLines, 0, 5) as $traceLine) {
|
||||
$this->application_deployment_queue->addLogEntry(" {$traceLine}", 'stderr', hidden: true);
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry('========================================', 'stderr');
|
||||
|
||||
if ($this->application->build_pack !== 'dockercompose') {
|
||||
$code = $exception->getCode();
|
||||
if ($code !== 69420) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
|
|
@ -20,10 +22,51 @@ public function __construct(public Server $server) {}
|
|||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
// Get all active deployments on this server
|
||||
$activeDeployments = ApplicationDeploymentQueue::where('server_id', $this->server->id)
|
||||
->whereIn('status', [
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
])
|
||||
->pluck('deployment_uuid')
|
||||
->toArray();
|
||||
|
||||
\Log::info('CleanupHelperContainersJob - Active deployments', [
|
||||
'server' => $this->server->name,
|
||||
'active_deployment_uuids' => $activeDeployments,
|
||||
]);
|
||||
|
||||
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
|
||||
$containerIds = collect(json_decode($containers))->pluck('ID');
|
||||
if ($containerIds->count() > 0) {
|
||||
foreach ($containerIds as $containerId) {
|
||||
$helperContainers = collect(json_decode($containers));
|
||||
|
||||
if ($helperContainers->count() > 0) {
|
||||
foreach ($helperContainers as $container) {
|
||||
$containerId = data_get($container, 'ID');
|
||||
$containerName = data_get($container, 'Names');
|
||||
|
||||
// Check if this container belongs to an active deployment
|
||||
$isActiveDeployment = false;
|
||||
foreach ($activeDeployments as $deploymentUuid) {
|
||||
if (str_contains($containerName, $deploymentUuid)) {
|
||||
$isActiveDeployment = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isActiveDeployment) {
|
||||
\Log::info('CleanupHelperContainersJob - Skipping active deployment container', [
|
||||
'container' => $containerName,
|
||||
'id' => $containerId,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
\Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [
|
||||
'container' => $containerName,
|
||||
'id' => $containerId,
|
||||
]);
|
||||
|
||||
instant_remote_process_with_timeout(['docker container rm -f '.$containerId], $this->server, false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,35 @@
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\CoolifyTask\RunRemoteProcess;
|
||||
use App\Enums\ProcessStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public $tries = 3;
|
||||
|
||||
/**
|
||||
* The maximum number of unhandled exceptions to allow before failing.
|
||||
*/
|
||||
public $maxExceptions = 1;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
*/
|
||||
public $timeout = 600;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
|
|
@ -42,4 +59,36 @@ public function handle(): void
|
|||
|
||||
$remote_process();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public function backoff(): array
|
||||
{
|
||||
return [30, 90, 180]; // 30s, 90s, 180s between retries
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
Log::channel('scheduled-errors')->error('CoolifyTask permanently failed', [
|
||||
'job' => 'CoolifyTask',
|
||||
'activity_id' => $this->activity->id,
|
||||
'server_uuid' => $this->activity->getExtraProperty('server_uuid'),
|
||||
'command_preview' => substr($this->activity->getExtraProperty('command') ?? '', 0, 200),
|
||||
'error' => $exception?->getMessage(),
|
||||
'total_attempts' => $this->attempts(),
|
||||
'trace' => $exception?->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// Update activity status to reflect permanent failure
|
||||
$this->activity->properties = $this->activity->properties->merge([
|
||||
'status' => ProcessStatus::ERROR->value,
|
||||
'error' => $exception?->getMessage() ?? 'Job permanently failed',
|
||||
'failed_at' => now()->toIso8601String(),
|
||||
]);
|
||||
$this->activity->save();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
|
@ -31,6 +32,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $maxExceptions = 1;
|
||||
|
||||
public ?Team $team = null;
|
||||
|
||||
public Server $server;
|
||||
|
|
@ -74,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
public function __construct(public ScheduledDatabaseBackup $backup)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
$this->timeout = $backup->timeout;
|
||||
$this->timeout = $backup->timeout ?? 3600;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
|
|
@ -653,24 +656,42 @@ 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}";
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [
|
||||
'job' => 'DatabaseBackupJob',
|
||||
'backup_id' => $this->backup->uuid,
|
||||
'database' => $this->database?->name ?? 'unknown',
|
||||
'database_type' => get_class($this->database ?? new \stdClass),
|
||||
'server' => $this->server?->name ?? 'unknown',
|
||||
'total_attempts' => $this->attempts(),
|
||||
'error' => $exception?->getMessage(),
|
||||
'trace' => $exception?->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
|
||||
|
||||
if ($log) {
|
||||
$log->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Job failed: '.($exception?->getMessage() ?? 'Unknown error'),
|
||||
'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
|
||||
'size' => 0,
|
||||
'filename' => null,
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Notify team about permanent failure
|
||||
if ($this->team) {
|
||||
$databaseName = $log?->database_name ?? 'unknown';
|
||||
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
|
||||
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,16 +124,54 @@ private function deleteApplicationPreview()
|
|||
$this->resource->delete();
|
||||
}
|
||||
|
||||
// Cancel any active deployments for this PR (same logic as API cancel_deployment)
|
||||
$activeDeployments = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->whereIn('status', [
|
||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->get();
|
||||
|
||||
foreach ($activeDeployments as $activeDeployment) {
|
||||
try {
|
||||
// Mark deployment as cancelled
|
||||
$activeDeployment->update([
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Add cancellation log entry
|
||||
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||
|
||||
// Check if helper container exists and kill it
|
||||
$deployment_uuid = $activeDeployment->deployment_uuid;
|
||||
$escapedDeploymentUuid = escapeshellarg($deployment_uuid);
|
||||
$checkCommand = "docker ps -a --filter name={$escapedDeploymentUuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process(["docker rm -f {$escapedDeploymentUuid}"], $server);
|
||||
$activeDeployment->addLogEntry('Deployment container stopped.');
|
||||
} else {
|
||||
$activeDeployment->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// Silently handle errors during deployment cancellation
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if ($server->isSwarm()) {
|
||||
instant_remote_process(["docker stack rm {$application->uuid}-{$pull_request_id}"], $server);
|
||||
$escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
|
||||
instant_remote_process(["docker stack rm {$escapedStackName}"], $server);
|
||||
} else {
|
||||
$containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray();
|
||||
$this->stopPreviewContainers($containers, $server);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Log the error but don't fail the job
|
||||
ray('Error stopping preview containers: '.$e->getMessage());
|
||||
\Log::warning('Error stopping preview containers for application '.$application->uuid.', PR #'.$pull_request_id.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
// Finally, force delete to trigger resource cleanup
|
||||
|
|
@ -156,7 +194,6 @@ private function stopPreviewContainers(array $containers, $server, int $timeout
|
|||
"docker stop --time=$timeout $containerList",
|
||||
"docker rm -f $containerList",
|
||||
];
|
||||
|
||||
instant_remote_process(
|
||||
command: $commands,
|
||||
server: $server,
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 1000;
|
||||
|
||||
public function __construct(public Server $server)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$latest_version = instanceSettings()->helper_version;
|
||||
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ public function middleware(): array
|
|||
{
|
||||
return [
|
||||
(new WithoutOverlapping('scheduled-job-manager'))
|
||||
->expireAfter(60) // Lock expires after 1 minute to prevent stale locks
|
||||
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
|
||||
->dontRelease(), // Don't re-queue on lock conflict
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,14 +18,30 @@
|
|||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ScheduledTaskJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public $tries = 3;
|
||||
|
||||
/**
|
||||
* The maximum number of unhandled exceptions to allow before failing.
|
||||
*/
|
||||
public $maxExceptions = 1;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
*/
|
||||
public $timeout = 300;
|
||||
|
||||
public Team $team;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public ScheduledTask $task;
|
||||
|
||||
|
|
@ -33,6 +49,11 @@ class ScheduledTaskJob implements ShouldQueue
|
|||
|
||||
public ?ScheduledTaskExecution $task_log = null;
|
||||
|
||||
/**
|
||||
* Store execution ID to survive job serialization for timeout handling.
|
||||
*/
|
||||
protected ?int $executionId = null;
|
||||
|
||||
public string $task_status = 'failed';
|
||||
|
||||
public ?string $task_output = null;
|
||||
|
|
@ -55,6 +76,9 @@ public function __construct($task)
|
|||
}
|
||||
$this->team = Team::findOrFail($task->team_id);
|
||||
$this->server_timezone = $this->getServerTimezone();
|
||||
|
||||
// Set timeout from task configuration
|
||||
$this->timeout = $this->task->timeout ?? 300;
|
||||
}
|
||||
|
||||
private function getServerTimezone(): string
|
||||
|
|
@ -70,11 +94,18 @@ private function getServerTimezone(): string
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
$startTime = Carbon::now();
|
||||
|
||||
try {
|
||||
$this->task_log = ScheduledTaskExecution::create([
|
||||
'scheduled_task_id' => $this->task->id,
|
||||
'started_at' => $startTime,
|
||||
'retry_count' => $this->attempts() - 1,
|
||||
]);
|
||||
|
||||
// Store execution ID for timeout handling
|
||||
$this->executionId = $this->task_log->id;
|
||||
|
||||
$this->server = $this->resource->destination->server;
|
||||
|
||||
if ($this->resource->type() === 'application') {
|
||||
|
|
@ -129,15 +160,101 @@ public function handle(): void
|
|||
'message' => $this->task_output ?? $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
$this->team?->notify(new TaskFailed($this->task, $e->getMessage()));
|
||||
|
||||
// Log the error to the scheduled-errors channel
|
||||
Log::channel('scheduled-errors')->error('ScheduledTask execution failed', [
|
||||
'job' => 'ScheduledTaskJob',
|
||||
'task_id' => $this->task->uuid,
|
||||
'task_name' => $this->task->name,
|
||||
'server' => $this->server?->name ?? 'unknown',
|
||||
'attempt' => $this->attempts(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// Only notify and throw on final failure
|
||||
|
||||
// Re-throw to trigger Laravel's retry mechanism with backoff
|
||||
throw $e;
|
||||
} finally {
|
||||
ScheduledTaskDone::dispatch($this->team->id);
|
||||
if ($this->task_log) {
|
||||
$finishedAt = Carbon::now();
|
||||
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
|
||||
|
||||
$this->task_log->update([
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
'finished_at' => $finishedAt->toImmutable(),
|
||||
'duration' => $duration,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public function backoff(): array
|
||||
{
|
||||
return [30, 60, 120]; // 30s, 60s, 120s between retries
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
|
||||
'job' => 'ScheduledTaskJob',
|
||||
'task_id' => $this->task->uuid,
|
||||
'task_name' => $this->task->name,
|
||||
'server' => $this->server?->name ?? 'unknown',
|
||||
'total_attempts' => $this->attempts(),
|
||||
'error' => $exception?->getMessage(),
|
||||
'trace' => $exception?->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// Reload execution log from database
|
||||
// When a job times out, failed() is called in a fresh process with the original
|
||||
// queue payload, so $executionId will be null. We need to query for the latest execution.
|
||||
$execution = null;
|
||||
|
||||
// Try to find execution using stored ID first (works for non-timeout failures)
|
||||
if ($this->executionId) {
|
||||
$execution = ScheduledTaskExecution::find($this->executionId);
|
||||
}
|
||||
|
||||
// If no stored ID or not found, query for the most recent execution log for this task
|
||||
if (! $execution) {
|
||||
$execution = ScheduledTaskExecution::query()
|
||||
->where('scheduled_task_id', $this->task->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
}
|
||||
|
||||
// Last resort: check task_log property
|
||||
if (! $execution && $this->task_log) {
|
||||
$execution = $this->task_log;
|
||||
}
|
||||
|
||||
if ($execution) {
|
||||
$errorMessage = 'Job permanently failed after '.$this->attempts().' attempts';
|
||||
if ($exception) {
|
||||
$errorMessage .= ': '.$exception->getMessage();
|
||||
}
|
||||
|
||||
$execution->update([
|
||||
'status' => 'failed',
|
||||
'message' => $errorMessage,
|
||||
'error_details' => $exception?->getTraceAsString(),
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
]);
|
||||
} else {
|
||||
Log::channel('scheduled-errors')->warning('Could not find execution log to update', [
|
||||
'execution_id' => $this->executionId,
|
||||
'task_id' => $this->task->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
// Notify team about permanent failure
|
||||
$this->team?->notify(new TaskFailed($this->task, $exception?->getMessage() ?? 'Unknown error'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ private function dispatchConnectionChecks(Collection $servers): void
|
|||
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => get_class($e).': '.$e->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
|
@ -103,7 +103,7 @@ private function processScheduledTasks(Collection $servers): void
|
|||
Log::channel('scheduled-errors')->error('Error processing server tasks', [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => get_class($e).': '.$e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Concerns;
|
||||
|
||||
trait SynchronizesModelData
|
||||
{
|
||||
/**
|
||||
* Define the mapping between component properties and model keys.
|
||||
*
|
||||
* @return array<string, string> Array mapping property names to model keys (e.g., ['content' => 'fileStorage.content'])
|
||||
*/
|
||||
abstract protected function getModelBindings(): array;
|
||||
|
||||
/**
|
||||
* Synchronize component properties TO the model.
|
||||
* Copies values from component properties to the model.
|
||||
*/
|
||||
protected function syncToModel(): void
|
||||
{
|
||||
foreach ($this->getModelBindings() as $property => $modelKey) {
|
||||
data_set($this, $modelKey, $this->{$property});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize component properties FROM the model.
|
||||
* Copies values from the model to component properties.
|
||||
*/
|
||||
protected function syncFromModel(): void
|
||||
{
|
||||
foreach ($this->getModelBindings() as $property => $modelKey) {
|
||||
$this->{$property} = data_get($this, $modelKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,22 @@ 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->resetDefaultLabels(false);
|
||||
}
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
if ($this->buildPack === 'dockercompose') {
|
||||
// Only update if user has permission
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
|
|
@ -549,22 +653,10 @@ public function updatedBuildPack()
|
|||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User doesn't have update permission, just continue without saving
|
||||
}
|
||||
} else {
|
||||
// Clear Docker Compose specific data when switching away from dockercompose
|
||||
if ($this->application->getOriginal('build_pack') === 'dockercompose') {
|
||||
$this->application->docker_compose_domains = null;
|
||||
$this->application->docker_compose_raw = null;
|
||||
|
||||
// Remove SERVICE_FQDN_* and SERVICE_URL_* environment variables
|
||||
$this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete();
|
||||
$this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
|
||||
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete();
|
||||
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
|
||||
}
|
||||
}
|
||||
if ($this->build_pack === 'static') {
|
||||
$this->ports_exposes = 80;
|
||||
$this->application->ports_exposes = 80;
|
||||
if ($this->buildPack === 'static') {
|
||||
$this->portsExposes = '80';
|
||||
$this->application->ports_exposes = '80';
|
||||
$this->resetDefaultLabels(false);
|
||||
$this->generateNginxConfiguration();
|
||||
}
|
||||
|
|
@ -581,10 +673,10 @@ 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 +690,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 +704,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 +808,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 +828,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 +895,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 {
|
||||
|
|
@ -895,4 +986,23 @@ private function updateServiceEnvironmentVariables()
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getDetectedPortInfoProperty(): ?array
|
||||
{
|
||||
$detectedPort = $this->application->detectPortFromEnvironment();
|
||||
|
||||
if (! $detectedPort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$portsExposesArray = $this->application->ports_exposes_array;
|
||||
$isMatch = in_array($detectedPort, $portsExposesArray);
|
||||
$isEmpty = empty($portsExposesArray);
|
||||
|
||||
return [
|
||||
'port' => $detectedPort,
|
||||
'matches' => $isMatch,
|
||||
'isEmpty' => $isEmpty,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,11 +101,18 @@ public function deploy(bool $force_rebuild = false)
|
|||
force_rebuild: $force_rebuild,
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||
$this->dispatch('error', 'Deployment skipped', $result['message']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset restart count on successful deployment
|
||||
$this->application->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
|
||||
return $this->redirectRoute('project.application.deployment.show', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'application_uuid' => $this->parameters['application_uuid'],
|
||||
|
|
@ -137,6 +144,7 @@ public function restart()
|
|||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setDeploymentUuid();
|
||||
$result = queue_application_deployment(
|
||||
application: $this->application,
|
||||
|
|
@ -149,6 +157,13 @@ public function restart()
|
|||
return;
|
||||
}
|
||||
|
||||
// Reset restart count on manual restart
|
||||
$this->application->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => now(),
|
||||
'last_restart_type' => 'manual',
|
||||
]);
|
||||
|
||||
return $this->redirectRoute('project.application.deployment.show', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'application_uuid' => $this->parameters['application_uuid'],
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ class BackupEdit extends Component
|
|||
#[Validate(['required', 'boolean'])]
|
||||
public bool $dumpAll = false;
|
||||
|
||||
#[Validate(['required', 'int', 'min:1', 'max:36000'])]
|
||||
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
|
||||
public int $timeout = 3600;
|
||||
|
||||
public function mount()
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -2,14 +2,16 @@
|
|||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Livewire\Concerns\SynchronizesModelData;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class EditDomain extends Component
|
||||
{
|
||||
use SynchronizesModelData;
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $applicationId;
|
||||
|
||||
public ServiceApplication $application;
|
||||
|
|
@ -20,6 +22,13 @@ class EditDomain extends Component
|
|||
|
||||
public $forceSaveDomains = false;
|
||||
|
||||
public $showPortWarningModal = false;
|
||||
|
||||
public $forceRemovePort = false;
|
||||
|
||||
public $requiredPort = null;
|
||||
|
||||
#[Validate(['nullable'])]
|
||||
public ?string $fqdn = null;
|
||||
|
||||
protected $rules = [
|
||||
|
|
@ -28,16 +37,25 @@ class EditDomain extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$this->application = ServiceApplication::query()->findOrFail($this->applicationId);
|
||||
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
|
||||
$this->authorize('view', $this->application);
|
||||
$this->syncFromModel();
|
||||
$this->requiredPort = $this->application->getRequiredPort();
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
protected function getModelBindings(): array
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
return [
|
||||
'fqdn' => 'application.fqdn',
|
||||
];
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
||||
// Sync to model
|
||||
$this->application->fqdn = $this->fqdn;
|
||||
|
||||
$this->application->save();
|
||||
} else {
|
||||
// Sync from model
|
||||
$this->fqdn = $this->application->fqdn;
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmDomainUsage()
|
||||
|
|
@ -47,6 +65,19 @@ 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,44 @@ public function submit()
|
|||
$this->forceSaveDomains = false;
|
||||
}
|
||||
|
||||
// Check for required port
|
||||
if (! $this->forceRemovePort) {
|
||||
$requiredPort = $this->application->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
// Check if all FQDNs have a port
|
||||
$fqdns = str($this->fqdn)->trim()->explode(',');
|
||||
$missingPort = false;
|
||||
|
||||
foreach ($fqdns as $fqdn) {
|
||||
$fqdn = trim($fqdn);
|
||||
if (empty($fqdn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$port = ServiceApplication::extractPortFromUrl($fqdn);
|
||||
if ($port === null) {
|
||||
$missingPort = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingPort) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceRemovePort = false;
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
$this->application->save();
|
||||
$this->application->refresh();
|
||||
$this->syncFromModel();
|
||||
$this->syncData();
|
||||
updateCompose($this->application);
|
||||
if (str($this->application->fqdn)->contains(',')) {
|
||||
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
|
||||
|
|
@ -96,7 +161,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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->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,44 @@ public function submit()
|
|||
$this->forceSaveDomains = false;
|
||||
}
|
||||
|
||||
// Check for required port
|
||||
if (! $this->forceRemovePort) {
|
||||
$requiredPort = $this->application->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
// Check if all FQDNs have a port
|
||||
$fqdns = str($this->fqdn)->trim()->explode(',');
|
||||
$missingPort = false;
|
||||
|
||||
foreach ($fqdns as $fqdn) {
|
||||
$fqdn = trim($fqdn);
|
||||
if (empty($fqdn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$port = ServiceApplication::extractPortFromUrl($fqdn);
|
||||
if ($port === null) {
|
||||
$missingPort = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingPort) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceRemovePort = false;
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
$this->application->save();
|
||||
$this->application->refresh();
|
||||
$this->syncFromModel();
|
||||
$this->syncData();
|
||||
updateCompose($this->application);
|
||||
if (str($this->application->fqdn)->contains(',')) {
|
||||
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
|
||||
|
|
@ -224,7 +315,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Models\Service;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class StackForm extends Component
|
||||
|
|
@ -22,7 +23,7 @@ class StackForm extends Component
|
|||
|
||||
public string $dockerComposeRaw;
|
||||
|
||||
public string $dockerCompose;
|
||||
public ?string $dockerCompose = null;
|
||||
|
||||
public ?bool $connectToDockerNetwork = null;
|
||||
|
||||
|
|
@ -30,7 +31,7 @@ protected function rules(): array
|
|||
{
|
||||
$baseRules = [
|
||||
'dockerComposeRaw' => 'required',
|
||||
'dockerCompose' => 'required',
|
||||
'dockerCompose' => 'nullable',
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'connectToDockerNetwork' => 'nullable',
|
||||
|
|
@ -140,18 +141,27 @@ public function submit($notify = true)
|
|||
$this->validate();
|
||||
$this->syncData(true);
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
// Validate for command injection BEFORE any database operations
|
||||
validateDockerComposeForInjection($this->service->docker_compose_raw);
|
||||
|
||||
$this->service->save();
|
||||
$this->service->saveExtraFields($this->fields);
|
||||
$this->service->parse();
|
||||
// Use transaction to ensure atomicity - if parse fails, save is rolled back
|
||||
DB::transaction(function () {
|
||||
$this->service->save();
|
||||
$this->service->saveExtraFields($this->fields);
|
||||
$this->service->parse();
|
||||
});
|
||||
// Refresh and write files after a successful commit
|
||||
$this->service->refresh();
|
||||
$this->service->saveComposeConfigs();
|
||||
|
||||
$this->dispatch('refreshEnvs');
|
||||
$this->dispatch('refreshServices');
|
||||
$notify && $this->dispatch('success', 'Service saved.');
|
||||
} catch (\Throwable $e) {
|
||||
// On error, refresh from database to restore clean state
|
||||
$this->service->refresh();
|
||||
$this->syncData(false);
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
if (is_null($this->service->config_hash)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -34,11 +34,14 @@ class Add extends Component
|
|||
|
||||
public ?string $container = '';
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string',
|
||||
'command' => 'required|string',
|
||||
'frequency' => 'required|string',
|
||||
'container' => 'nullable|string',
|
||||
'timeout' => 'required|integer|min:60|max:3600',
|
||||
];
|
||||
|
||||
protected $validationAttributes = [
|
||||
|
|
@ -46,6 +49,7 @@ class Add extends Component
|
|||
'command' => 'command',
|
||||
'frequency' => 'frequency',
|
||||
'container' => 'container',
|
||||
'timeout' => 'timeout',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
|
|
@ -103,6 +107,7 @@ public function saveScheduledTask()
|
|||
$task->command = $this->command;
|
||||
$task->frequency = $this->frequency;
|
||||
$task->container = $this->container;
|
||||
$task->timeout = $this->timeout;
|
||||
$task->team_id = currentTeam()->id;
|
||||
|
||||
switch ($this->type) {
|
||||
|
|
@ -130,5 +135,6 @@ public function clear()
|
|||
$this->command = '';
|
||||
$this->frequency = '';
|
||||
$this->container = '';
|
||||
$this->timeout = 300;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ class Show extends Component
|
|||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $container = null;
|
||||
|
||||
#[Validate(['integer', 'required', 'min:60', 'max:3600'])]
|
||||
public $timeout = 300;
|
||||
|
||||
#[Locked]
|
||||
public ?string $application_uuid;
|
||||
|
||||
|
|
@ -99,6 +102,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->task->command = str($this->command)->trim()->value();
|
||||
$this->task->frequency = str($this->frequency)->trim()->value();
|
||||
$this->task->container = str($this->container)->trim()->value();
|
||||
$this->task->timeout = (int) $this->timeout;
|
||||
$this->task->save();
|
||||
} else {
|
||||
$this->isEnabled = $this->task->enabled;
|
||||
|
|
@ -106,6 +110,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->command = $this->task->command;
|
||||
$this->frequency = $this->task->frequency;
|
||||
$this->container = $this->task->container;
|
||||
$this->timeout = $this->task->timeout ?? 300;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,60 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -561,7 +561,12 @@ public function submit()
|
|||
$server->save();
|
||||
|
||||
if ($this->from_onboarding) {
|
||||
// When in onboarding, use wire:navigate for proper modal handling
|
||||
// Complete the boarding when server is successfully created via Hetzner
|
||||
currentTeam()->update([
|
||||
'show_boarding' => false,
|
||||
]);
|
||||
refreshSession();
|
||||
|
||||
return $this->redirect(route('server.show', $server->uuid));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!');
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]]);
|
||||
|
|
|
|||
|
|
@ -120,8 +120,9 @@ class Application extends BaseModel
|
|||
protected $appends = ['server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'custom_network_aliases' => 'array',
|
||||
'http_basic_auth_password' => 'encrypted',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
|
|
@ -175,6 +176,39 @@ protected static function booted()
|
|||
if (count($payload) > 0) {
|
||||
$application->forceFill($payload);
|
||||
}
|
||||
|
||||
// Buildpack switching cleanup logic
|
||||
if ($application->isDirty('build_pack')) {
|
||||
$originalBuildPack = $application->getOriginal('build_pack');
|
||||
|
||||
// Clear Docker Compose specific data when switching away from dockercompose
|
||||
if ($originalBuildPack === 'dockercompose') {
|
||||
$application->docker_compose_domains = null;
|
||||
$application->docker_compose_raw = null;
|
||||
|
||||
// Remove SERVICE_FQDN_* and SERVICE_URL_* environment variables
|
||||
$application->environment_variables()
|
||||
->where(function ($q) {
|
||||
$q->where('key', 'LIKE', 'SERVICE_FQDN_%')
|
||||
->orWhere('key', 'LIKE', 'SERVICE_URL_%');
|
||||
})
|
||||
->delete();
|
||||
$application->environment_variables_preview()
|
||||
->where(function ($q) {
|
||||
$q->where('key', 'LIKE', 'SERVICE_FQDN_%')
|
||||
->orWhere('key', 'LIKE', 'SERVICE_URL_%');
|
||||
})
|
||||
->delete();
|
||||
}
|
||||
|
||||
// Clear Dockerfile specific data when switching away from dockerfile
|
||||
if ($originalBuildPack === 'dockerfile') {
|
||||
$application->dockerfile = null;
|
||||
$application->dockerfile_location = null;
|
||||
$application->dockerfile_target_build = null;
|
||||
$application->custom_healthcheck_found = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
static::created(function ($application) {
|
||||
ApplicationSetting::create([
|
||||
|
|
@ -253,6 +287,30 @@ 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);
|
||||
}
|
||||
|
|
@ -749,6 +807,24 @@ public function main_port()
|
|||
return $this->settings->is_static ? [80] : $this->ports_exposes_array;
|
||||
}
|
||||
|
||||
public function detectPortFromEnvironment(?bool $isPreview = false): ?int
|
||||
{
|
||||
$envVars = $isPreview
|
||||
? $this->environment_variables_preview
|
||||
: $this->environment_variables;
|
||||
|
||||
$portVar = $envVars->firstWhere('key', 'PORT');
|
||||
|
||||
if ($portVar && $portVar->real_value) {
|
||||
$portValue = trim($portVar->real_value);
|
||||
if (is_numeric($portValue)) {
|
||||
return (int) $portValue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
|
|
@ -957,7 +1033,7 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Jobs\PullHelperImageJob;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Url\Url;
|
||||
|
|
@ -35,14 +34,6 @@ class InstanceSettings extends Model
|
|||
protected static function booted(): void
|
||||
{
|
||||
static::updated(function ($settings) {
|
||||
if ($settings->wasChanged('helper_version')) {
|
||||
Server::chunkById(100, function ($servers) {
|
||||
foreach ($servers as $server) {
|
||||
PullHelperImageJob::dispatch($server);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear trusted hosts cache when FQDN changes
|
||||
if ($settings->wasChanged('fqdn')) {
|
||||
\Cache::forget('instance_settings_fqdn_host');
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ class ScheduledTask extends BaseModel
|
|||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'timeout' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function service()
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@ class ScheduledTaskExecution extends BaseModel
|
|||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'retry_count' => 'integer',
|
||||
'duration' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduledTask(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ScheduledTask::class);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -1262,6 +1287,11 @@ public function workdir()
|
|||
|
||||
public function saveComposeConfigs()
|
||||
{
|
||||
// Guard against null or empty docker_compose
|
||||
if (! $this->docker_compose) {
|
||||
return;
|
||||
}
|
||||
|
||||
$workdir = $this->workdir();
|
||||
|
||||
instant_remote_process([
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@ public function fileStorages()
|
|||
return $this->morphMany(LocalFileVolume::class, 'resource');
|
||||
}
|
||||
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
|
||||
}
|
||||
|
||||
public function fqdns(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
|
@ -118,6 +123,53 @@ 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);
|
||||
|
|
@ -127,4 +179,77 @@ public function isBackupSolutionAvailable()
|
|||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the required port for this service application.
|
||||
* Extracts port from SERVICE_URL_* or SERVICE_FQDN_* environment variables
|
||||
* stored at the Service level, filtering by normalized container name.
|
||||
* Falls back to service-level port if no port-specific variable is found.
|
||||
*/
|
||||
public function getRequiredPort(): ?int
|
||||
{
|
||||
try {
|
||||
// Normalize container name same way as variable creation
|
||||
// (uppercase, replace - and . with _)
|
||||
$normalizedName = str($this->name)
|
||||
->upper()
|
||||
->replace('-', '_')
|
||||
->replace('.', '_')
|
||||
->value();
|
||||
// Get all environment variables from the service
|
||||
$serviceEnvVars = $this->service->environment_variables()->get();
|
||||
|
||||
// Look for SERVICE_FQDN_* or SERVICE_URL_* variables that match this container
|
||||
foreach ($serviceEnvVars as $envVar) {
|
||||
$key = str($envVar->key);
|
||||
|
||||
// Check if this is a SERVICE_FQDN_* or SERVICE_URL_* variable
|
||||
if (! $key->startsWith('SERVICE_FQDN_') && ! $key->startsWith('SERVICE_URL_')) {
|
||||
continue;
|
||||
}
|
||||
// Extract the part after SERVICE_FQDN_ or SERVICE_URL_
|
||||
if ($key->startsWith('SERVICE_FQDN_')) {
|
||||
$suffix = $key->after('SERVICE_FQDN_');
|
||||
} else {
|
||||
$suffix = $key->after('SERVICE_URL_');
|
||||
}
|
||||
|
||||
// Check if this variable starts with our normalized container name
|
||||
// Format: {NORMALIZED_NAME}_{PORT} or just {NORMALIZED_NAME}
|
||||
if (! $suffix->startsWith($normalizedName)) {
|
||||
\Log::debug('[ServiceApplication::getRequiredPort] Suffix does not match container', [
|
||||
'expected_start' => $normalizedName,
|
||||
'actual_suffix' => $suffix->value(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if there's a port suffix after the container name
|
||||
// The suffix should be exactly NORMALIZED_NAME or NORMALIZED_NAME_PORT
|
||||
$afterName = $suffix->after($normalizedName)->value();
|
||||
|
||||
// If there's content after the name, it should start with underscore
|
||||
if ($afterName !== '' && str($afterName)->startsWith('_')) {
|
||||
// Extract port: _3210 -> 3210
|
||||
$port = str($afterName)->after('_')->value();
|
||||
// Validate that the extracted port is numeric
|
||||
if (is_numeric($port)) {
|
||||
\Log::debug('[ServiceApplication::getRequiredPort] MATCH FOUND - Returning port', [
|
||||
'port' => (int) $port,
|
||||
]);
|
||||
|
||||
return (int) $port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to service-level port if no port-specific variable is found
|
||||
$fallbackPort = $this->service->getRequiredPort();
|
||||
|
||||
return $fallbackPort;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,10 @@ public function databaseType()
|
|||
$image = str($this->image)->before(':');
|
||||
if ($image->contains('supabase/postgres')) {
|
||||
$finalImage = 'supabase/postgres';
|
||||
} elseif ($image->contains('timescale')) {
|
||||
$finalImage = 'postgresql';
|
||||
} elseif ($image->contains('pgvector')) {
|
||||
$finalImage = 'postgresql';
|
||||
} elseif ($image->contains('postgres') || $image->contains('postgis')) {
|
||||
$finalImage = 'postgresql';
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'])) {
|
||||
|
|
|
|||
|
|
@ -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'])) {
|
||||
|
|
|
|||
|
|
@ -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'])) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -101,6 +101,38 @@ public function send(SendsEmail $notifiable, Notification $notification): void
|
|||
|
||||
$mailer->send($email);
|
||||
}
|
||||
} catch (\Resend\Exceptions\ErrorException $e) {
|
||||
// Map HTTP status codes to user-friendly messages
|
||||
$userMessage = match ($e->getErrorCode()) {
|
||||
403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.',
|
||||
401 => 'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.',
|
||||
429 => 'Resend rate limit exceeded. Please try again in a few minutes.',
|
||||
400 => 'Email validation failed: '.$e->getErrorMessage(),
|
||||
default => 'Failed to send email via Resend: '.$e->getErrorMessage(),
|
||||
};
|
||||
|
||||
// Log detailed error for admin debugging (redact sensitive data)
|
||||
$emailSettings = $notifiable->emailNotificationSettings ?? instanceSettings();
|
||||
data_set($emailSettings, 'smtp_password', '********');
|
||||
data_set($emailSettings, 'resend_api_key', '********');
|
||||
|
||||
send_internal_notification(sprintf(
|
||||
"Resend Error\nStatus Code: %s\nMessage: %s\nNotification: %s\nEmail Settings:\n%s",
|
||||
$e->getErrorCode(),
|
||||
$e->getErrorMessage(),
|
||||
get_class($notification),
|
||||
json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
|
||||
));
|
||||
|
||||
// Don't report expected errors (invalid keys, validation) to Sentry
|
||||
if (in_array($e->getErrorCode(), [403, 401, 400])) {
|
||||
throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e));
|
||||
}
|
||||
|
||||
throw new \Exception($userMessage, $e->getCode(), $e);
|
||||
} catch (\Resend\Exceptions\TransporterException $e) {
|
||||
send_internal_notification("Resend Transport Error: {$e->getMessage()}");
|
||||
throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.');
|
||||
} catch (\Throwable $e) {
|
||||
// Check if this is a Resend domain verification error on cloud instances
|
||||
if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) {
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@ class ServerPatchCheck extends CustomEmailNotification
|
|||
public function __construct(public Server $server, public array $patchData)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
$this->serverUrl = route('server.security.patches', ['server_uuid' => $this->server->uuid]);
|
||||
if (isDev()) {
|
||||
$this->serverUrl = 'https://staging-but-dev.coolify.io/server/'.$this->server->uuid.'/security/patches';
|
||||
}
|
||||
$this->serverUrl = base_url().'/server/'.$this->server->uuid.'/security/patches';
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -219,9 +219,22 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
|
|||
$process_result = $process->wait();
|
||||
if ($process_result->exitCode() !== 0) {
|
||||
if (! $ignore_errors) {
|
||||
// Check if deployment was cancelled while command was running
|
||||
if (isset($this->application_deployment_queue)) {
|
||||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
throw new \RuntimeException('Deployment cancelled by user', 69420);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't immediately set to FAILED - let the retry logic handle it
|
||||
// This prevents premature status changes during retryable SSH errors
|
||||
throw new \RuntimeException($process_result->errorOutput());
|
||||
$error = $process_result->errorOutput();
|
||||
if (empty($error)) {
|
||||
$error = $process_result->output() ?: 'Command failed with no error output';
|
||||
}
|
||||
$redactedCommand = $this->redact_sensitive_info($command);
|
||||
throw new \RuntimeException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue