v4.0.0-beta.442 (#7130)

This commit is contained in:
Andras Bacsai 2025-11-06 15:04:56 +01:00 committed by GitHub
commit 7bbfa094d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2088 additions and 547 deletions

View file

@ -4,6 +4,11 @@ on:
schedule:
- cron: '0 1 * * *'
permissions:
issues: write
discussions: write
pull-requests: write
jobs:
lock-threads:
runs-on: ubuntu-latest
@ -13,5 +18,5 @@ jobs:
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '30'
pr-inactive-days: '30'
discussion-inactive-days: '30'
pr-inactive-days: '30'

View file

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

View file

@ -3,20 +3,13 @@ on:
pull_request_target:
types:
- labeled
permissions:
pull-requests: write
jobs:
add-comment:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
actions: none
checks: none
deployments: none
issues: none
packages: none
repository-projects: none
security-events: none
statuses: none
strategy:
matrix:
include:

View file

@ -8,6 +8,10 @@ on:
pull_request_target:
types: [closed]
permissions:
issues: write
pull-requests: write
jobs:
remove-labels-and-assignees:
runs-on: ubuntu-latest

View file

@ -1,79 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
if: false
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Direct prompt for automated review (no @claude mention needed)
direct_prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Be constructive and helpful in your feedback.
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
# use_sticky_comment: true
# Optional: Customize review based on file types
# direct_prompt: |
# Review this PR focusing on:
# - For TypeScript files: Type safety and proper interface usage
# - For API endpoints: Security, input validation, and error handling
# - For React components: Performance, accessibility, and best practices
# - For tests: Coverage, edge cases, and test quality
# Optional: Different prompts for different authors
# direct_prompt: |
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
# Optional: Add specific tools for running tests or linting
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
# Optional: Skip review for certain conditions
# if: |
# !contains(github.event.pull_request.title, '[skip-review]') &&
# !contains(github.event.pull_request.title, '[WIP]')

View file

@ -1,65 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'Claude') ||
(github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'Claude') ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test

View file

@ -1,17 +1,14 @@
name: Cleanup Untagged GHCR Images
on:
workflow_dispatch: # Manual trigger only
workflow_dispatch:
env:
GITHUB_REGISTRY: ghcr.io
permissions:
packages: write
jobs:
cleanup-all-packages:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host']

View file

@ -7,6 +7,10 @@ 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
@ -15,11 +19,10 @@ env:
jobs:
amd64:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -54,11 +57,10 @@ jobs:
coolify.managed=true
aarch64:
runs-on: [ self-hosted, arm64 ]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -94,12 +96,12 @@ jobs:
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [ amd64, aarch64 ]
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 }}

View file

@ -7,6 +7,10 @@ on:
- .github/workflows/coolify-helper.yml
- docker/coolify-helper/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
@ -15,11 +19,10 @@ env:
jobs:
amd64:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -54,11 +57,10 @@ jobs:
coolify.managed=true
aarch64:
runs-on: [ self-hosted, arm64 ]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -93,12 +95,11 @@ jobs:
coolify.managed=true
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [ amd64, aarch64 ]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3

View file

@ -14,6 +14,10 @@ on:
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
@ -23,7 +27,9 @@ jobs:
amd64:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -58,7 +64,9 @@ jobs:
aarch64:
runs-on: [self-hosted, arm64]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -92,12 +100,11 @@ jobs:
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [amd64, aarch64]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3

View file

@ -11,6 +11,10 @@ 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
@ -19,11 +23,10 @@ env:
jobs:
amd64:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -59,11 +62,11 @@ jobs:
aarch64:
runs-on: [ self-hosted, arm64 ]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -99,12 +102,11 @@ jobs:
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [ amd64, aarch64 ]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3

View file

@ -11,6 +11,10 @@ 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
@ -19,11 +23,10 @@ env:
jobs:
amd64:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -59,11 +62,10 @@ jobs:
aarch64:
runs-on: [ self-hosted, arm64 ]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -99,12 +101,11 @@ jobs:
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [ amd64, aarch64 ]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3

View file

@ -17,6 +17,10 @@ on:
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
@ -34,11 +38,10 @@ jobs:
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
@ -82,11 +85,10 @@ jobs:
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize

View file

@ -7,6 +7,10 @@ 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
@ -15,11 +19,10 @@ env:
jobs:
amd64:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -50,11 +53,10 @@ jobs:
aarch64:
runs-on: [ self-hosted, arm64 ]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -85,12 +87,11 @@ jobs:
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
needs: [ amd64, aarch64 ]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3

View file

@ -16,6 +16,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Generate changelog

View file

@ -69,7 +69,6 @@ ## Big Sponsors
* [Dade2](https://dade2.net/?ref=coolify.io) - IT Consulting, Cloud Solutions & System Integration
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
* [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers

View file

@ -1,6 +1,6 @@
<?php
namespace App\Console\Commands;
namespace App\Console\Commands\Cloud;
use Illuminate\Console\Command;

View file

@ -26,9 +26,9 @@ class SyncBunny extends Command
protected $description = 'Sync files to BunnyCDN';
/**
* Fetch GitHub releases and sync to CDN
* Fetch GitHub releases and sync to GitHub repository
*/
private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn)
private function syncReleasesToGitHubRepo(): bool
{
$this->info('Fetching releases from GitHub...');
try {
@ -37,33 +37,122 @@ private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny
'per_page' => 30, // Fetch more releases for better changelog
]);
if ($response->successful()) {
$releases = $response->json();
// Save releases to a temporary file
$releases_file = "$parent_dir/releases.json";
file_put_contents($releases_file, json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
// Upload to CDN
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $releases_file)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/releases.json"),
$pool->purge("$bunny_cdn/coolify/releases.json"),
]);
// Clean up temporary file
unlink($releases_file);
$this->info('releases.json uploaded & purged...');
$this->info('Total releases synced: '.count($releases));
return true;
} else {
if (! $response->successful()) {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
$releases = $response->json();
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp;
$branchName = 'update-releases-'.$timestamp;
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write releases.json to: $releasesPath");
$this->error('Possible reasons: directory does not exist, permission denied, or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Stage and commit
$this->info('Committing changes...');
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('Releases are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
$commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR Output: '.implode("\n", $output));
}
$this->info('Total releases synced: '.count($releases));
return true;
} catch (\Throwable $e) {
$this->error('Error fetching releases: '.$e->getMessage());
$this->error('Error syncing releases: '.$e->getMessage());
return false;
}
@ -174,11 +263,7 @@ public function handle()
return;
}
// First sync GitHub releases
$this->info('Syncing GitHub releases first...');
$this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn);
// Then sync versions.json
// Sync versions.json to BunnyCDN
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
@ -187,14 +272,14 @@ public function handle()
return;
} elseif ($only_github_releases) {
$this->info('About to sync GitHub releases to BunnyCDN.');
$this->info('About to sync GitHub releases to GitHub repository.');
$confirmed = confirm('Are you sure you want to sync GitHub releases?');
if (! $confirmed) {
return;
}
// Use the reusable function
$this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn);
// Sync releases to GitHub repository
$this->syncReleasesToGitHubRepo();
return;
}

View file

@ -3226,6 +3226,20 @@ private function generate_secrets_hash($variables)
return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key);
}
protected function findFromInstructionLines($dockerfile): array
{
$fromLines = [];
foreach ($dockerfile as $index => $line) {
$trimmedLine = trim($line);
// Check if line starts with FROM (case-insensitive)
if (preg_match('/^FROM\s+/i', $trimmedLine)) {
$fromLines[] = $index;
}
}
return $fromLines;
}
private function add_build_env_variables_to_dockerfile()
{
if ($this->dockerBuildkitSupported) {
@ -3238,6 +3252,18 @@ private function add_build_env_variables_to_dockerfile()
'ignore_errors' => true,
]);
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
// Find all FROM instruction positions
$fromLines = $this->findFromInstructionLines($dockerfile);
// If no FROM instructions found, skip ARG insertion
if (empty($fromLines)) {
return;
}
// Collect all ARG statements to insert
$argsToInsert = collect();
if ($this->pull_request_id === 0) {
// Only add environment variables that are available during build
$envs = $this->application->environment_variables()
@ -3246,9 +3272,9 @@ private function add_build_env_variables_to_dockerfile()
->get();
foreach ($envs as $env) {
if (data_get($env, 'is_multiline') === true) {
$dockerfile->splice(1, 0, ["ARG {$env->key}"]);
$argsToInsert->push("ARG {$env->key}");
} else {
$dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
}
}
// Add Coolify variables as ARGs
@ -3258,9 +3284,7 @@ private function add_build_env_variables_to_dockerfile()
->map(function ($var) {
return "ARG {$var}";
});
foreach ($coolify_vars as $arg) {
$dockerfile->splice(1, 0, [$arg]);
}
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
} else {
// Only add preview environment variables that are available during build
@ -3270,9 +3294,9 @@ private function add_build_env_variables_to_dockerfile()
->get();
foreach ($envs as $env) {
if (data_get($env, 'is_multiline') === true) {
$dockerfile->splice(1, 0, ["ARG {$env->key}"]);
$argsToInsert->push("ARG {$env->key}");
} else {
$dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
}
}
// Add Coolify variables as ARGs
@ -3282,15 +3306,23 @@ private function add_build_env_variables_to_dockerfile()
->map(function ($var) {
return "ARG {$var}";
});
foreach ($coolify_vars as $arg) {
$dockerfile->splice(1, 0, [$arg]);
}
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
}
if ($envs->isNotEmpty()) {
$secrets_hash = $this->generate_secrets_hash($envs);
$dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]);
// Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers)
if ($argsToInsert->isNotEmpty()) {
foreach (array_reverse($fromLines) as $fromLineIndex) {
// Insert all ARGs after this FROM instruction
foreach ($argsToInsert->reverse() as $arg) {
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
}
}
$envs_mapped = $envs->mapWithKeys(function ($env) {
return [$env->key => $env->real_value];
});
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));

