v4.0.0-beta.442 (#7130)
This commit is contained in:
commit
7bbfa094d0
46 changed files with 2088 additions and 547 deletions
|
|
@ -4,6 +4,11 @@ on:
|
|||
schedule:
|
||||
- cron: '0 1 * * *'
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
discussions: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
lock-threads:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -13,5 +18,5 @@ jobs:
|
|||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-inactive-days: '30'
|
||||
pr-inactive-days: '30'
|
||||
discussion-inactive-days: '30'
|
||||
pr-inactive-days: '30'
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ on:
|
|||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
manage-stale:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
15
.github/workflows/chore-pr-comments.yml
vendored
15
.github/workflows/chore-pr-comments.yml
vendored
|
|
@ -3,20 +3,13 @@ on:
|
|||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
actions: none
|
||||
checks: none
|
||||
deployments: none
|
||||
issues: none
|
||||
packages: none
|
||||
repository-projects: none
|
||||
security-events: none
|
||||
statuses: none
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ on:
|
|||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
remove-labels-and-assignees:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
79
.github/workflows/claude-code-review.yml
vendored
79
.github/workflows/claude-code-review.yml
vendored
|
|
@ -1,79 +0,0 @@
|
|||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
if: false
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||
# model: "claude-opus-4-1-20250805"
|
||||
|
||||
# Direct prompt for automated review (no @claude mention needed)
|
||||
direct_prompt: |
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Be constructive and helpful in your feedback.
|
||||
|
||||
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
|
||||
# use_sticky_comment: true
|
||||
|
||||
# Optional: Customize review based on file types
|
||||
# direct_prompt: |
|
||||
# Review this PR focusing on:
|
||||
# - For TypeScript files: Type safety and proper interface usage
|
||||
# - For API endpoints: Security, input validation, and error handling
|
||||
# - For React components: Performance, accessibility, and best practices
|
||||
# - For tests: Coverage, edge cases, and test quality
|
||||
|
||||
# Optional: Different prompts for different authors
|
||||
# direct_prompt: |
|
||||
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
|
||||
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
|
||||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
|
||||
|
||||
# Optional: Add specific tools for running tests or linting
|
||||
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
|
||||
|
||||
# Optional: Skip review for certain conditions
|
||||
# if: |
|
||||
# !contains(github.event.pull_request.title, '[skip-review]') &&
|
||||
# !contains(github.event.pull_request.title, '[WIP]')
|
||||
|
||||
65
.github/workflows/claude.yml
vendored
65
.github/workflows/claude.yml
vendored
|
|
@ -1,65 +0,0 @@
|
|||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'Claude') ||
|
||||
(github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'Claude') ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||
# model: "claude-opus-4-1-20250805"
|
||||
|
||||
# Optional: Customize the trigger phrase (default: @claude)
|
||||
# trigger_phrase: "/claude"
|
||||
|
||||
# Optional: Trigger when specific user is assigned to an issue
|
||||
# assignee_trigger: "claude-bot"
|
||||
|
||||
# Optional: Allow Claude to run specific commands
|
||||
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
||||
|
||||
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
||||
# custom_instructions: |
|
||||
# Follow our coding standards
|
||||
# Ensure all new code has tests
|
||||
# Use TypeScript for new files
|
||||
|
||||
# Optional: Custom environment variables for Claude
|
||||
# claude_env: |
|
||||
# NODE_ENV: test
|
||||
9
.github/workflows/cleanup-ghcr-untagged.yml
vendored
9
.github/workflows/cleanup-ghcr-untagged.yml
vendored
|
|
@ -1,17 +1,14 @@
|
|||
name: Cleanup Untagged GHCR Images
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger only
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
cleanup-all-packages:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host']
|
||||
|
|
|
|||
26
.github/workflows/coolify-helper-next.yml
vendored
26
.github/workflows/coolify-helper-next.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
25
.github/workflows/coolify-helper.yml
vendored
25
.github/workflows/coolify-helper.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
19
.github/workflows/coolify-production-build.yml
vendored
19
.github/workflows/coolify-production-build.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
26
.github/workflows/coolify-realtime-next.yml
vendored
26
.github/workflows/coolify-realtime-next.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
25
.github/workflows/coolify-realtime.yml
vendored
25
.github/workflows/coolify-realtime.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
14
.github/workflows/coolify-staging-build.yml
vendored
14
.github/workflows/coolify-staging-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
25
.github/workflows/coolify-testing-host.yml
vendored
25
.github/workflows/coolify-testing-host.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1
.github/workflows/generate-changelog.yml
vendored
1
.github/workflows/generate-changelog.yml
vendored
|
|
@ -16,6 +16,7 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate changelog
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
154
tests/Feature/Service/EditDomainPortValidationTest.php
Normal file
154
tests/Feature/Service/EditDomainPortValidationTest.php
Normal 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
|
||||
});
|
||||
296
tests/Unit/DockerComposeEmptyStringPreservationTest.php
Normal file
296
tests/Unit/DockerComposeEmptyStringPreservationTest.php
Normal 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();
|
||||
});
|
||||
194
tests/Unit/DockerComposeEmptyTopLevelSectionsTest.php
Normal file
194
tests/Unit/DockerComposeEmptyTopLevelSectionsTest.php
Normal 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);
|
||||
});
|
||||
218
tests/Unit/DockerfileArgInsertionTest.php
Normal file
218
tests/Unit/DockerfileArgInsertionTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\User;
|
||||
use App\Policies\PrivateKeyPolicy;
|
||||
|
||||
|
|
|
|||
174
tests/Unit/ServicePortSpecificVariablesTest.php
Normal file
174
tests/Unit/ServicePortSpecificVariablesTest.php
Normal 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");
|
||||
}
|
||||
});
|
||||
153
tests/Unit/ServiceRequiredPortTest.php
Normal file
153
tests/Unit/ServiceRequiredPortTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue