diff --git a/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml b/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml index d00853964..365842254 100644 --- a/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml +++ b/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml @@ -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' diff --git a/.github/workflows/chore-manage-stale-issues-and-prs.yml b/.github/workflows/chore-manage-stale-issues-and-prs.yml index 58a2b7d7e..d61005549 100644 --- a/.github/workflows/chore-manage-stale-issues-and-prs.yml +++ b/.github/workflows/chore-manage-stale-issues-and-prs.yml @@ -4,6 +4,10 @@ on: schedule: - cron: '0 2 * * *' +permissions: + issues: write + pull-requests: write + jobs: manage-stale: runs-on: ubuntu-latest diff --git a/.github/workflows/chore-pr-comments.yml b/.github/workflows/chore-pr-comments.yml index 8836c6632..1d94bec81 100644 --- a/.github/workflows/chore-pr-comments.yml +++ b/.github/workflows/chore-pr-comments.yml @@ -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: diff --git a/.github/workflows/chore-remove-labels-and-assignees-on-close.yml b/.github/workflows/chore-remove-labels-and-assignees-on-close.yml index 194984ddc..8ac199a08 100644 --- a/.github/workflows/chore-remove-labels-and-assignees-on-close.yml +++ b/.github/workflows/chore-remove-labels-and-assignees-on-close.yml @@ -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 diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index a2c92df59..000000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -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]') - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 9daf0e90e..000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/cleanup-ghcr-untagged.yml b/.github/workflows/cleanup-ghcr-untagged.yml index 394fba68f..a86cedcb0 100644 --- a/.github/workflows/cleanup-ghcr-untagged.yml +++ b/.github/workflows/cleanup-ghcr-untagged.yml @@ -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'] diff --git a/.github/workflows/coolify-helper-next.yml b/.github/workflows/coolify-helper-next.yml index a4a2a21f6..ba8a69d28 100644 --- a/.github/workflows/coolify-helper-next.yml +++ b/.github/workflows/coolify-helper-next.yml @@ -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 }} diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml index 56c3eaa17..738a3480c 100644 --- a/.github/workflows/coolify-helper.yml +++ b/.github/workflows/coolify-helper.yml @@ -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 diff --git a/.github/workflows/coolify-production-build.yml b/.github/workflows/coolify-production-build.yml index cd1f002b8..b6cfd34ae 100644 --- a/.github/workflows/coolify-production-build.yml +++ b/.github/workflows/coolify-production-build.yml @@ -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 diff --git a/.github/workflows/coolify-realtime-next.yml b/.github/workflows/coolify-realtime-next.yml index ad590146b..7a6071bde 100644 --- a/.github/workflows/coolify-realtime-next.yml +++ b/.github/workflows/coolify-realtime-next.yml @@ -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 diff --git a/.github/workflows/coolify-realtime.yml b/.github/workflows/coolify-realtime.yml index d00621cc2..1074af3ee 100644 --- a/.github/workflows/coolify-realtime.yml +++ b/.github/workflows/coolify-realtime.yml @@ -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 diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index df737c9c3..67b7b03e8 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -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 diff --git a/.github/workflows/coolify-testing-host.yml b/.github/workflows/coolify-testing-host.yml index 95a228114..c4aecd85e 100644 --- a/.github/workflows/coolify-testing-host.yml +++ b/.github/workflows/coolify-testing-host.yml @@ -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 diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 935a88721..f62b41736 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -16,6 +16,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: + persist-credentials: false fetch-depth: 0 - name: Generate changelog diff --git a/README.md b/README.md index f159cde89..456a1268e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/Console/Commands/Cloud/RestoreDatabase.php b/app/Console/Commands/Cloud/RestoreDatabase.php index 6c60d1c6c..7c6c0d4c6 100644 --- a/app/Console/Commands/Cloud/RestoreDatabase.php +++ b/app/Console/Commands/Cloud/RestoreDatabase.php @@ -1,6 +1,6 @@ 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; } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index a240a759a..ea8cdff95 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -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")); diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index 371c860ca..a9a7de878 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -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(); diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 09392ab09..1d8d8b247 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -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(); diff --git a/app/Models/Service.php b/app/Models/Service.php index c4b8623e0..12d3d6a11 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -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); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 5cafc9042..49bd56206 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -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); diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index d6c9b5bdf..5bccb50f1 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -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']); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 01ae50f6b..1deec45d7 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -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; diff --git a/config/constants.php b/config/constants.php index fd2adb860..02a1eaae6 100644 --- a/config/constants.php +++ b/config/constants.php @@ -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' => [ diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 212703798..14879eb96 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -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 diff --git a/gcool.json b/jean.json similarity index 53% rename from gcool.json rename to jean.json index 629d8569a..c625e08c0 100644 --- a/gcool.json +++ b/jean.json @@ -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" } } diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 5d070a6bb..a83b4c8ce 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -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" diff --git a/resources/css/app.css b/resources/css/app.css index fa1e61cb2..70759e542 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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 { diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index f85dc268e..ede49117a 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -61,7 +61,7 @@ class="text-sm dark:text-neutral-400 hover:text-coollabs dark:hover:text-warning
- + Don't have an account?
@@ -82,7 +82,7 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d
- or + or continue with
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 3db943726..cdfa52a98 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -33,7 +33,8 @@ function getOldOrLocal($key, $localValue)

Root User Setup

-

This user will be the root user with full admin access.

+

This user will be the root user with full + admin access.

@@ -58,13 +59,16 @@ function getOldOrLocal($key, $localValue) -
+

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

- + Create Account @@ -74,17 +78,18 @@ function getOldOrLocal($key, $localValue)
- + Already have an account?
- + {{ __('auth.already_registered') }} - + \ No newline at end of file diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php index a4a07ebd6..3e0c237b4 100644 --- a/resources/views/auth/reset-password.blade.php +++ b/resources/views/auth/reset-password.blade.php @@ -47,16 +47,19 @@ label="{{ __('input.email') }}" /> - + -
+

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

- + {{ __('auth.reset_password') }} @@ -66,17 +69,18 @@
- + Remember your password?
- + Back to Login - + \ No newline at end of file diff --git a/resources/views/auth/two-factor-challenge.blade.php b/resources/views/auth/two-factor-challenge.blade.php index d4531cbe8..05dbcc90c 100644 --- a/resources/views/auth/two-factor-challenge.blade.php +++ b/resources/views/auth/two-factor-challenge.blade.php @@ -120,7 +120,7 @@ class="mt-2 text-sm dark:text-neutral-400 hover:text-black dark:hover:text-white
- + Need help?
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index c577f7248..7bb366cd4 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -2,7 +2,7 @@ - + }, 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], + }) + } + }) + }); + + @show - + \ No newline at end of file diff --git a/resources/views/livewire/project/service/edit-domain.blade.php b/resources/views/livewire/project/service/edit-domain.blade.php index a126eca5b..0691146f6 100644 --- a/resources/views/livewire/project/service/edit-domain.blade.php +++ b/resources/views/livewire/project/service/edit-domain.blade.php @@ -1,7 +1,13 @@
-
Note: If a service has a defined port, do not delete it.
If you want to use your custom - domain, you can add it with a port.
+ @if($requiredPort) + + This service requires port {{ $requiredPort }} to function correctly. All domains must include this port number (or any other port if you know what you're doing). +

+ Example: http://app.coolify.io:{{ $requiredPort }} +
+ @endif + @@ -18,4 +24,61 @@ + + @if ($showPortWarningModal) +
+ +
+ @endif
diff --git a/resources/views/livewire/project/service/service-application-view.blade.php b/resources/views/livewire/project/service/service-application-view.blade.php index b95dc6540..5fb4a62d0 100644 --- a/resources/views/livewire/project/service/service-application-view.blade.php +++ b/resources/views/livewire/project/service/service-application-view.blade.php @@ -22,6 +22,14 @@ @endcan
+ @if($requiredPort && !$application->serviceType()?->contains(str($application->image)->before(':'))) + + This service requires port {{ $requiredPort }} to function correctly. All domains must include this port number (or any other port if you know what you're doing). +

+ Example: http://app.coolify.io:{{ $requiredPort }} +
+ @endif +
@@ -68,9 +76,9 @@
-
    @@ -81,4 +89,61 @@
+ + @if ($showPortWarningModal) +
+ +
+ @endif
diff --git a/tests/Feature/DatabaseBackupCreationApiTest.php b/tests/Feature/DatabaseBackupCreationApiTest.php index 16a65dff2..893141de3 100644 --- a/tests/Feature/DatabaseBackupCreationApiTest.php +++ b/tests/Feature/DatabaseBackupCreationApiTest.php @@ -1,7 +1,5 @@ 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 +}); diff --git a/tests/Unit/DockerComposeEmptyStringPreservationTest.php b/tests/Unit/DockerComposeEmptyStringPreservationTest.php new file mode 100644 index 000000000..df654f2ea --- /dev/null +++ b/tests/Unit/DockerComposeEmptyStringPreservationTest.php @@ -0,0 +1,296 @@ +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(); +}); diff --git a/tests/Unit/DockerComposeEmptyTopLevelSectionsTest.php b/tests/Unit/DockerComposeEmptyTopLevelSectionsTest.php new file mode 100644 index 000000000..bfd674053 --- /dev/null +++ b/tests/Unit/DockerComposeEmptyTopLevelSectionsTest.php @@ -0,0 +1,194 @@ +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); +}); diff --git a/tests/Unit/DockerfileArgInsertionTest.php b/tests/Unit/DockerfileArgInsertionTest.php new file mode 100644 index 000000000..593f09145 --- /dev/null +++ b/tests/Unit/DockerfileArgInsertionTest.php @@ -0,0 +1,218 @@ +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'); +}); diff --git a/tests/Unit/Policies/PrivateKeyPolicyTest.php b/tests/Unit/Policies/PrivateKeyPolicyTest.php index dd0037403..6844d92f7 100644 --- a/tests/Unit/Policies/PrivateKeyPolicyTest.php +++ b/tests/Unit/Policies/PrivateKeyPolicyTest.php @@ -1,6 +1,5 @@ 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"); + } +}); diff --git a/tests/Unit/ServiceRequiredPortTest.php b/tests/Unit/ServiceRequiredPortTest.php new file mode 100644 index 000000000..70bf2bca2 --- /dev/null +++ b/tests/Unit/ServiceRequiredPortTest.php @@ -0,0 +1,153 @@ + [ + '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(); +}); diff --git a/versions.json b/versions.json index 5d070a6bb..a83b4c8ce 100644 --- a/versions.json +++ b/versions.json @@ -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"