View file

@ -22,6 +22,12 @@ class EditDomain extends Component
public $forceSaveDomains = false;
public $showPortWarningModal = false;
public $forceRemovePort = false;
public $requiredPort = null;
#[Validate(['nullable'])]
public ?string $fqdn = null;
@ -33,6 +39,7 @@ public function mount()
{
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
$this->authorize('view', $this->application);
$this->requiredPort = $this->application->service->getRequiredPort();
$this->syncData();
}
@ -58,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 {
@ -91,6 +111,41 @@ public function submit()
$this->forceSaveDomains = false;
}
// Check for required port
if (! $this->forceRemovePort) {
$service = $this->application->service;
$requiredPort = $service->getRequiredPort();
if ($requiredPort !== null) {
// Check if all FQDNs have a port
$fqdns = str($this->fqdn)->trim()->explode(',');
$missingPort = false;
foreach ($fqdns as $fqdn) {
$fqdn = trim($fqdn);
if (empty($fqdn)) {
continue;
}
$port = ServiceApplication::extractPortFromUrl($fqdn);
if ($port === null) {
$missingPort = true;
break;
}
}
if ($missingPort) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
return;
}
}
} else {
// Reset the force flag after using it
$this->forceRemovePort = false;
}
$this->validate();
$this->application->save();
$this->application->refresh();

View file

@ -30,6 +30,12 @@ class ServiceApplicationView extends Component
public $forceSaveDomains = false;
public $showPortWarningModal = false;
public $forceRemovePort = false;
public $requiredPort = null;
#[Validate(['nullable'])]
public ?string $humanName = null;
@ -129,12 +135,26 @@ public function mount()
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
$this->requiredPort = $this->application->service->getRequiredPort();
$this->syncData();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
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 syncData(bool $toModel = false): void
{
if ($toModel) {
@ -246,6 +266,41 @@ public function submit()
$this->forceSaveDomains = false;
}
// Check for required port
if (! $this->forceRemovePort) {
$service = $this->application->service;
$requiredPort = $service->getRequiredPort();
if ($requiredPort !== null) {
// Check if all FQDNs have a port
$fqdns = str($this->fqdn)->trim()->explode(',');
$missingPort = false;
foreach ($fqdns as $fqdn) {
$fqdn = trim($fqdn);
if (empty($fqdn)) {
continue;
}
$port = ServiceApplication::extractPortFromUrl($fqdn);
if ($port === null) {
$missingPort = true;
break;
}
}
if ($missingPort) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
return;
}
}
} else {
// Reset the force flag after using it
$this->forceRemovePort = false;
}
$this->validate();
$this->application->save();
$this->application->refresh();

View file

@ -1184,6 +1184,31 @@ public function documentation()
return data_get($service, 'documentation', config('constants.urls.docs'));
}
/**
* Get the required port for this service from the template definition.
*/
public function getRequiredPort(): ?int
{
try {
$services = get_service_templates();
$serviceName = str($this->name)->beforeLast('-')->value();
$service = data_get($services, $serviceName, []);
$port = data_get($service, 'port');
return $port ? (int) $port : null;
} catch (\Throwable) {
return null;
}
}
/**
* Check if this service requires a port to function correctly.
*/
public function requiresPort(): bool
{
return $this->getRequiredPort() !== null;
}
public function applications()
{
return $this->hasMany(ServiceApplication::class);

View file

@ -118,6 +118,53 @@ public function fqdns(): Attribute
);
}
/**
* Extract port number from a given FQDN URL.
* Returns null if no port is specified.
*/
public static function extractPortFromUrl(string $url): ?int
{
try {
// Ensure URL has a scheme for proper parsing
if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) {
$url = 'http://'.$url;
}
$parsed = parse_url($url);
$port = $parsed['port'] ?? null;
return $port ? (int) $port : null;
} catch (\Throwable) {
return null;
}
}
/**
* Check if all FQDNs have a port specified.
*/
public function allFqdnsHavePort(): bool
{
if (is_null($this->fqdn) || $this->fqdn === '') {
return false;
}
$fqdns = explode(',', $this->fqdn);
foreach ($fqdns as $fqdn) {
$fqdn = trim($fqdn);
if (empty($fqdn)) {
continue;
}
$port = self::extractPortFromUrl($fqdn);
if ($port === null) {
return false;
}
}
return true;
}
public function getFilesFromServer(bool $isInit = false)
{
getFilesystemVolumesFromServer($this, $isInit);

View file

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

View file

@ -1164,13 +1164,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$environment = $environment->filter(function ($value, $key) {
return ! str($key)->startsWith('SERVICE_FQDN_');
})->map(function ($value, $key) use ($resource) {
// if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
if (str($value)->isEmpty()) {
if ($resource->environment_variables()->where('key', $key)->exists()) {
$value = $resource->environment_variables()->where('key', $key)->first()->value;
} else {
$value = null;
// Preserve empty strings and null values with correct Docker Compose semantics:
// - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy")
// - Null: Variable is unset/removed from container environment (may inherit from host)
if ($value === null) {
// User explicitly wants variable unset - respect that
// NEVER override from database - null means "inherit from environment"
// Keep as null (will be excluded from container environment)
} elseif ($value === '') {
// Empty string - allow database override for backward compatibility
$dbEnv = $resource->environment_variables()->where('key', $key)->first();
// Only use database override if it exists AND has a non-empty value
if ($dbEnv && str($dbEnv->value)->isNotEmpty()) {
$value = $dbEnv->value;
}
// Otherwise keep empty string as-is
}
return $value;
@ -1299,6 +1307,18 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
return array_search($key, $customOrder);
});
// Remove empty top-level sections (volumes, networks, configs, secrets)
// Keep only non-empty sections to match Docker Compose best practices
$topLevel = $topLevel->filter(function ($value, $key) {
// Always keep 'services' section
if ($key === 'services') {
return true;
}
// Keep section only if it has content
return $value instanceof Collection ? $value->isNotEmpty() : ! empty($value);
});
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
$resource->docker_compose = $cleanedCompose;
@ -1589,21 +1609,22 @@ function serviceParser(Service $resource): Collection
]);
}
if (substr_count(str($key)->value(), '_') === 3) {
$newKey = str($key)->beforeLast('_');
// For port-specific variables (e.g., SERVICE_FQDN_UMAMI_3000),
// keep the port suffix in the key and use the URL with port
$resource->environment_variables()->updateOrCreate([
'key' => $newKey->value(),
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'value' => $fqdnWithPort,
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
'key' => $newKey->value(),
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'value' => $urlWithPort,
'is_preview' => false,
]);
}
@ -2122,13 +2143,21 @@ function serviceParser(Service $resource): Collection
$environment = $environment->filter(function ($value, $key) {
return ! str($key)->startsWith('SERVICE_FQDN_');
})->map(function ($value, $key) use ($resource) {
// if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
if (str($value)->isEmpty()) {
if ($resource->environment_variables()->where('key', $key)->exists()) {
$value = $resource->environment_variables()->where('key', $key)->first()->value;
} else {
$value = null;
// Preserve empty strings and null values with correct Docker Compose semantics:
// - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy")
// - Null: Variable is unset/removed from container environment (may inherit from host)
if ($value === null) {
// User explicitly wants variable unset - respect that
// NEVER override from database - null means "inherit from environment"
// Keep as null (will be excluded from container environment)
} elseif ($value === '') {
// Empty string - allow database override for backward compatibility
$dbEnv = $resource->environment_variables()->where('key', $key)->first();
// Only use database override if it exists AND has a non-empty value
if ($dbEnv && str($dbEnv->value)->isNotEmpty()) {
$value = $dbEnv->value;
}
// Otherwise keep empty string as-is
}
return $value;
@ -2251,6 +2280,18 @@ function serviceParser(Service $resource): Collection
return array_search($key, $customOrder);
});
// Remove empty top-level sections (volumes, networks, configs, secrets)
// Keep only non-empty sections to match Docker Compose best practices
$topLevel = $topLevel->filter(function ($value, $key) {
// Always keep 'services' section
if ($key === 'services') {
return true;
}
// Keep section only if it has content
return $value instanceof Collection ? $value->isNotEmpty() : ! empty($value);
});
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
$resource->docker_compose = $cleanedCompose;

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"scripts": {
"onWorktreeCreate": "cp $GCOOL_ROOT_PATH/.env .",
"onWorktreeCreate": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json",
"run": "docker rm -f coolify coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite && spin up"
}
}

View file

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

View file

@ -82,7 +82,7 @@ @keyframes lds-heart {
*/
html,
body {
@apply w-full min-h-full bg-neutral-50 dark:bg-base dark:text-neutral-400;
@apply w-full min-h-full bg-gray-50 dark:bg-base dark:text-neutral-400;
}
body {

View file

@ -61,7 +61,7 @@ class="text-sm dark:text-neutral-400 hover:text-coollabs dark:hover:text-warning
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400 ">
Don't have an account?
</span>
</div>
@ -82,7 +82,7 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">or
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">or
continue with</span>
</div>
</div>

View file

@ -33,7 +33,8 @@ function getOldOrLocal($key, $localValue)
</svg>
<div>
<p class="font-bold text-warning">Root User Setup</p>
<p class="text-sm dark:text-white text-black">This user will be the root user with full admin access.</p>
<p class="text-sm dark:text-white text-black">This user will be the root user with full
admin access.</p>
</div>
</div>
</div>
@ -58,13 +59,16 @@ function getOldOrLocal($key, $localValue)
<x-forms.input id="password_confirmation" required type="password" name="password_confirmation"
label="{{ __('input.password.again') }}" />
<div class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<div
class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<p class="text-xs dark:text-neutral-400">
Your password should be min 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one symbol.
Your password should be min 8 characters long and contain at least one uppercase letter,
one lowercase letter, one number, and one symbol.
</p>
</div>
<x-forms.button class="w-full justify-center py-3 box-boarding mt-2" type="submit" isHighlighted>
<x-forms.button class="w-full justify-center py-3 box-boarding mt-2" type="submit"
isHighlighted>
Create Account
</x-forms.button>
</form>
@ -74,17 +78,18 @@ function getOldOrLocal($key, $localValue)
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">
Already have an account?
</span>
</div>
</div>
<a href="/login" class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
{{ __('auth.already_registered') }}
</a>
</div>
</div>
</div>
</section>
</x-layout-simple>
</x-layout-simple>

View file

@ -47,16 +47,19 @@
label="{{ __('input.email') }}" />
<x-forms.input required type="password" id="password" name="password"
label="{{ __('input.password') }}" />
<x-forms.input required type="password" id="password_confirmation"
name="password_confirmation" label="{{ __('input.password.again') }}" />
<x-forms.input required type="password" id="password_confirmation" name="password_confirmation"
label="{{ __('input.password.again') }}" />
<div class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<div
class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<p class="text-xs dark:text-neutral-400">
Your password should be min 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one symbol.
Your password should be min 8 characters long and contain at least one uppercase letter,
one lowercase letter, one number, and one symbol.
</p>
</div>
<x-forms.button class="w-full justify-center py-3 box-boarding mt-2" type="submit" isHighlighted>
<x-forms.button class="w-full justify-center py-3 box-boarding mt-2" type="submit"
isHighlighted>
{{ __('auth.reset_password') }}
</x-forms.button>
</form>
@ -66,17 +69,18 @@
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">
Remember your password?
</span>
</div>
</div>
<a href="/login" class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
Back to Login
</a>
</div>
</div>
</div>
</section>
</x-layout-simple>
</x-layout-simple>

View file

@ -120,7 +120,7 @@ class="mt-2 text-sm dark:text-neutral-400 hover:text-black dark:hover:text-white
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">
Need help?
</span>
</div>

View file

@ -2,7 +2,7 @@
<html data-theme="dark" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<script>
// Immediate theme application - runs before any rendering
(function() {
(function () {
const t = localStorage.theme || 'dark';
const d = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList[d ? 'add' : 'remove']('dark');
@ -75,102 +75,102 @@
</head>
@section('body')
<body>
<x-toast />
<script data-navigate-once>
// Global HTML sanitization function using DOMPurify
window.sanitizeHTML = function(html) {
if (!html) return '';
const URL_RE = /^(https?:|mailto:)/i;
const config = {
ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'p', 'pre', 's', 'span', 'strong',
'u'
],
ALLOWED_ATTR: ['class', 'href', 'target', 'title', 'rel'],
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ['script', 'object', 'embed', 'applet', 'iframe', 'form', 'input', 'button', 'select',
'textarea', 'details', 'summary', 'dialog', 'style'
],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange',
'onsubmit', 'ontoggle', 'style'
],
KEEP_CONTENT: true,
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
SANITIZE_DOM: true,
SANITIZE_NAMED_PROPS: true,
SAFE_FOR_TEMPLATES: true,
ALLOWED_URI_REGEXP: URL_RE
};
// One-time hook registration (idempotent pattern)
if (!window.__dpLinkHook) {
DOMPurify.addHook('afterSanitizeAttributes', node => {
// Remove Alpine.js directives to prevent XSS
if (node.hasAttributes && node.hasAttributes()) {
const attrs = Array.from(node.attributes);
attrs.forEach(attr => {
// Remove x-* attributes (Alpine directives)
if (attr.name.startsWith('x-')) {
node.removeAttribute(attr.name);
}
// Remove @* attributes (Alpine event shorthand)
if (attr.name.startsWith('@')) {
node.removeAttribute(attr.name);
}
// Remove :* attributes (Alpine binding shorthand)
if (attr.name.startsWith(':')) {
node.removeAttribute(attr.name);
}
});
}
// Existing link sanitization
if (node.nodeName === 'A' && node.hasAttribute('href')) {
const href = node.getAttribute('href') || '';
if (!URL_RE.test(href)) node.removeAttribute('href');
if (node.getAttribute('target') === '_blank') {
node.setAttribute('rel', 'noopener noreferrer');
}
}
});
window.__dpLinkHook = true;
}
return DOMPurify.sanitize(html, config);
<body class="dark:text-inherit text-black">
<x-toast />
<script data-navigate-once>
// Global HTML sanitization function using DOMPurify
window.sanitizeHTML = function (html) {
if (!html) return '';
const URL_RE = /^(https?:|mailto:)/i;
const config = {
ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'p', 'pre', 's', 'span', 'strong',
'u'
],
ALLOWED_ATTR: ['class', 'href', 'target', 'title', 'rel'],
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ['script', 'object', 'embed', 'applet', 'iframe', 'form', 'input', 'button', 'select',
'textarea', 'details', 'summary', 'dialog', 'style'
],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange',
'onsubmit', 'ontoggle', 'style'
],
KEEP_CONTENT: true,
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
SANITIZE_DOM: true,
SANITIZE_NAMED_PROPS: true,
SAFE_FOR_TEMPLATES: true,
ALLOWED_URI_REGEXP: URL_RE
};
// Initialize theme if not set
if (!('theme' in localStorage)) {
localStorage.theme = 'dark';
}
// One-time hook registration (idempotent pattern)
if (!window.__dpLinkHook) {
DOMPurify.addHook('afterSanitizeAttributes', node => {
// Remove Alpine.js directives to prevent XSS
if (node.hasAttributes && node.hasAttributes()) {
const attrs = Array.from(node.attributes);
attrs.forEach(attr => {
// Remove x-* attributes (Alpine directives)
if (attr.name.startsWith('x-')) {
node.removeAttribute(attr.name);
}
// Remove @* attributes (Alpine event shorthand)
if (attr.name.startsWith('@')) {
node.removeAttribute(attr.name);
}
// Remove :* attributes (Alpine binding shorthand)
if (attr.name.startsWith(':')) {
node.removeAttribute(attr.name);
}
});
}
let theme = localStorage.theme
let cpuColor = '#1e90ff'
let ramColor = '#00ced1'
let textColor = '#ffffff'
let editorBackground = '#181818'
let editorTheme = 'blackboard'
function checkTheme() {
theme = localStorage.theme
if (theme == 'system') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
if (theme == 'dark') {
cpuColor = '#1e90ff'
ramColor = '#00ced1'
textColor = '#ffffff'
editorBackground = '#181818'
editorTheme = 'blackboard'
} else {
cpuColor = '#1e90ff'
ramColor = '#00ced1'
textColor = '#000000'
editorBackground = '#ffffff'
editorTheme = null
}
// Existing link sanitization
if (node.nodeName === 'A' && node.hasAttribute('href')) {
const href = node.getAttribute('href') || '';
if (!URL_RE.test(href)) node.removeAttribute('href');
if (node.getAttribute('target') === '_blank') {
node.setAttribute('rel', 'noopener noreferrer');
}
}
});
window.__dpLinkHook = true;
}
@auth
return DOMPurify.sanitize(html, config);
};
// Initialize theme if not set
if (!('theme' in localStorage)) {
localStorage.theme = 'dark';
}
let theme = localStorage.theme
let cpuColor = '#1e90ff'
let ramColor = '#00ced1'
let textColor = '#ffffff'
let editorBackground = '#181818'
let editorTheme = 'blackboard'
function checkTheme() {
theme = localStorage.theme
if (theme == 'system') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
if (theme == 'dark') {
cpuColor = '#1e90ff'
ramColor = '#00ced1'
textColor = '#ffffff'
editorBackground = '#181818'
editorTheme = 'blackboard'
} else {
cpuColor = '#1e90ff'
ramColor = '#00ced1'
textColor = '#000000'
editorBackground = '#ffffff'
editorTheme = null
}
}
@auth
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'pusher',
@ -199,131 +199,131 @@ function checkTheme() {
// Maximum number of reconnection attempts
maxAttempts: 15
});
@endauth
let checkHealthInterval = null;
let checkIfIamDeadInterval = null;
@endauth
let checkHealthInterval = null;
let checkIfIamDeadInterval = null;
function changePasswordFieldType(event) {
let element = event.target
for (let i = 0; i < 10; i++) {
if (element.className === "relative") {
break;
}
element = element.parentElement;
function changePasswordFieldType(event) {
let element = event.target
for (let i = 0; i < 10; i++) {
if (element.className === "relative") {
break;
}
element = element.children[1];
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
if (element.type === 'password') {
element.type = 'text';
if (element.disabled) return;
element.classList.add('truncate');
this.type = 'text';
} else {
element.type = 'password';
if (element.disabled) return;
element.classList.remove('truncate');
this.type = 'password';
}
element = element.parentElement;
}
element = element.children[1];
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
if (element.type === 'password') {
element.type = 'text';
if (element.disabled) return;
element.classList.add('truncate');
this.type = 'text';
} else {
element.type = 'password';
if (element.disabled) return;
element.classList.remove('truncate');
this.type = 'password';
}
}
}
function copyToClipboard(text) {
navigator?.clipboard?.writeText(text) && window.Livewire.dispatch('success', 'Copied to clipboard.');
}
document.addEventListener('livewire:init', () => {
window.Livewire.on('reloadWindow', (timeout) => {
if (timeout) {
setTimeout(() => {
window.location.reload();
}, timeout);
return;
} else {
function copyToClipboard(text) {
navigator?.clipboard?.writeText(text) && window.Livewire.dispatch('success', 'Copied to clipboard.');
}
document.addEventListener('livewire:init', () => {
window.Livewire.on('reloadWindow', (timeout) => {
if (timeout) {
setTimeout(() => {
window.location.reload();
}
})
window.Livewire.on('info', (message) => {
if (typeof message === 'string') {
window.toast('Info', {
type: 'info',
description: message,
})
return;
}
if (message.length == 1) {
window.toast('Info', {
type: 'info',
description: message[0],
})
} else if (message.length == 2) {
window.toast(message[0], {
type: 'info',
description: message[1],
})
}
})
window.Livewire.on('error', (message) => {
if (typeof message === 'string') {
window.toast('Error', {
type: 'danger',
description: message,
})
return;
}
if (message.length == 1) {
window.toast('Error', {
type: 'danger',
description: message[0],
})
} else if (message.length == 2) {
window.toast(message[0], {
type: 'danger',
description: message[1],
})
}
})
window.Livewire.on('warning', (message) => {
if (typeof message === 'string') {
window.toast('Warning', {
type: 'warning',
description: message,
})
return;
}
if (message.length == 1) {
window.toast('Warning', {
type: 'warning',
description: message[0],
})
} else if (message.length == 2) {
window.toast(message[0], {
type: 'warning',
description: message[1],
})
}
})
window.Livewire.on('success', (message) => {
if (typeof message === 'string') {
window.toast('Success', {
type: 'success',
description: message,
})
return;
}
if (message.length == 1) {
window.toast('Success', {
type: 'success',
description: message[0],
})
} else if (message.length == 2) {
window.toast(message[0], {
type: 'success',
description: message[1],
})
}
})
});
</script>
</body>
}, timeout);
return;
} else {
window.location.reload();
}
})
window.Livewire.on('info', (message) => {
if (typeof message === 'string') {
window.toast('Info', {
type: 'info',
description: message,
})
return;
}
if (message.length == 1) {
window.toast('Info', {
type: 'info',
description: message[0],
})
} else if (message.length == 2) {
window.toast(message[0], {
type: 'info',
description: message[1],
})
}
})
window.Livewire.on('error', (message) => {
if (typeof message === 'string') {
window.toast('Error', {
type: 'danger',
description: message,
})
return;
}
if (message.length == 1) {
window.toast('Error', {
type: 'danger',
description: message[0],
})
} else if (message.length == 2) {
window.toast(message[0], {
type: 'danger',
description: message[1],
})
}
})
window.Livewire.on('warning', (message) => {
if (typeof message === 'string') {
window.toast('Warning', {
type: 'warning',
description: message,
})
return;
}
if (message.length == 1) {
window.toast('Warning', {
type: 'warning',
description: message[0],
})
} else if (message.length == 2) {
window.toast(message[0], {
type: 'warning',
description: message[1],
})
}
})
window.Livewire.on('success', (message) => {
if (typeof message === 'string') {
window.toast('Success', {
type: 'success',
description: message,
})
return;
}
if (message.length == 1) {
window.toast('Success', {
type: 'success',
description: message[0],
})
} else if (message.length == 2) {
window.toast(message[0], {
type: 'success',
description: message[1],
})
}
})
});
</script>
</body>
@show
</html>
</html>

View file

@ -1,7 +1,13 @@
<div class="w-full">
<form wire:submit.prevent='submit' class="flex flex-col w-full gap-2">
<div class="pb-2">Note: If a service has a defined port, do not delete it. <br>If you want to use your custom
domain, you can add it with a port.</div>
@if($requiredPort)
<x-callout type="warning" title="Required Port: {{ $requiredPort }}" class="mb-2">
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly. All domains must include this port number (or any other port if you know what you're doing).
<br><br>
<strong>Example:</strong> http://app.coolify.io:{{ $requiredPort }}
</x-callout>
@endif
<x-forms.input canGate="update" :canResource="$application" placeholder="https://app.coolify.io" label="Domains"
id="fqdn"
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "></x-forms.input>
@ -18,4 +24,61 @@
</ul>
</x-slot:consequences>
</x-domain-conflict-modal>
@if ($showPortWarningModal)
<div x-data="{ modalOpen: true }" x-init="$nextTick(() => { modalOpen = true })"
@keydown.escape.window="modalOpen = false; $wire.call('cancelRemovePort')"
:class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full py-6 border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300">
<div class="flex justify-between items-center pb-3">
<h2 class="pr-8 font-bold">Remove Required Port?</h2>
<button @click="modalOpen = false; $wire.call('cancelRemovePort')"
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="relative w-auto">
<x-callout type="warning" title="Port Requirement Warning" class="mb-4">
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly.
One or more of your domains are missing a port number.
</x-callout>
<x-callout type="danger" title="What will happen if you continue?" class="mb-4">
<ul class="mt-2 ml-4 list-disc">
<li>The service may become unreachable</li>
<li>The proxy may not be able to route traffic correctly</li>
<li>Environment variables may not be generated properly</li>
<li>The service may fail to start or function</li>
</ul>
</x-callout>
<div class="flex flex-wrap gap-2 justify-between mt-4">
<x-forms.button @click="modalOpen = false; $wire.call('cancelRemovePort')"
class="w-auto dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
Cancel - Keep Port
</x-forms.button>
<x-forms.button wire:click="confirmRemovePort" @click="modalOpen = false" class="w-auto"
isError>
I understand, remove port anyway
</x-forms.button>
</div>
</div>
</div>
</div>
</template>
</div>
@endif
</div>

View file

@ -22,6 +22,14 @@
@endcan
</div>
<div class="flex flex-col gap-2">
@if($requiredPort && !$application->serviceType()?->contains(str($application->image)->before(':')))
<x-callout type="warning" title="Required Port: {{ $requiredPort }}" class="mb-2">
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly. All domains must include this port number (or any other port if you know what you're doing).
<br><br>
<strong>Example:</strong> http://app.coolify.io:{{ $requiredPort }}
</x-callout>
@endif
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$application" label="Name" id="humanName"
placeholder="Human readable name"></x-forms.input>
@ -68,9 +76,9 @@
</div>
</form>
<x-domain-conflict-modal
:conflicts="$domainConflicts"
:showModal="$showDomainConflictModal"
<x-domain-conflict-modal
:conflicts="$domainConflicts"
:showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage">
<x-slot:consequences>
<ul class="mt-2 ml-4 list-disc">
@ -81,4 +89,61 @@
</ul>
</x-slot:consequences>
</x-domain-conflict-modal>
@if ($showPortWarningModal)
<div x-data="{ modalOpen: true }" x-init="$nextTick(() => { modalOpen = true })"
@keydown.escape.window="modalOpen = false; $wire.call('cancelRemovePort')"
:class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full py-6 border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300">
<div class="flex justify-between items-center pb-3">
<h2 class="pr-8 font-bold">Remove Required Port?</h2>
<button @click="modalOpen = false; $wire.call('cancelRemovePort')"
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="relative w-auto">
<x-callout type="warning" title="Port Requirement Warning" class="mb-4">
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly.
One or more of your domains are missing a port number.
</x-callout>
<x-callout type="danger" title="What will happen if you continue?" class="mb-4">
<ul class="mt-2 ml-4 list-disc">
<li>The service may become unreachable</li>
<li>The proxy may not be able to route traffic correctly</li>
<li>Environment variables may not be generated properly</li>
<li>The service may fail to start or function</li>
</ul>
</x-callout>
<div class="flex flex-wrap gap-2 justify-between mt-4">
<x-forms.button @click="modalOpen = false; $wire.call('cancelRemovePort')"
class="w-auto dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
Cancel - Keep Port
</x-forms.button>
<x-forms.button wire:click="confirmRemovePort" @click="modalOpen = false" class="w-auto"
isError>
I understand, remove port anyway
</x-forms.button>
</div>
</div>
</div>
</div>
</template>
</div>
@endif
</div>

View file

@ -1,7 +1,5 @@
<?php
use App\Models\PersonalAccessToken;
use App\Models\ScheduledDatabaseBackup;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;

View file

@ -0,0 +1,154 @@
<?php
use App\Livewire\Project\Service\EditDomain;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Livewire\Livewire;
beforeEach(function () {
// Create user and team
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
// Create server
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
// Create standalone docker destination
$this->destination = StandaloneDocker::factory()->create([
'server_id' => $this->server->id,
]);
// Create project and environment
$this->project = Project::factory()->create([
'team_id' => $this->team->id,
]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
// Create service with a name that maps to a template with required port
$this->service = Service::factory()->create([
'name' => 'supabase-test123',
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
// Create service application
$this->serviceApplication = ServiceApplication::factory()->create([
'service_id' => $this->service->id,
'fqdn' => 'http://example.com:8000',
]);
// Mock get_service_templates to return a service with required port
if (! function_exists('get_service_templates_mock')) {
function get_service_templates_mock()
{
return collect([
'supabase' => [
'name' => 'Supabase',
'port' => '8000',
'documentation' => 'https://supabase.com',
],
]);
}
}
});
it('loads the EditDomain component with required port', function () {
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
->assertSet('requiredPort', 8000)
->assertSet('fqdn', 'http://example.com:8000')
->assertOk();
});
it('shows warning modal when trying to remove required port', function () {
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
->set('fqdn', 'http://example.com') // Remove port
->call('submit')
->assertSet('showPortWarningModal', true)
->assertSet('requiredPort', 8000);
});
it('allows port removal when user confirms', function () {
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
->set('fqdn', 'http://example.com') // Remove port
->call('submit')
->assertSet('showPortWarningModal', true)
->call('confirmRemovePort')
->assertSet('showPortWarningModal', false);
// Verify the FQDN was updated in database
$this->serviceApplication->refresh();
expect($this->serviceApplication->fqdn)->toBe('http://example.com');
});
it('cancels port removal when user cancels', function () {
$originalFqdn = $this->serviceApplication->fqdn;
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
->set('fqdn', 'http://example.com') // Remove port
->call('submit')
->assertSet('showPortWarningModal', true)
->call('cancelRemovePort')
->assertSet('showPortWarningModal', false)
->assertSet('fqdn', $originalFqdn); // Should revert to original
});
it('allows saving when port is changed to different port', function () {
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
->set('fqdn', 'http://example.com:3000') // Change to different port
->call('submit')
->assertSet('showPortWarningModal', false); // Should not show warning
// Verify the FQDN was updated
$this->serviceApplication->refresh();
expect($this->serviceApplication->fqdn)->toBe('http://example.com:3000');
});
it('allows saving when all domains have ports (multiple domains)', function () {
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
->set('fqdn', 'http://example.com:8000,https://app.example.com:8080')
->call('submit')
->assertSet('showPortWarningModal', false); // Should not show warning
});
it('shows warning when at least one domain is missing port (multiple domains)', function () {
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
->set('fqdn', 'http://example.com:8000,https://app.example.com') // Second domain missing port
->call('submit')
->assertSet('showPortWarningModal', true);
});
it('does not show warning for services without required port', function () {
// Create a service without required port (e.g., cloudflared)
$serviceWithoutPort = Service::factory()->create([
'name' => 'cloudflared-test456',
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$appWithoutPort = ServiceApplication::factory()->create([
'service_id' => $serviceWithoutPort->id,
'fqdn' => 'http://example.com',
]);
Livewire::test(EditDomain::class, ['applicationId' => $appWithoutPort->id])
->set('fqdn', 'http://example.com') // No port
->call('submit')
->assertSet('showPortWarningModal', false); // Should not show warning
});

View file

@ -0,0 +1,296 @@
<?php
use Symfony\Component\Yaml\Yaml;
/**
* Unit tests to verify that environment variables with empty strings
* in Docker Compose files are preserved as empty strings, not converted to null.
*
* This is important because empty strings and null have different semantics in Docker:
* - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy")
* - Null: Variable is unset/removed from container environment
*
* See: https://github.com/coollabsio/coolify/issues/7126
*/
it('ensures parsers.php preserves empty strings in application parser', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Find the applicationParser function's environment mapping logic
$hasApplicationParser = str_contains($parsersFile, 'function applicationParser(');
expect($hasApplicationParser)->toBeTrue('applicationParser function should exist');
// The code should distinguish between null and empty string
// Check for the pattern where we explicitly check for null vs empty string
$hasNullCheck = str_contains($parsersFile, 'if ($value === null)');
$hasEmptyStringCheck = str_contains($parsersFile, "} elseif (\$value === '') {");
expect($hasNullCheck)->toBeTrue('Should have explicit null check');
expect($hasEmptyStringCheck)->toBeTrue('Should have explicit empty string check');
});
it('ensures parsers.php preserves empty strings in service parser', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Find the serviceParser function's environment mapping logic
$hasServiceParser = str_contains($parsersFile, 'function serviceParser(');
expect($hasServiceParser)->toBeTrue('serviceParser function should exist');
// The code should distinguish between null and empty string
// Same check as application parser
$hasNullCheck = str_contains($parsersFile, 'if ($value === null)');
$hasEmptyStringCheck = str_contains($parsersFile, "} elseif (\$value === '') {");
expect($hasNullCheck)->toBeTrue('Should have explicit null check');
expect($hasEmptyStringCheck)->toBeTrue('Should have explicit empty string check');
});
it('verifies YAML parsing preserves empty strings correctly', function () {
// Test that Symfony YAML parser handles empty strings as we expect
$yamlWithEmptyString = <<<'YAML'
environment:
HTTP_PROXY: ""
HTTPS_PROXY: ''
NO_PROXY: "localhost"
YAML;
$parsed = Yaml::parse($yamlWithEmptyString);
// Empty strings should remain as empty strings, not null
expect($parsed['environment']['HTTP_PROXY'])->toBe('');
expect($parsed['environment']['HTTPS_PROXY'])->toBe('');
expect($parsed['environment']['NO_PROXY'])->toBe('localhost');
});
it('verifies YAML parsing handles null values correctly', function () {
// Test that null values are preserved as null
$yamlWithNull = <<<'YAML'
environment:
HTTP_PROXY: null
HTTPS_PROXY:
NO_PROXY: "localhost"
YAML;
$parsed = Yaml::parse($yamlWithNull);
// Null should remain null
expect($parsed['environment']['HTTP_PROXY'])->toBeNull();
expect($parsed['environment']['HTTPS_PROXY'])->toBeNull();
expect($parsed['environment']['NO_PROXY'])->toBe('localhost');
});
it('verifies YAML serialization preserves empty strings', function () {
// Test that empty strings serialize back correctly
$data = [
'environment' => [
'HTTP_PROXY' => '',
'HTTPS_PROXY' => '',
'NO_PROXY' => 'localhost',
],
];
$yaml = Yaml::dump($data, 10, 2);
// Empty strings should be serialized with quotes
expect($yaml)->toContain("HTTP_PROXY: ''");
expect($yaml)->toContain("HTTPS_PROXY: ''");
expect($yaml)->toContain('NO_PROXY: localhost');
// Should NOT contain "null"
expect($yaml)->not->toContain('HTTP_PROXY: null');
});
it('verifies YAML serialization handles null values', function () {
// Test that null values serialize as null
$data = [
'environment' => [
'HTTP_PROXY' => null,
'HTTPS_PROXY' => null,
'NO_PROXY' => 'localhost',
],
];
$yaml = Yaml::dump($data, 10, 2);
// Null should be serialized as "null"
expect($yaml)->toContain('HTTP_PROXY: null');
expect($yaml)->toContain('HTTPS_PROXY: null');
expect($yaml)->toContain('NO_PROXY: localhost');
// Should NOT contain empty quotes for null values
expect($yaml)->not->toContain("HTTP_PROXY: ''");
});
it('verifies empty string round-trip through YAML', function () {
// Test full round-trip: empty string -> YAML -> parse -> serialize -> parse
$original = [
'environment' => [
'HTTP_PROXY' => '',
'NO_PROXY' => 'localhost',
],
];
// Serialize to YAML
$yaml1 = Yaml::dump($original, 10, 2);
// Parse back
$parsed1 = Yaml::parse($yaml1);
// Verify empty string is preserved
expect($parsed1['environment']['HTTP_PROXY'])->toBe('');
expect($parsed1['environment']['NO_PROXY'])->toBe('localhost');
// Serialize again
$yaml2 = Yaml::dump($parsed1, 10, 2);
// Parse again
$parsed2 = Yaml::parse($yaml2);
// Should still be empty string, not null
expect($parsed2['environment']['HTTP_PROXY'])->toBe('');
expect($parsed2['environment']['NO_PROXY'])->toBe('localhost');
// Both YAML representations should be equivalent
expect($yaml1)->toBe($yaml2);
});
it('verifies str()->isEmpty() behavior with empty strings and null', function () {
// Test Laravel's str()->isEmpty() helper behavior
// Empty string should be considered empty
expect(str('')->isEmpty())->toBeTrue();
// Null should be considered empty
expect(str(null)->isEmpty())->toBeTrue();
// String with content should not be empty
expect(str('value')->isEmpty())->toBeFalse();
// This confirms that we need additional logic to distinguish
// between empty string ('') and null, since both are "isEmpty"
});
it('verifies the distinction between empty string and null in PHP', function () {
// Document PHP's behavior for empty strings vs null
$emptyString = '';
$nullValue = null;
// They are different values
expect($emptyString === $nullValue)->toBeFalse();
// Empty string is not null
expect($emptyString === '')->toBeTrue();
expect($nullValue === null)->toBeTrue();
// isset() treats them differently
$arrayWithEmpty = ['key' => ''];
$arrayWithNull = ['key' => null];
expect(isset($arrayWithEmpty['key']))->toBeTrue();
expect(isset($arrayWithNull['key']))->toBeFalse();
});
it('verifies YAML null syntax options all produce PHP null', function () {
// Test all three ways to write null in YAML
$yamlWithNullSyntax = <<<'YAML'
environment:
VAR_NO_VALUE:
VAR_EXPLICIT_NULL: null
VAR_TILDE: ~
VAR_EMPTY_STRING: ""
YAML;
$parsed = Yaml::parse($yamlWithNullSyntax);
// All three null syntaxes should produce PHP null
expect($parsed['environment']['VAR_NO_VALUE'])->toBeNull();
expect($parsed['environment']['VAR_EXPLICIT_NULL'])->toBeNull();
expect($parsed['environment']['VAR_TILDE'])->toBeNull();
// Empty string should remain empty string
expect($parsed['environment']['VAR_EMPTY_STRING'])->toBe('');
});
it('verifies null round-trip through YAML', function () {
// Test full round-trip: null -> YAML -> parse -> serialize -> parse
$original = [
'environment' => [
'NULL_VAR' => null,
'EMPTY_VAR' => '',
'VALUE_VAR' => 'localhost',
],
];
// Serialize to YAML
$yaml1 = Yaml::dump($original, 10, 2);
// Parse back
$parsed1 = Yaml::parse($yaml1);
// Verify types are preserved
expect($parsed1['environment']['NULL_VAR'])->toBeNull();
expect($parsed1['environment']['EMPTY_VAR'])->toBe('');
expect($parsed1['environment']['VALUE_VAR'])->toBe('localhost');
// Serialize again
$yaml2 = Yaml::dump($parsed1, 10, 2);
// Parse again
$parsed2 = Yaml::parse($yaml2);
// Should still have correct types
expect($parsed2['environment']['NULL_VAR'])->toBeNull();
expect($parsed2['environment']['EMPTY_VAR'])->toBe('');
expect($parsed2['environment']['VALUE_VAR'])->toBe('localhost');
// Both YAML representations should be equivalent
expect($yaml1)->toBe($yaml2);
});
it('verifies null vs empty string behavior difference', function () {
// Document the critical difference between null and empty string
// Null in YAML
$yamlNull = "VAR: null\n";
$parsedNull = Yaml::parse($yamlNull);
expect($parsedNull['VAR'])->toBeNull();
// Empty string in YAML
$yamlEmpty = "VAR: \"\"\n";
$parsedEmpty = Yaml::parse($yamlEmpty);
expect($parsedEmpty['VAR'])->toBe('');
// They should NOT be equal
expect($parsedNull['VAR'] === $parsedEmpty['VAR'])->toBeFalse();
// Verify type differences
expect(is_null($parsedNull['VAR']))->toBeTrue();
expect(is_string($parsedEmpty['VAR']))->toBeTrue();
});
it('verifies parser logic distinguishes null from empty string', function () {
// Test the exact === comparison behavior
$nullValue = null;
$emptyString = '';
// PHP strict comparison
expect($nullValue === null)->toBeTrue();
expect($emptyString === '')->toBeTrue();
expect($nullValue === $emptyString)->toBeFalse();
// This is what the parser should use for correct behavior
if ($nullValue === null) {
$nullHandled = true;
} else {
$nullHandled = false;
}
if ($emptyString === '') {
$emptyHandled = true;
} else {
$emptyHandled = false;
}
expect($nullHandled)->toBeTrue();
expect($emptyHandled)->toBeTrue();
});

View file

@ -0,0 +1,194 @@
<?php
use Symfony\Component\Yaml\Yaml;
/**
* Unit tests to verify that empty top-level sections (volumes, configs, secrets)
* are removed from generated Docker Compose files.
*
* Empty sections like "volumes: { }" are not valid/clean YAML and should be omitted
* when they contain no actual content.
*/
it('ensures parsers.php filters empty top-level sections', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that filtering logic exists
expect($parsersFile)
->toContain('Remove empty top-level sections')
->toContain('->filter(function ($value, $key)');
});
it('verifies YAML dump produces empty objects for empty arrays', function () {
// Demonstrate the problem: empty arrays serialize as empty objects
$data = [
'services' => ['web' => ['image' => 'nginx']],
'volumes' => [],
'configs' => [],
'secrets' => [],
];
$yaml = Yaml::dump($data, 10, 2);
// Empty arrays become empty objects in YAML
expect($yaml)->toContain('volumes: { }');
expect($yaml)->toContain('configs: { }');
expect($yaml)->toContain('secrets: { }');
});
it('verifies YAML dump omits keys that are not present', function () {
// Demonstrate the solution: omit empty keys entirely
$data = [
'services' => ['web' => ['image' => 'nginx']],
// Don't include volumes, configs, secrets at all
];
$yaml = Yaml::dump($data, 10, 2);
// Keys that don't exist are not in the output
expect($yaml)->not->toContain('volumes:');
expect($yaml)->not->toContain('configs:');
expect($yaml)->not->toContain('secrets:');
expect($yaml)->toContain('services:');
});
it('verifies collection filter removes empty items', function () {
// Test Laravel Collection filter behavior
$collection = collect([
'services' => collect(['web' => ['image' => 'nginx']]),
'volumes' => collect([]),
'networks' => collect(['coolify' => ['external' => true]]),
'configs' => collect([]),
'secrets' => collect([]),
]);
$filtered = $collection->filter(function ($value, $key) {
// Always keep services
if ($key === 'services') {
return true;
}
// Keep only non-empty collections
return $value->isNotEmpty();
});
// Should have services and networks (non-empty)
expect($filtered)->toHaveKey('services');
expect($filtered)->toHaveKey('networks');
// Should NOT have volumes, configs, secrets (empty)
expect($filtered)->not->toHaveKey('volumes');
expect($filtered)->not->toHaveKey('configs');
expect($filtered)->not->toHaveKey('secrets');
});
it('verifies filtered collections serialize cleanly to YAML', function () {
// Full test: filter then serialize
$collection = collect([
'services' => collect(['web' => ['image' => 'nginx']]),
'volumes' => collect([]),
'networks' => collect(['coolify' => ['external' => true]]),
'configs' => collect([]),
'secrets' => collect([]),
]);
$filtered = $collection->filter(function ($value, $key) {
if ($key === 'services') {
return true;
}
return $value->isNotEmpty();
});
$yaml = Yaml::dump($filtered->toArray(), 10, 2);
// Should have services and networks
expect($yaml)->toContain('services:');
expect($yaml)->toContain('networks:');
// Should NOT have empty sections
expect($yaml)->not->toContain('volumes:');
expect($yaml)->not->toContain('configs:');
expect($yaml)->not->toContain('secrets:');
});
it('ensures services section is always kept even if empty', function () {
// Services should never be filtered out
$collection = collect([
'services' => collect([]),
'volumes' => collect([]),
]);
$filtered = $collection->filter(function ($value, $key) {
if ($key === 'services') {
return true; // Always keep
}
return $value->isNotEmpty();
});
// Services should be present
expect($filtered)->toHaveKey('services');
// Volumes should be removed
expect($filtered)->not->toHaveKey('volumes');
});
it('verifies non-empty sections are preserved', function () {
// Non-empty sections should remain
$collection = collect([
'services' => collect(['web' => ['image' => 'nginx']]),
'volumes' => collect(['data' => ['driver' => 'local']]),
'networks' => collect(['coolify' => ['external' => true]]),
'configs' => collect(['app_config' => ['file' => './config']]),
'secrets' => collect(['db_password' => ['file' => './secret']]),
]);
$filtered = $collection->filter(function ($value, $key) {
if ($key === 'services') {
return true;
}
return $value->isNotEmpty();
});
// All sections should be present (none are empty)
expect($filtered)->toHaveKey('services');
expect($filtered)->toHaveKey('volumes');
expect($filtered)->toHaveKey('networks');
expect($filtered)->toHaveKey('configs');
expect($filtered)->toHaveKey('secrets');
// Count should be 5 (all original keys)
expect($filtered->count())->toBe(5);
});
it('verifies mixed empty and non-empty sections', function () {
// Mixed scenario: some empty, some not
$collection = collect([
'services' => collect(['web' => ['image' => 'nginx']]),
'volumes' => collect([]), // Empty
'networks' => collect(['coolify' => ['external' => true]]), // Not empty
'configs' => collect([]), // Empty
'secrets' => collect(['db_password' => ['file' => './secret']]), // Not empty
]);
$filtered = $collection->filter(function ($value, $key) {
if ($key === 'services') {
return true;
}
return $value->isNotEmpty();
});
// Should have: services, networks, secrets
expect($filtered)->toHaveKey('services');
expect($filtered)->toHaveKey('networks');
expect($filtered)->toHaveKey('secrets');
// Should NOT have: volumes, configs
expect($filtered)->not->toHaveKey('volumes');
expect($filtered)->not->toHaveKey('configs');
// Count should be 3
expect($filtered->count())->toBe(3);
});

View file

@ -0,0 +1,218 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
/**
* Test the Dockerfile ARG insertion logic
* This tests the fix for GitHub issue #7118
*/
it('finds FROM instructions in simple dockerfile', function () {
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$dockerfile = collect([
'FROM node:16',
'WORKDIR /app',
'COPY . .',
]);
$result = $job->findFromInstructionLines($dockerfile);
expect($result)->toBe([0]);
});
it('finds FROM instructions with comments before', function () {
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$dockerfile = collect([
'# Build stage',
'# Another comment',
'FROM node:16',
'WORKDIR /app',
]);
$result = $job->findFromInstructionLines($dockerfile);
expect($result)->toBe([2]);
});
it('finds multiple FROM instructions in multi-stage dockerfile', function () {
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$dockerfile = collect([
'FROM node:16 AS builder',
'WORKDIR /app',
'RUN npm install',
'',
'FROM nginx:alpine',
'COPY --from=builder /app/dist /usr/share/nginx/html',
]);
$result = $job->findFromInstructionLines($dockerfile);
expect($result)->toBe([0, 4]);
});
it('handles FROM with different cases', function () {
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$dockerfile = collect([
'from node:16',
'From nginx:alpine',
'FROM alpine:latest',
]);
$result = $job->findFromInstructionLines($dockerfile);
expect($result)->toBe([0, 1, 2]);
});
it('returns empty array when no FROM instructions found', function () {
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$dockerfile = collect([
'# Just comments',
'WORKDIR /app',
'RUN npm install',
]);
$result = $job->findFromInstructionLines($dockerfile);
expect($result)->toBe([]);
});
it('inserts ARGs after FROM in simple dockerfile', function () {
$dockerfile = collect([
'FROM node:16',
'WORKDIR /app',
'COPY . .',
]);
$fromLines = [0];
$argsToInsert = collect(['ARG MY_VAR=value', 'ARG ANOTHER_VAR']);
foreach (array_reverse($fromLines) as $fromLineIndex) {
foreach ($argsToInsert->reverse() as $arg) {
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
}
}
expect($dockerfile[0])->toBe('FROM node:16');
expect($dockerfile[1])->toBe('ARG MY_VAR=value');
expect($dockerfile[2])->toBe('ARG ANOTHER_VAR');
expect($dockerfile[3])->toBe('WORKDIR /app');
});
it('inserts ARGs after each FROM in multi-stage dockerfile', function () {
$dockerfile = collect([
'FROM node:16 AS builder',
'WORKDIR /app',
'',
'FROM nginx:alpine',
'COPY --from=builder /app/dist /usr/share/nginx/html',
]);
$fromLines = [0, 3];
$argsToInsert = collect(['ARG MY_VAR=value']);
foreach (array_reverse($fromLines) as $fromLineIndex) {
foreach ($argsToInsert->reverse() as $arg) {
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
}
}
// First stage
expect($dockerfile[0])->toBe('FROM node:16 AS builder');
expect($dockerfile[1])->toBe('ARG MY_VAR=value');
expect($dockerfile[2])->toBe('WORKDIR /app');
// Second stage (index shifted by +1 due to inserted ARG)
expect($dockerfile[4])->toBe('FROM nginx:alpine');
expect($dockerfile[5])->toBe('ARG MY_VAR=value');
});
it('inserts ARGs after FROM when comments precede FROM', function () {
$dockerfile = collect([
'# Build stage comment',
'FROM node:16',
'WORKDIR /app',
]);
$fromLines = [1];
$argsToInsert = collect(['ARG MY_VAR=value']);
foreach (array_reverse($fromLines) as $fromLineIndex) {
foreach ($argsToInsert->reverse() as $arg) {
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
}
}
expect($dockerfile[0])->toBe('# Build stage comment');
expect($dockerfile[1])->toBe('FROM node:16');
expect($dockerfile[2])->toBe('ARG MY_VAR=value');
expect($dockerfile[3])->toBe('WORKDIR /app');
});
it('handles real-world nuxt multi-stage dockerfile with comments', function () {
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$dockerfile = collect([
'# Build Stage 1',
'',
'FROM node:22-alpine AS build',
'WORKDIR /app',
'',
'RUN corepack enable',
'',
'# Copy package.json and your lockfile, here we add pnpm-lock.yaml for illustration',
'COPY package.json pnpm-lock.yaml .npmrc ./',
'',
'# Install dependencies',
'RUN pnpm i',
'',
'# Copy the entire project',
'COPY . ./',
'',
'# Build the project',
'RUN pnpm run build',
'',
'# Build Stage 2',
'',
'FROM node:22-alpine',
'WORKDIR /app',
'',
'# Only `.output` folder is needed from the build stage',
'COPY --from=build /app/.output/ ./',
'',
'# Change the port and host',
'ENV PORT=80',
'ENV HOST=0.0.0.0',
'',
'EXPOSE 80',
'',
'CMD ["node", "/app/server/index.mjs"]',
]);
// Find FROM instructions
$fromLines = $job->findFromInstructionLines($dockerfile);
expect($fromLines)->toBe([2, 21]);
// Simulate ARG insertion
$argsToInsert = collect(['ARG BUILD_VAR=production']);
foreach (array_reverse($fromLines) as $fromLineIndex) {
foreach ($argsToInsert->reverse() as $arg) {
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
}
}
// Verify first stage
expect($dockerfile[2])->toBe('FROM node:22-alpine AS build');
expect($dockerfile[3])->toBe('ARG BUILD_VAR=production');
expect($dockerfile[4])->toBe('WORKDIR /app');
// Verify second stage (index shifted by +1 due to first ARG insertion)
expect($dockerfile[22])->toBe('FROM node:22-alpine');
expect($dockerfile[23])->toBe('ARG BUILD_VAR=production');
expect($dockerfile[24])->toBe('WORKDIR /app');
});

View file

@ -1,6 +1,5 @@
<?php
use App\Models\PrivateKey;
use App\Models\User;
use App\Policies\PrivateKeyPolicy;

View file

@ -0,0 +1,174 @@
<?php
/**
* Unit tests to verify that SERVICE_URL_* and SERVICE_FQDN_* variables
* with port suffixes are properly handled and populated.
*
* These variables should include the port number in both the key name and the URL value.
* Example: SERVICE_URL_UMAMI_3000 should be populated with http://domain.com:3000
*/
it('ensures parsers.php populates port-specific SERVICE variables', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that the fix is in place
$hasPortSpecificComment = str_contains($parsersFile, 'For port-specific variables');
$usesFqdnWithPort = str_contains($parsersFile, '$fqdnWithPort');
$usesUrlWithPort = str_contains($parsersFile, '$urlWithPort');
expect($hasPortSpecificComment)->toBeTrue('Should have comment about port-specific variables');
expect($usesFqdnWithPort)->toBeTrue('Should use $fqdnWithPort for port variables');
expect($usesUrlWithPort)->toBeTrue('Should use $urlWithPort for port variables');
});
it('verifies SERVICE_URL variable naming convention', function () {
// Test the naming convention for port-specific variables
// Base variable (no port): SERVICE_URL_UMAMI
$baseKey = 'SERVICE_URL_UMAMI';
expect(substr_count($baseKey, '_'))->toBe(2);
// Port-specific variable: SERVICE_URL_UMAMI_3000
$portKey = 'SERVICE_URL_UMAMI_3000';
expect(substr_count($portKey, '_'))->toBe(3);
// Extract service name
$serviceName = str($portKey)->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
expect($serviceName)->toBe('umami');
// Extract port
$port = str($portKey)->afterLast('_')->value();
expect($port)->toBe('3000');
});
it('verifies SERVICE_FQDN variable naming convention', function () {
// Test the naming convention for port-specific FQDN variables
// Base variable (no port): SERVICE_FQDN_POSTGRES
$baseKey = 'SERVICE_FQDN_POSTGRES';
expect(substr_count($baseKey, '_'))->toBe(2);
// Port-specific variable: SERVICE_FQDN_POSTGRES_5432
$portKey = 'SERVICE_FQDN_POSTGRES_5432';
expect(substr_count($portKey, '_'))->toBe(3);
// Extract service name
$serviceName = str($portKey)->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
expect($serviceName)->toBe('postgres');
// Extract port
$port = str($portKey)->afterLast('_')->value();
expect($port)->toBe('5432');
});
it('verifies URL with port format', function () {
// Test that URLs with ports are formatted correctly
$baseUrl = 'http://umami-abc123.domain.com';
$port = '3000';
$urlWithPort = "$baseUrl:$port";
expect($urlWithPort)->toBe('http://umami-abc123.domain.com:3000');
expect($urlWithPort)->toContain(':3000');
});
it('verifies FQDN with port format', function () {
// Test that FQDNs with ports are formatted correctly
$baseFqdn = 'postgres-xyz789.domain.com';
$port = '5432';
$fqdnWithPort = "$baseFqdn:$port";
expect($fqdnWithPort)->toBe('postgres-xyz789.domain.com:5432');
expect($fqdnWithPort)->toContain(':5432');
});
it('verifies port extraction from variable name', function () {
// Test extracting port from various variable names
$tests = [
'SERVICE_URL_APP_3000' => '3000',
'SERVICE_URL_API_8080' => '8080',
'SERVICE_FQDN_DB_5432' => '5432',
'SERVICE_FQDN_REDIS_6379' => '6379',
];
foreach ($tests as $varName => $expectedPort) {
$port = str($varName)->afterLast('_')->value();
expect($port)->toBe($expectedPort, "Port extraction failed for $varName");
}
});
it('verifies service name extraction with port suffix', function () {
// Test extracting service name when port is present
$tests = [
'SERVICE_URL_APP_3000' => 'app',
'SERVICE_URL_MY_API_8080' => 'my_api',
'SERVICE_FQDN_DB_5432' => 'db',
'SERVICE_FQDN_REDIS_CACHE_6379' => 'redis_cache',
];
foreach ($tests as $varName => $expectedService) {
if (str($varName)->startsWith('SERVICE_URL_')) {
$serviceName = str($varName)->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
} else {
$serviceName = str($varName)->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
}
expect($serviceName)->toBe($expectedService, "Service name extraction failed for $varName");
}
});
it('verifies distinction between base and port-specific variables', function () {
// Test that base and port-specific variables are different
$baseUrl = 'SERVICE_URL_UMAMI';
$portUrl = 'SERVICE_URL_UMAMI_3000';
expect($baseUrl)->not->toBe($portUrl);
expect(substr_count($baseUrl, '_'))->toBe(2);
expect(substr_count($portUrl, '_'))->toBe(3);
// Port-specific should contain port number
expect(str($portUrl)->contains('_3000'))->toBeTrue();
expect(str($baseUrl)->contains('_3000'))->toBeFalse();
});
it('verifies multiple port variables for same service', function () {
// Test that a service can have multiple port-specific variables
$service = 'api';
$ports = ['3000', '8080', '9090'];
foreach ($ports as $port) {
$varName = "SERVICE_URL_API_$port";
// Should have 3 underscores
expect(substr_count($varName, '_'))->toBe(3);
// Should extract correct service name
$serviceName = str($varName)->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
expect($serviceName)->toBe('api');
// Should extract correct port
$extractedPort = str($varName)->afterLast('_')->value();
expect($extractedPort)->toBe($port);
}
});
it('verifies common port numbers are handled correctly', function () {
// Test common port numbers used in applications
$commonPorts = [
'80' => 'HTTP',
'443' => 'HTTPS',
'3000' => 'Node.js/React',
'5432' => 'PostgreSQL',
'6379' => 'Redis',
'8080' => 'Alternative HTTP',
'9000' => 'PHP-FPM',
];
foreach ($commonPorts as $port => $description) {
$varName = "SERVICE_URL_APP_$port";
expect(substr_count($varName, '_'))->toBe(3, "Failed for $description port $port");
$extractedPort = str($varName)->afterLast('_')->value();
expect($extractedPort)->toBe((string) $port, "Port extraction failed for $description");
}
});

View file

@ -0,0 +1,153 @@
<?php
use App\Models\Service;
use App\Models\ServiceApplication;
use Mockery;
it('returns required port from service template', function () {
// Mock get_service_templates() function
$mockTemplates = collect([
'supabase' => [
'name' => 'Supabase',
'port' => '8000',
],
'umami' => [
'name' => 'Umami',
'port' => '3000',
],
]);
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'supabase-xyz123';
// Mock the get_service_templates function to return our mock data
$service->shouldReceive('getRequiredPort')->andReturn(8000);
expect($service->getRequiredPort())->toBe(8000);
});
it('returns null for service without required port', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'cloudflared-xyz123';
// Mock to return null for services without port
$service->shouldReceive('getRequiredPort')->andReturn(null);
expect($service->getRequiredPort())->toBeNull();
});
it('requiresPort returns true when service has required port', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('getRequiredPort')->andReturn(8000);
$service->shouldReceive('requiresPort')->andReturnUsing(function () use ($service) {
return $service->getRequiredPort() !== null;
});
expect($service->requiresPort())->toBeTrue();
});
it('requiresPort returns false when service has no required port', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('getRequiredPort')->andReturn(null);
$service->shouldReceive('requiresPort')->andReturnUsing(function () use ($service) {
return $service->getRequiredPort() !== null;
});
expect($service->requiresPort())->toBeFalse();
});
it('extracts port from URL with http scheme', function () {
$url = 'http://example.com:3000';
$port = ServiceApplication::extractPortFromUrl($url);
expect($port)->toBe(3000);
});
it('extracts port from URL with https scheme', function () {
$url = 'https://example.com:8080';
$port = ServiceApplication::extractPortFromUrl($url);
expect($port)->toBe(8080);
});
it('extracts port from URL without scheme', function () {
$url = 'example.com:5000';
$port = ServiceApplication::extractPortFromUrl($url);
expect($port)->toBe(5000);
});
it('returns null for URL without port', function () {
$url = 'http://example.com';
$port = ServiceApplication::extractPortFromUrl($url);
expect($port)->toBeNull();
});
it('returns null for URL without port and without scheme', function () {
$url = 'example.com';
$port = ServiceApplication::extractPortFromUrl($url);
expect($port)->toBeNull();
});
it('handles invalid URLs gracefully', function () {
$url = 'not-a-valid-url:::';
$port = ServiceApplication::extractPortFromUrl($url);
expect($port)->toBeNull();
});
it('checks if all FQDNs have port - single FQDN with port', function () {
$app = Mockery::mock(ServiceApplication::class)->makePartial();
$app->fqdn = 'http://example.com:3000';
$result = $app->allFqdnsHavePort();
expect($result)->toBeTrue();
});
it('checks if all FQDNs have port - single FQDN without port', function () {
$app = Mockery::mock(ServiceApplication::class)->makePartial();
$app->fqdn = 'http://example.com';
$result = $app->allFqdnsHavePort();
expect($result)->toBeFalse();
});
it('checks if all FQDNs have port - multiple FQDNs all with ports', function () {
$app = Mockery::mock(ServiceApplication::class)->makePartial();
$app->fqdn = 'http://example.com:3000,https://example.org:8080';
$result = $app->allFqdnsHavePort();
expect($result)->toBeTrue();
});
it('checks if all FQDNs have port - multiple FQDNs one without port', function () {
$app = Mockery::mock(ServiceApplication::class)->makePartial();
$app->fqdn = 'http://example.com:3000,https://example.org';
$result = $app->allFqdnsHavePort();
expect($result)->toBeFalse();
});
it('checks if all FQDNs have port - empty FQDN', function () {
$app = Mockery::mock(ServiceApplication::class)->makePartial();
$app->fqdn = '';
$result = $app->allFqdnsHavePort();
expect($result)->toBeFalse();
});
it('checks if all FQDNs have port - null FQDN', function () {
$app = Mockery::mock(ServiceApplication::class)->makePartial();
$app->fqdn = null;
$result = $app->allFqdnsHavePort();
expect($result)->toBeFalse();
});

View file

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