diff --git a/.dockerignore b/.dockerignore index d6abd1451..3a0ec49f7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,3 +22,4 @@ yarn-error.log /_data .rnd /.ssh +.ignition.json diff --git a/.env.development.example b/.env.development.example index 3ad26fa50..00098446c 100644 --- a/.env.development.example +++ b/.env.development.example @@ -6,7 +6,7 @@ APP_KEY= APP_URL=http://localhost APP_PORT=8000 APP_DEBUG=true -SSH_MUX_ENABLED=false +SSH_MUX_ENABLED=true # Enable Laravel Telescope for debugging TELESCOPE_ENABLED=false @@ -27,11 +27,7 @@ DB_PORT=5432 # Set to true to enable Ray RAY_ENABLED=false # Set custom ray port -RAY_PORT= - -# Clockwork Configuration -CLOCKWORK_ENABLED=false -CLOCKWORK_QUEUE_COLLECT=true +# RAY_PORT= # Enable Laravel Telescope for debugging TELESCOPE_ENABLED=false diff --git a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml new file mode 100644 index 000000000..42df4785e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml @@ -0,0 +1,65 @@ +name: 🐞 Bug Report +description: "File a new bug report." +title: "[Bug]: " +labels: ["🐛 Bug", "🔍 Triage"] +body: + - type: markdown + attributes: + value: | + > [!IMPORTANT] + > **Please ensure you are using the latest version of Coolify before submitting an issue, as the bug may have already been fixed in a recent update.** (Of course, if you're experiencing an issue on the latest version that wasn't present in a previous version, please let us know.) + + # 💎 Bounty Program (with [algora.io](https://console.algora.io/org/coollabsio/bounties/new)) + - If you would like to prioritize the issue resolution, consider adding a bounty to this issue through our [Bounty Program](https://console.algora.io/org/coollabsio/bounties/new). + + - type: textarea + attributes: + label: Error Message and Logs + description: Provide a detailed description of the error or exception you encountered, along with any relevant log output. + validations: + required: true + + - type: textarea + attributes: + label: Steps to Reproduce + description: Please provide a step-by-step guide to reproduce the issue. Be as detailed as possible, otherwise we may not be able to assist you. + value: | + 1. + 2. + 3. + 4. + validations: + required: true + + - type: input + attributes: + label: Example Repository URL + description: If applicable, provide a URL to a repository demonstrating the issue. + + - type: input + attributes: + label: Coolify Version + description: Please provide the Coolify version you are using. This can be found in the top left corner of your Coolify dashboard. + placeholder: "v4.0.0-beta.335" + validations: + required: true + + - type: dropdown + attributes: + label: Are you using Coolify Cloud? + options: + - "No (self-hosted)" + - "Yes (Coolify Cloud)" + validations: + required: true + + - type: input + attributes: + label: Operating System and Version (self-hosted) + description: Run `cat /etc/os-release` or `lsb_release -a` in your terminal and provide the operating system and version. + placeholder: "Ubuntu 22.04" + + - type: textarea + attributes: + label: Additional Information + description: Any other relevant details about the issue. diff --git a/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml b/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml new file mode 100644 index 000000000..ef26125e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml @@ -0,0 +1,31 @@ +name: 💎 Enhancement Bounty +description: "Propose a new feature, service, or improvement with an attached bounty." +title: "[Enhancement]: " +labels: ["✨ Enhancement", "🔍 Triage"] +body: + - type: markdown + attributes: + value: | + > [!IMPORTANT] + > **This issue template is exclusively for proposing new features, services, or improvements with an attached bounty.** Enhancements without a bounty can be discussed in the appropriate category of [Github Discussions](https://github.com/coollabsio/coolify/discussions). + + # 💎 Add a Bounty (with [algora.io](https://console.algora.io/org/coollabsio/bounties/new)) + - [Click here to add the required bounty](https://console.algora.io/org/coollabsio/bounties/new) + + - type: dropdown + attributes: + label: Request Type + description: Select the type of request you are making. + options: + - New Feature + - New Service + - Improvement + validations: + required: true + + - type: textarea + attributes: + label: Description + description: Provide a detailed description of the feature, improvement, or service you are proposing. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml deleted file mode 100644 index f3d52b1b4..000000000 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Bug report -description: "Create a new bug report." -title: "[Bug]: " -body: - - type: markdown - attributes: - value: >- - # 💎 Bounty program (with - [algora.io](https://console.algora.io/org/coollabsio/bounties/new)) - - - If you would like to prioritize the issue resolution, you can add bounty - to this issue. - - - Click [here](https://console.algora.io/org/coollabsio/bounties/new) to - get started. - - type: textarea - attributes: - label: Description - description: A clear and concise description of the problem - - type: textarea - attributes: - label: Minimal Reproduction (if possible, example repository) - description: Please provide a step by step guide to reproduce the issue. - validations: - required: true - - type: textarea - attributes: - label: Exception or Error - description: Please provide error logs if possible. - - type: input - attributes: - label: Version - description: Coolify's version (see top of your screen). - validations: - required: true - - type: checkboxes - attributes: - label: Cloud? - description: "Are you using the cloud version of Coolify?" - options: - - label: 'Yes' - required: false - - label: 'No' - required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 4f12f436c..92c48e2d6 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,18 @@ blank_issues_enabled: false + contact_links: - - name: 🤔 Community Support (Chat) + - name: 🤔 Questions and Community Support url: https://coollabs.io/discord - about: Reach out to us on Discord. - - name: 🙋‍♂️ Feature Requests - url: https://github.com/coollabsio/coolify/discussions/categories/new-features - about: All feature requests will be discussed here. + about: If you have any questions, reach out to us on Discord inside the "#support" channel. + + - name: 💡 Feature Request + url: https://github.com/coollabsio/coolify/discussions/categories/feature-requests + about: Suggest a new feature for Coolify. + + - name: ⚙️ Service Request + url: https://github.com/coollabsio/coolify/discussions/categories/service-requests + about: Request a new service integration for Coolify. + + - name: 🔧 Improvements + url: https://github.com/coollabsio/coolify/discussions/categories/improvements + about: Suggest improvements to existing features for Coolify. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3ded74ce3..5afe00a30 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1 +1,13 @@ -> Always use `next` branch as destination branch for PRs, not `main` +## Submit Checklist (REMOVE THIS SECTION BEFORE SUBMITTING) +- [ ] I have selected the `next` branch as the destination for my PR, not `main`. +- [ ] I have listed all changes in the `Changes` section. +- [ ] I have filled out the `Issues` section with the issue/discussion link(s) (if applicable). +- [ ] I have tested my changes. +- [ ] I have considered backwards compatibility. +- [ ] I have removed this checklist and any unused sections. + +## Changes +- + +## Issues +- fix # diff --git a/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml b/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml new file mode 100644 index 000000000..d00853964 --- /dev/null +++ b/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml @@ -0,0 +1,17 @@ +name: Lock closed Issues, Discussions, and PRs + +on: + schedule: + - cron: '0 1 * * *' + +jobs: + lock-threads: + runs-on: ubuntu-latest + steps: + - name: Lock threads after 30 days of inactivity + uses: dessant/lock-threads@v5 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + issue-inactive-days: '30' + pr-inactive-days: '30' + discussion-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 new file mode 100644 index 000000000..2afc996cb --- /dev/null +++ b/.github/workflows/chore-manage-stale-issues-and-prs.yml @@ -0,0 +1,28 @@ +name: Manage Stale Issues and PRs + +on: + schedule: + - cron: '0 2 * * *' + +jobs: + manage-stale: + runs-on: ubuntu-latest + steps: + - name: Manage stale issues and PRs + uses: actions/stale@v9 + id: stale + with: + stale-issue-message: 'This issue will be automatically closed in a few days if no response is received. Please provide an update with the requested information.' + stale-pr-message: 'This pull request will be automatically closed in a few days if no response is received. Please update your PR or comment if you would like to continue working on it.' + close-issue-message: 'This issue has been automatically closed due to inactivity.' + close-pr-message: 'This pull request has been automatically closed due to inactivity.' + days-before-stale: 14 + days-before-close: 7 + stale-issue-label: '⏱︎ Stale' + stale-pr-label: '⏱︎ Stale' + only-labels: '💤 Waiting for feedback' + remove-stale-when-updated: true + operations-per-run: 100 + labels-to-remove-when-unstale: '⏱︎ Stale, 💤 Waiting for feedback' + close-issue-reason: 'not_planned' + exempt-all-milestones: false diff --git a/.github/workflows/chore-remove-labels-and-assignees-on-close.yml b/.github/workflows/chore-remove-labels-and-assignees-on-close.yml new file mode 100644 index 000000000..ea097e328 --- /dev/null +++ b/.github/workflows/chore-remove-labels-and-assignees-on-close.yml @@ -0,0 +1,78 @@ +name: Remove Labels and Assignees on Issue Close + +on: + issues: + types: [closed] + pull_request: + types: [closed] + pull_request_target: + types: [closed] + +jobs: + remove-labels-and-assignees: + runs-on: ubuntu-latest + steps: + - name: Remove labels and assignees + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + + async function processIssue(issueNumber) { + try { + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner, + repo, + issue_number: issueNumber + }); + + const labelsToKeep = currentLabels + .filter(label => label.name === '⏱︎ Stale') + .map(label => label.name); + + await github.rest.issues.setLabels({ + owner, + repo, + issue_number: issueNumber, + labels: labelsToKeep + }); + + const { data: issue } = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber + }); + + if (issue.assignees && issue.assignees.length > 0) { + await github.rest.issues.removeAssignees({ + owner, + repo, + issue_number: issueNumber, + assignees: issue.assignees.map(assignee => assignee.login) + }); + } + } catch (error) { + if (error.status !== 404) { + console.error(`Error processing issue ${issueNumber}:`, error); + } + } + } + + if (context.eventName === 'issues' || context.eventName === 'pull_request' || context.eventName === 'pull_request_target') { + const issue = context.payload.issue || context.payload.pull_request; + await processIssue(issue.number); + } + + if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') { + const pr = context.payload.pull_request; + if (pr.body) { + const issueReferences = pr.body.match(/#(\d+)/g); + if (issueReferences) { + for (const reference of issueReferences) { + const issueNumber = parseInt(reference.substring(1)); + await processIssue(issueNumber); + } + } + } + } diff --git a/.github/workflows/coolify-helper-next.yml b/.github/workflows/coolify-helper-next.yml index 4add8516e..4354294b1 100644 --- a/.github/workflows/coolify-helper-next.yml +++ b/.github/workflows/coolify-helper-next.yml @@ -1,4 +1,4 @@ -name: Coolify Helper Image Development (v4) +name: Coolify Helper Image Development on: push: @@ -8,7 +8,8 @@ on: - docker/coolify-helper/Dockerfile env: - REGISTRY: ghcr.io + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io IMAGE_NAME: "coollabsio/coolify-helper" jobs: @@ -19,25 +20,36 @@ jobs: packages: write steps: - uses: actions/checkout@v4 - - name: Login to ghcr.io + + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ${{ env.GITHUB_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Get Version id: version run: | echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.helper.version' versions.json)"|xargs >> $GITHUB_OUTPUT - - name: Build image and push to registry - uses: docker/build-push-action@v5 + + - name: Build and Push Image + uses: docker/build-push-action@v6 with: - no-cache: true context: . file: docker/coolify-helper/Dockerfile platforms: linux/amd64 push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next labels: | coolify.managed=true aarch64: @@ -47,27 +59,39 @@ jobs: packages: write steps: - uses: actions/checkout@v4 - - name: Login to ghcr.io + + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ${{ env.GITHUB_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Get Version id: version run: | echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.helper.version' versions.json)"|xargs >> $GITHUB_OUTPUT - - name: Build image and push to registry - uses: docker/build-push-action@v5 + + - name: Build and Push Image + uses: docker/build-push-action@v6 with: - no-cache: true context: . file: docker/coolify-helper/Dockerfile platforms: linux/aarch64 push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 labels: | coolify.managed=true + merge-manifest: runs-on: ubuntu-latest permissions: @@ -75,25 +99,42 @@ jobs: packages: write needs: [ amd64, aarch64 ] steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to ghcr.io + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ${{ env.GITHUB_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Get Version id: version run: | echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.helper.version' versions.json)"|xargs >> $GITHUB_OUTPUT - - name: Create & publish manifest + + - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} run: | - docker buildx imagetools create --append ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:next + docker buildx imagetools create \ + --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next + + - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} + run: | + docker buildx imagetools create \ + --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next + - uses: sarisia/actions-status-discord@v1 if: always() with: diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml index 830b36d28..6d852a2b3 100644 --- a/.github/workflows/coolify-helper.yml +++ b/.github/workflows/coolify-helper.yml @@ -1,14 +1,15 @@ -name: Coolify Helper Image (v4) +name: Coolify Helper Image on: push: - branches: [ "main", "next" ] + branches: [ "main" ] paths: - .github/workflows/coolify-helper.yml - docker/coolify-helper/Dockerfile env: - REGISTRY: ghcr.io + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io IMAGE_NAME: "coollabsio/coolify-helper" jobs: @@ -19,25 +20,36 @@ jobs: packages: write steps: - uses: actions/checkout@v4 - - name: Login to ghcr.io + + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ${{ env.GITHUB_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Get Version id: version run: | echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.helper.version' versions.json)"|xargs >> $GITHUB_OUTPUT - - name: Build image and push to registry - uses: docker/build-push-action@v5 + + - name: Build and Push Image + uses: docker/build-push-action@v6 with: - no-cache: true context: . file: docker/coolify-helper/Dockerfile platforms: linux/amd64 push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} labels: | coolify.managed=true aarch64: @@ -47,25 +59,36 @@ jobs: packages: write steps: - uses: actions/checkout@v4 - - name: Login to ghcr.io + + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ${{ env.GITHUB_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Get Version id: version run: | - echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.helper.version' versions.json)"|xargs >> $GITHUB_OUTPUT - - name: Build image and push to registry - uses: docker/build-push-action@v5 + echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.helper.version' versions.json)"|xargs >> $GITHUB_OUTPUT + + - name: Build and Push Image + uses: docker/build-push-action@v6 with: - no-cache: true context: . file: docker/coolify-helper/Dockerfile platforms: linux/aarch64 push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 labels: | coolify.managed=true merge-manifest: @@ -75,26 +98,45 @@ jobs: packages: write needs: [ amd64, aarch64 ] steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to ghcr.io + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ${{ env.GITHUB_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Get Version id: version run: | - echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.helper.version' versions.json)"|xargs >> $GITHUB_OUTPUT - - name: Create & publish manifest + echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.helper.version' versions.json)"|xargs >> $GITHUB_OUTPUT + + - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} run: | - docker buildx imagetools create --append ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + docker buildx imagetools create \ + --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} + run: | + docker buildx imagetools create \ + --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest + - uses: sarisia/actions-status-discord@v1 if: always() with: webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }} + diff --git a/.github/workflows/coolify-production-build.yml b/.github/workflows/coolify-production-build.yml new file mode 100644 index 000000000..771687d4b --- /dev/null +++ b/.github/workflows/coolify-production-build.yml @@ -0,0 +1,143 @@ +name: Production Build (v4) + +on: + push: + branches: ["main"] + paths-ignore: + - .github/workflows/coolify-helper.yml + - .github/workflows/coolify-helper-next.yml + - .github/workflows/coolify-realtime.yml + - .github/workflows/coolify-realtime-next.yml + - docker/coolify-helper/Dockerfile + - docker/coolify-realtime/Dockerfile + - docker/testing-host/Dockerfile + - templates/service-templates.json + +env: + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io + IMAGE_NAME: "coollabsio/coolify" + +jobs: + amd64: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Get Version + id: version + run: | + echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT] + + - name: Build and Push Image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/prod/Dockerfile + platforms: linux/amd64 + push: true + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} + labels: | + coolify.managed=true + + aarch64: + runs-on: [self-hosted, arm64] + steps: + - uses: actions/checkout@v4 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Get Version + id: version + run: | + echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT] + + - name: Build and Push Image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/prod/Dockerfile + platforms: linux/aarch64 + push: true + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 + labels: | + coolify.managed=true + + merge-manifest: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + needs: [amd64, aarch64] + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Get Version + id: version + run: | + echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT + + - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} + run: | + docker buildx imagetools create \ + --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} + run: | + docker buildx imagetools create \ + --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - uses: sarisia/actions-status-discord@v1 + if: always() + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }} diff --git a/.github/workflows/coolify-realtime-next.yml b/.github/workflows/coolify-realtime-next.yml new file mode 100644 index 000000000..7e937d17a --- /dev/null +++ b/.github/workflows/coolify-realtime-next.yml @@ -0,0 +1,146 @@ +name: Coolify Realtime Development + +on: + push: + branches: [ "next" ] + paths: + - .github/workflows/coolify-realtime-next.yml + - docker/coolify-realtime/Dockerfile + - docker/coolify-realtime/terminal-server.js + - docker/coolify-realtime/package.json + - docker/coolify-realtime/soketi-entrypoint.sh + +env: + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io + IMAGE_NAME: "coollabsio/coolify-realtime" + +jobs: + amd64: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Get Version + id: version + run: | + echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.realtime.version' versions.json)"|xargs >> $GITHUB_OUTPUT + + - name: Build and Push Image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/coolify-realtime/Dockerfile + platforms: linux/amd64 + push: true + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next + labels: | + coolify.managed=true + + aarch64: + runs-on: [ self-hosted, arm64 ] + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Get Version + id: version + run: | + echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.realtime.version' versions.json)"|xargs >> $GITHUB_OUTPUT + + - name: Build and Push Image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/coolify-realtime/Dockerfile + platforms: linux/aarch64 + push: true + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 + labels: | + coolify.managed=true + + merge-manifest: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + needs: [ amd64, aarch64 ] + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Get Version + id: version + run: | + echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.realtime.version' versions.json)"|xargs >> $GITHUB_OUTPUT + + - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} + run: | + docker buildx imagetools create \ + --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next + + - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} + run: | + docker buildx imagetools create \ + --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next + + - uses: sarisia/actions-status-discord@v1 + if: always() + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} diff --git a/.github/workflows/coolify-realtime.yml b/.github/workflows/coolify-realtime.yml new file mode 100644 index 000000000..97bfd52eb --- /dev/null +++ b/.github/workflows/coolify-realtime.yml @@ -0,0 +1,146 @@ +name: Coolify Realtime + +on: + push: + branches: [ "main" ] + paths: + - .github/workflows/coolify-realtime.yml + - docker/coolify-realtime/Dockerfile + - docker/coolify-realtime/terminal-server.js + - docker/coolify-realtime/package.json + - docker/coolify-realtime/soketi-entrypoint.sh + +env: + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io + IMAGE_NAME: "coollabsio/coolify-realtime" + +jobs: + amd64: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Get Version + id: version + run: | + echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.realtime.version' versions.json)"|xargs >> $GITHUB_OUTPUT + + - name: Build and Push Image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/coolify-realtime/Dockerfile + platforms: linux/amd64 + push: true + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} + labels: | + coolify.managed=true + + aarch64: + runs-on: [ self-hosted, arm64 ] + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Get Version + id: version + run: | + echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.realtime.version' versions.json)"|xargs >> $GITHUB_OUTPUT + + - name: Build and Push Image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/coolify-realtime/Dockerfile + platforms: linux/aarch64 + push: true + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 + labels: | + coolify.managed=true + + merge-manifest: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + needs: [ amd64, aarch64 ] + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Get Version + id: version + run: | + echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app ghcr.io/jqlang/jq:latest '.coolify.realtime.version' versions.json)"|xargs >> $GITHUB_OUTPUT + + - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} + run: | + docker buildx imagetools create \ + --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} + run: | + docker buildx imagetools create \ + --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - uses: sarisia/actions-status-discord@v1 + if: always() + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }} diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml new file mode 100644 index 000000000..dd5e6ebd6 --- /dev/null +++ b/.github/workflows/coolify-staging-build.yml @@ -0,0 +1,129 @@ +name: Staging Build + +on: + push: + branches-ignore: ["main", "v3"] + paths-ignore: + - .github/workflows/coolify-helper.yml + - .github/workflows/coolify-helper-next.yml + - .github/workflows/coolify-realtime.yml + - .github/workflows/coolify-realtime-next.yml + - docker/coolify-helper/Dockerfile + - docker/coolify-realtime/Dockerfile + - docker/testing-host/Dockerfile + - templates/service-templates.json + +env: + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io + IMAGE_NAME: "coollabsio/coolify" + +jobs: + amd64: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and Push Image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/prod/Dockerfile + platforms: linux/amd64 + push: true + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + labels: | + coolify.managed=true + + aarch64: + runs-on: [self-hosted, arm64] + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and Push Image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/prod/Dockerfile + platforms: linux/aarch64 + push: true + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 + labels: | + coolify.managed=true + + merge-manifest: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + needs: [amd64, aarch64] + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} + run: | + docker buildx imagetools create \ + --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + + - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} + run: | + docker buildx imagetools create \ + --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + + - uses: sarisia/actions-status-discord@v1 + if: always() + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} diff --git a/.github/workflows/coolify-testing-host.yml b/.github/workflows/coolify-testing-host.yml index 5fdc32991..95a228114 100644 --- a/.github/workflows/coolify-testing-host.yml +++ b/.github/workflows/coolify-testing-host.yml @@ -1,14 +1,15 @@ -name: Coolify Testing Host (v4-non-prod) +name: Coolify Testing Host on: push: - branches: [ "main", "next" ] + branches: [ "next" ] paths: - .github/workflows/coolify-testing-host.yml - docker/testing-host/Dockerfile env: - REGISTRY: ghcr.io + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io IMAGE_NAME: "coollabsio/coolify-testing-host" jobs: @@ -19,21 +20,34 @@ jobs: packages: write steps: - uses: actions/checkout@v4 - - name: Login to ghcr.io + + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ${{ env.GITHUB_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build image and push to registry - uses: docker/build-push-action@v5 + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and Push Image + uses: docker/build-push-action@v6 with: - no-cache: true context: . file: docker/testing-host/Dockerfile platforms: linux/amd64 push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest + labels: | + coolify.managed=true + aarch64: runs-on: [ self-hosted, arm64 ] permissions: @@ -41,21 +55,34 @@ jobs: packages: write steps: - uses: actions/checkout@v4 - - name: Login to ghcr.io + + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ${{ env.GITHUB_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build image and push to registry - uses: docker/build-push-action@v5 + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and Push Image + uses: docker/build-push-action@v6 with: - no-cache: true context: . file: docker/testing-host/Dockerfile platforms: linux/aarch64 push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 + tags: | + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 + labels: | + coolify.managed=true + merge-manifest: runs-on: ubuntu-latest permissions: @@ -63,21 +90,36 @@ jobs: packages: write needs: [ amd64, aarch64 ] steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to ghcr.io + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ${{ env.GITHUB_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Create & publish manifest + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} run: | - docker buildx imagetools create --append ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + docker buildx imagetools create \ + --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} + run: | + docker buildx imagetools create \ + --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest + - uses: sarisia/actions-status-discord@v1 if: always() with: diff --git a/.github/workflows/development-build.yml b/.github/workflows/development-build.yml deleted file mode 100644 index 268b885ac..000000000 --- a/.github/workflows/development-build.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Development Build (v4) - -on: - push: - branches-ignore: ["main", "v3"] - paths-ignore: - - .github/workflows/coolify-helper.yml - - docker/coolify-helper/Dockerfile - -env: - REGISTRY: ghcr.io - IMAGE_NAME: "coollabsio/coolify" - -jobs: - amd64: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Login to ghcr.io - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build image and push to registry - uses: docker/build-push-action@v5 - with: - context: . - file: docker/prod/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} - aarch64: - runs-on: [self-hosted, arm64] - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - name: Login to ghcr.io - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build image and push to registry - uses: docker/build-push-action@v5 - with: - context: . - file: docker/prod/Dockerfile - platforms: linux/aarch64 - push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 - merge-manifest: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - needs: [amd64, aarch64] - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to ghcr.io - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Create & publish manifest - run: | - docker buildx imagetools create --append ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} - - uses: sarisia/actions-status-discord@v1 - if: always() - with: - webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml deleted file mode 100644 index 0edaa4f1c..000000000 --- a/.github/workflows/docker-image.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Docker Image CI - -on: - # push: - # branches: [ "main" ] - # pull_request: - # branches: [ "*" ] - push: - branches: ["this-does-not-exist"] - pull_request: - branches: ["this-does-not-exist"] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Cache Docker layers - uses: actions/cache@v2 - with: - path: | - /usr/local/share/ca-certificates - /var/cache/apt/archives - /var/lib/apt/lists - ~/.cache - key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile') }} - restore-keys: | - ${{ runner.os }}-docker- - - name: Build the Docker image - run: | - cp .env.example .env - docker run --rm -u "$(id -u):$(id -g)" \ - -v "$(pwd):/app" \ - -w /app composer:2 \ - composer install --ignore-platform-reqs - ./vendor/bin/spin build - - name: Start the stack - run: | - ./vendor/bin/spin up -d - ./vendor/bin/spin exec coolify php artisan key:generate - ./vendor/bin/spin exec coolify php artisan migrate:fresh --seed - - name: Test (missing E2E tests) - run: | - ./vendor/bin/spin exec coolify php artisan test diff --git a/.github/workflows/fix-php-code-style-issues b/.github/workflows/fix-php-code-style-issues deleted file mode 100644 index aebce91bc..000000000 --- a/.github/workflows/fix-php-code-style-issues +++ /dev/null @@ -1,25 +0,0 @@ -name: Fix PHP code style issues - -on: [push] - -permissions: - contents: write - -jobs: - php-code-styling: - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - - - name: Fix PHP code style issues - uses: aglipanci/laravel-pint-action@2.4 - - - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v5 - with: - commit_message: Fix styling diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml deleted file mode 100644 index d7a680170..000000000 --- a/.github/workflows/pr-build.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: PR Build (v4) - -on: - pull_request: - types: - - opened - branches-ignore: ["main", "v3"] - paths-ignore: - - .github/workflows/coolify-helper.yml - - docker/coolify-helper/Dockerfile - -env: - REGISTRY: ghcr.io - IMAGE_NAME: "coollabsio/coolify" - -jobs: - amd64: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - attestations: write - id-token: write - actions: write - steps: - - uses: actions/checkout@v4 - - name: Login to ghcr.io - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build image and push to registry - uses: docker/build-push-action@v5 - with: - context: . - file: docker/prod/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.number }} - aarch64: - runs-on: [self-hosted, arm64] - permissions: - contents: read - packages: write - attestations: write - id-token: write - actions: write - steps: - - uses: actions/checkout@v4 - - name: Login to ghcr.io - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build image and push to registry - uses: docker/build-push-action@v5 - with: - context: . - file: docker/prod/Dockerfile - platforms: linux/aarch64 - push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.number }}-aarch64 - merge-manifest: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - attestations: write - id-token: write - actions: write - needs: [amd64, aarch64] - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to ghcr.io - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Create & publish manifest - run: | - docker buildx imagetools create --append ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.number }}-aarch64 --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.number }} - - uses: sarisia/actions-status-discord@v1 - if: always() - with: - webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} diff --git a/.github/workflows/production-build.yml b/.github/workflows/production-build.yml deleted file mode 100644 index c78c865bf..000000000 --- a/.github/workflows/production-build.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Production Build (v4) - -on: - push: - branches: ["main"] - paths-ignore: - - .github/workflows/coolify-helper.yml - - docker/coolify-helper/Dockerfile - - templates/service-templates.json - -env: - REGISTRY: ghcr.io - IMAGE_NAME: "coollabsio/coolify" - -jobs: - amd64: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Login to ghcr.io - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Get Version - id: version - run: | - echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT - - name: Build image and push to registry - uses: docker/build-push-action@v5 - with: - context: . - file: docker/prod/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} - aarch64: - runs-on: [self-hosted, arm64] - steps: - - uses: actions/checkout@v4 - - name: Login to ghcr.io - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Get Version - id: version - run: | - echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT - - name: Build image and push to registry - uses: docker/build-push-action@v5 - with: - context: . - file: docker/prod/Dockerfile - platforms: linux/aarch64 - push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 - merge-manifest: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - needs: [amd64, aarch64] - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to ghcr.io - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Get Version - id: version - run: | - echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT - - name: Create & publish manifest - run: | - docker buildx imagetools create --append ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - - uses: sarisia/actions-status-discord@v1 - if: always() - with: - webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }} diff --git a/.gitignore b/.gitignore index ac8a1e090..09504afee 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ _ide_helper_models.php .rnd /.ssh scripts/load-test/* +.ignition.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 590360ddb..80ec0614e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,9 +12,10 @@ You can ask for guidance anytime on our [Discord server](https://coollabs.io/dis 4. [Set up Environment Variables](#4-set-up-environment-variables) 5. [Start Coolify](#5-start-coolify) 6. [Start Development](#6-start-development) -7. [Development Notes](#7-development-notes) -8. [Create a Pull Request](#8-create-a-pull-request) -9. [Additional Contribution Guidelines](#additional-contribution-guidelines) +7. [Create a Pull Request](#7-create-a-pull-request) +8. [Development Notes](#development-notes) +9. [Resetting Development Environment](#resetting-development-environment) +10. [Additional Contribution Guidelines](#additional-contribution-guidelines) ## 1. Setup Development Environment @@ -25,15 +26,15 @@ Follow the steps below for your operating system: 1. Install `docker-ce`, Docker Desktop (or similar): - Docker CE (recommended): - - Install Windows Subsystem for Linux v2 (WSL2) by following this guide: [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install) - - After installing WSL2, install Docker CE for your Linux distribution by following this guide: [Install Docker Engine](https://docs.docker.com/engine/install/) + - Install Windows Subsystem for Linux v2 (WSL2) by following this guide: [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install?ref=coolify) + - After installing WSL2, install Docker CE for your Linux distribution by following this guide: [Install Docker Engine](https://docs.docker.com/engine/install/?ref=coolify) - Make sure to choose the appropriate Linux distribution (e.g., Ubuntu) when following the Docker installation guide - Install Docker Desktop (easier): - - Download and install [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/) + - Download and install [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/?ref=coolify) - Ensure WSL2 backend is enabled in Docker Desktop settings 2. Install Spin: - - Follow the instructions to install Spin on Windows from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-windows#download-and-install-spin-into-wsl2) + - Follow the instructions to install Spin on Windows from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-windows#download-and-install-spin-into-wsl2?ref=coolify) @@ -42,12 +43,12 @@ Follow the steps below for your operating system: 1. Install Orbstack, Docker Desktop (or similar): - Orbstack (recommended, as it is a faster and lighter alternative to Docker Desktop): - - Download and install [Orbstack](https://docs.orbstack.dev/quick-start#installation) + - Download and install [Orbstack](https://docs.orbstack.dev/quick-start#installation?ref=coolify) - Docker Desktop: - - Download and install [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/) + - Download and install [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/?ref=coolify) 2. Install Spin: - - Follow the instructions to install Spin on MacOS from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-macos/#download-and-install-spin) + - Follow the instructions to install Spin on MacOS from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-macos/#download-and-install-spin?ref=coolify) @@ -56,12 +57,12 @@ Follow the steps below for your operating system: 1. Install Docker Engine, Docker Desktop (or similar): - Docker Engine (recommended, as there is no VM overhead): - - Follow the official [Docker Engine installation guide](https://docs.docker.com/engine/install/) for your Linux distribution + - Follow the official [Docker Engine installation guide](https://docs.docker.com/engine/install/?ref=coolify) for your Linux distribution - Docker Desktop: - - If you want a GUI, you can use [Docker Desktop for Linux](https://docs.docker.com/desktop/install/linux-install/) + - If you want a GUI, you can use [Docker Desktop for Linux](https://docs.docker.com/desktop/install/linux-install/?ref=coolify) 2. Install Spin: - - Follow the instructions to install Spin on Linux from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-linux#configure-docker-permissions) + - Follow the instructions to install Spin on Linux from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-linux#configure-docker-permissions?ref=coolify) @@ -85,14 +86,14 @@ After installing Docker (or Orbstack) and Spin, verify the installation: | Editor | Platform | Download Link | |--------|----------|---------------| - | Visual Studio Code (recommended free) | Windows/macOS/Linux | [Download](https://code.visualstudio.com/download) | - | Cursor (recommended but paid) | Windows/macOS/Linux | [Download](https://www.cursor.com/) | - | Zed (very fast) | macOS/Linux | [Download](https://zed.dev/download) | + | Visual Studio Code (recommended free) | Windows/macOS/Linux | [Download](https://code.visualstudio.com/download?ref=coolify) | + | Cursor (recommended but paid) | Windows/macOS/Linux | [Download](https://www.cursor.com/?ref=coolify) | + | Zed (very fast) | macOS/Linux | [Download](https://zed.dev/download?ref=coolify) | 3. Clone the Coolify Repository from your fork to your local machine - Use `git clone` in the command line, or - Use GitHub Desktop (recommended): - - Download and install from [https://desktop.github.com/](https://desktop.github.com/) + - Download and install from [https://desktop.github.com/](https://desktop.github.com/?ref=coolify) - Open GitHub Desktop and login with your GitHub account - Click on `File` -> `Clone Repository` select `github.com` as the repository location, then select your forked Coolify repository, choose the local path and then click `Clone` @@ -145,7 +146,36 @@ After installing Docker (or Orbstack) and Spin, verify the installation: > TELESCOPE_ENABLED=true > ``` -## 7. Development Notes +## 7. Create a Pull Request + +1. After making changes or adding a new service: + - Commit your changes to your forked repository. + - Push the changes to your GitHub account. + +2. Creating the Pull Request (PR): + - Navigate to the main Coolify repository on GitHub. + - Click the "Pull requests" tab. + - Click the green "New pull request" button. + - Choose your fork and branch as the compare branch. + - Click "Create pull request". + +3. Filling out the PR details: + - Give your PR a descriptive title. + - Use the Pull Request Template provided and fill in the details. + +> [!IMPORTANT] +> Always set the base branch for your PR to the `next` branch of the Coolify repository, not the `main` branch. + +4. Submit your PR: + - Review your changes one last time. + - Click "Create pull request" to submit. + +> [!NOTE] +> Make sure your PR is out of draft mode as soon as it's ready for review. PRs that are in draft mode for a long time may be closed by maintainers. + +After submission, maintainers will review your PR and may request changes or provide feedback. + +## Development Notes When working on Coolify, keep the following in mind: @@ -164,35 +194,41 @@ When working on Coolify, keep the following in mind: > [!IMPORTANT] > Forgetting to migrate the database can cause problems, so make it a habit to run migrations after pulling changes or switching branches. -## 8. Create a Pull Request +## Resetting Development Environment -1. After making changes or adding a new service: - - Commit your changes to your forked repository. - - Push the changes to your GitHub account. +If you encounter issues or break your database or something else, follow these steps to start from a clean slate (works since `v4.0.0-beta.342`): -2. Creating the Pull Request (PR): - - Navigate to the main Coolify repository on GitHub. - - Click the "Pull requests" tab. - - Click the green "New pull request" button. - - Choose your fork and branch as the compare branch. - - Click "Create pull request". +1. Stop all running containers `ctrl + c`. -3. Filling out the PR details: - - Give your PR a descriptive title. - - In the description, explain the changes you've made. - - Reference any related issues by using keywords like "Fixes #123" or "Closes #456". +2. Remove all Coolify containers: + ```bash + docker rm coolify coolify-db coolify-redis coolify-realtime coolify-testing-host coolify-minio coolify-vite-1 coolify-mail + ``` + +3. Remove Coolify volumes (it is possible that the volumes have no `coolify` prefix on your machine, in that case remove the prefix from the command): + ```bash + docker volume rm coolify_dev_backups_data coolify_dev_postgres_data coolify_dev_redis_data coolify_dev_coolify_data coolify_dev_minio_data + ``` + +4. Remove unused images: + ```bash + docker image prune -a + ``` + +5. Start Coolify again: + ```bash + spin up + ``` + +6. Run database migrations and seeders: + ```bash + docker exec -it coolify php artisan migrate:fresh --seed + ``` + +After completing these steps, you'll have a fresh development setup. > [!IMPORTANT] -> Always set the base branch for your PR to the `next` branch of the Coolify repository, not the `main` branch. - -4. Submit your PR: - - Review your changes one last time. - - Click "Create pull request" to submit. - -> [!NOTE] -> Make sure your PR is out of draft mode as soon as it's ready for review. PRs that are in draft mode for a long time may be closed by maintainers. - -After submission, maintainers will review your PR and may request changes or provide feedback. +> Always run database migrations and seeders after switching branches or pulling updates to ensure your local database structure matches the current codebase and includes necessary seed data. ## Additional Contribution Guidelines diff --git a/RELEASE.md b/RELEASE.md index 2cb96b72b..d9f05f17d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,35 +2,120 @@ This guide outlines the release process for Coolify, intended for developers and those interested in understanding how releases are managed and deployed. +## Table of Contents +- [Release Process](#release-process) +- [Version Types](#version-types) + - [Stable](#stable) + - [Nightly](#nightly) + - [Beta](#beta) +- [Version Availability](#version-availability) + - [Self-Hosted](#self-hosted) + - [Cloud](#cloud) +- [Manually Update to Specific Versions](#manually-update-to-specific-versions) + ## Release Process -1. **Development on `next` or separate branches** - - Changes, fixes and new features are developed on the `next` or even separate branches. +1. **Development on `next` or Feature Branches** + - Improvements, fixes, and new features are developed on the `next` branch or separate feature branches. 2. **Merging to `main`** - - Once changes are ready, they are merged from `next` into the `main` branch. + - Once ready, changes are merged from the `next` branch into the `main` branch. -3. **Building the release** - - After merging to `main`, a new release is built. - - Note: A push to `main` does not automatically mean a new version is released. +3. **Building the Release** + - After merging to `main`, GitHub Actions automatically builds release images for all architectures and pushes them to the GitHub Container Registry with the version tag and the `latest` tag. -4. **Creating a GitHub release** - - A new release is created on GitHub with the new version details. +4. **Creating a GitHub Release** + - A new GitHub release is manually created with details of the changes made in the version. 5. **Updating the CDN** - - The final step is updating the version information on the CDN: - [https://cdn.coollabs.io/coolify/versions.json](https://cdn.coollabs.io/coolify/versions.json) + - To make a new version publicly available, the version information on the CDN needs to be updated: [https://cdn.coollabs.io/coolify/versions.json](https://cdn.coollabs.io/coolify/versions.json) > [!NOTE] -> The CDN update may not occur immediately after the GitHub release. It can happen hours or even days later due to additional testing, stability checks, or potential hotfixes. +> The CDN update may not occur immediately after the GitHub release. It can take hours or even days due to additional testing, stability checks, or potential hotfixes. **The update becomes available only after the CDN is updated.** +## Version Types + +
+ Stable (coming soon) + +- **Stable** + - The production version suitable for stable, production environments (generally recommended). + - **Update Frequency:** Every 2 to 4 weeks, with more frequent possible hotfixes. + - **Release Size:** Larger but less frequent releases. Multiple nightly versions are consolidated into a single stable release. + - **Versioning Scheme:** Follows semantic versioning (e.g., `v4.0.0`). + - **Installation Command:** + ```bash + curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash + ``` + +
+ +
+ Nightly + +- **Nightly** + - The latest development version, suitable for testing the latest changes and experimenting with new features. + - **Update Frequency:** Daily or bi-weekly updates. + - **Release Size:** Smaller, more frequent releases. + - **Versioning Scheme:** TO BE DETERMINED + - **Installation Command:** + ```bash + curl -fsSL https://cdn.coollabs.io/coolify-nightly/install.sh | bash -s next + ``` + +
+ +
+ Beta + +- **Beta** + - Test releases for the upcoming stable version. + - **Purpose:** Allows users to test and provide feedback on new features and changes before they become stable. + - **Update Frequency:** Available if we think beta testing is necessary. + - **Release Size:** Same size as stable release as it will become the next stabe release after some time. + - **Versioning Scheme:** Follows semantic versioning (e.g., `4.1.0-beta.1`). + - **Installation Command:** + ```bash + curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash + ``` + +
+ +> [!WARNING] +> Do not use nightly/beta builds in production as there is no guarantee of stability. ## Version Availability -It's important to understand that a new version released on GitHub may not immediately become available for users to update (through manual or auto-update). +When a new version is released and a new GitHub release is created, it doesn't immediately become available for your instance. Here's how version availability works for different instance types. + +### Self-Hosted + +- **Update Frequency:** More frequent updates, especially on the nightly release channel. +- **Update Availability:** New versions are available once the CDN has been updated. +- **Update Methods:** + 1. **Manual Update in Instance Settings:** + - Go to `Settings > Update Check Frequency` and click the `Check Manually` button. + - If an update is available, an upgrade button will appear on the sidebar. + 2. **Automatic Update:** + - If enabled, the instance will update automatically at the time set in the settings. + 3. **Re-run Installation Script:** + - Run the installation script again to upgrade to the latest version available on the CDN: + ```bash + curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash + ``` > [!IMPORTANT] -> If you see a new release on GitHub but haven't received the update, it's likely because the CDN hasn't been updated yet. This is intentional and ensures stability and allows for hotfixes before the new version is officially released. +> If a new release is available on GitHub but your instance hasn't updated yet or no upgrade button is shown in the UI, the CDN might not have been updated yet. This intentional delay ensures stability and allows for hotfixes before official release. + +### Cloud + +- **Update Frequency:** Less frequent as it's a managed service. +- **Update Availability:** New versions are available once Andras has updated the cloud version manually. +- **Update Method:** + - Updates are managed by Andras, who ensures each cloud version is thoroughly tested and stable before releasing it. + +> [!IMPORTANT] +> The cloud version of Coolify may be several versions behind the latest GitHub releases even if the CDN is updated. This is intentional to ensure stability and reliability for cloud users and Andras will manully update the cloud version when the update is ready. ## Manually Update to Specific Versions @@ -42,4 +127,4 @@ To update your Coolify instance to a specific (unreleased) version, use the foll ```bash curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash -s ``` --> Replace `` with the version you want to update to (for example `4.0.0-beta.332`). +Replace `` with the version you want to update to (for example `4.0.0-beta.332`). diff --git a/app/Actions/Application/GenerateConfig.php b/app/Actions/Application/GenerateConfig.php new file mode 100644 index 000000000..69365f921 --- /dev/null +++ b/app/Actions/Application/GenerateConfig.php @@ -0,0 +1,17 @@ +clearAll(); + return $application->generateConfig(is_json: $is_json); + } +} diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 7155f9a0a..61005845b 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -2,6 +2,7 @@ namespace App\Actions\Application; +use App\Actions\Server\CleanupDocker; use App\Models\Application; use Lorisleiva\Actions\Concerns\AsAction; @@ -9,44 +10,35 @@ class StopApplication { use AsAction; - public function handle(Application $application, bool $previewDeployments = false) + public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true) { - if ($application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$application->uuid}"], $application->destination->server); - - return; - } - - $servers = collect([]); - $servers->push($application->destination->server); - $application->additional_servers->map(function ($server) use ($servers) { - $servers->push($server); - }); - foreach ($servers as $server) { + try { + $server = $application->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; } - if ($previewDeployments) { - $containers = getCurrentApplicationContainerStatus($server, $application->id, includePullrequests: true); - } else { - $containers = getCurrentApplicationContainerStatus($server, $application->id, 0); - } - if ($containers->count() > 0) { - foreach ($containers as $container) { - $containerName = data_get($container, 'Names'); - if ($containerName) { - instant_remote_process(command: ["docker stop --time=30 $containerName"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm $containerName"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f {$containerName}"], server: $server, throwError: false); - } - } + ray('Stopping application: '.$application->name); + + if ($server->isSwarm()) { + instant_remote_process(["docker stack rm {$application->uuid}"], $server); + + return; } + + $containersToStop = $application->getContainersToStop($previewDeployments); + $application->stopContainers($containersToStop, $server); + if ($application->build_pack === 'dockercompose') { - // remove network - $uuid = $application->uuid; - instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); - instant_remote_process(["docker network rm {$uuid}"], $server, false); + $application->delete_connected_networks($application->uuid); } + + if ($dockerCleanup) { + CleanupDocker::dispatch($server, true); + } + } catch (\Exception $e) { + ray($e->getMessage()); + + return $e->getMessage(); } } } diff --git a/app/Actions/CoolifyTask/RunRemoteProcess.php b/app/Actions/CoolifyTask/RunRemoteProcess.php index 63e3afe2f..c691f52c0 100644 --- a/app/Actions/CoolifyTask/RunRemoteProcess.php +++ b/app/Actions/CoolifyTask/RunRemoteProcess.php @@ -4,6 +4,7 @@ namespace App\Actions\CoolifyTask; use App\Enums\ActivityTypes; use App\Enums\ProcessStatus; +use App\Helpers\SshMultiplexingHelper; use App\Jobs\ApplicationDeploymentJob; use App\Models\Server; use Illuminate\Process\ProcessResult; @@ -137,7 +138,7 @@ class RunRemoteProcess $command = $this->activity->getExtraProperty('command'); $server = Server::whereUuid($server_uuid)->firstOrFail(); - return generateSshCommand($server, $command); + return SshMultiplexingHelper::generateSshCommand($server, $command); } protected function handleOutput(string $type, string $output) diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 621834df0..3ee46a2e1 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -23,7 +23,7 @@ class StartDragonfly $startCommand = "dragonfly --requirepass {$this->database->dragonfly_password}"; $container_name = $this->database->uuid; - $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ "echo 'Starting {$database->name}.'", @@ -46,9 +46,6 @@ class StartDragonfly 'networks' => [ $this->database->destination->network, ], - 'ulimits' => [ - 'memlock' => '-1', - ], 'labels' => [ 'coolify.managed' => 'true', ], @@ -75,7 +72,7 @@ class StartDragonfly ], ], ]; - if (!is_null($this->database->limits_cpuset)) { + if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { @@ -118,10 +115,10 @@ class StartDragonfly $local_persistent_volumes = []; foreach ($this->database->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { - $local_persistent_volumes[] = $persistentStorage->host_path . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path; } else { $volume_name = $persistentStorage->name; - $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } } @@ -152,7 +149,7 @@ class StartDragonfly $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { $environment_variables->push("REDIS_PASSWORD={$this->database->dragonfly_password}"); } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 9290efc7c..a11452a68 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -24,7 +24,7 @@ class StartKeydb $startCommand = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes"; $container_name = $this->database->uuid; - $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ "echo 'Starting {$database->name}.'", @@ -74,7 +74,7 @@ class StartKeydb ], ], ]; - if (!is_null($this->database->limits_cpuset)) { + if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { @@ -94,10 +94,10 @@ class StartKeydb if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } - if (!is_null($this->database->keydb_conf) || !empty($this->database->keydb_conf)) { + if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', - 'source' => $this->configuration_dir . '/keydb.conf', + 'source' => $this->configuration_dir.'/keydb.conf', 'target' => '/etc/keydb/keydb.conf', 'read_only' => true, ]; @@ -125,10 +125,10 @@ class StartKeydb $local_persistent_volumes = []; foreach ($this->database->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { - $local_persistent_volumes[] = $persistentStorage->host_path . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path; } else { $volume_name = $persistentStorage->name; - $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } } @@ -159,7 +159,7 @@ class StartKeydb $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { $environment_variables->push("REDIS_PASSWORD={$this->database->keydb_password}"); } diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index f37a5e361..a5630f734 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -21,7 +21,7 @@ class StartMariadb $this->database = $database; $container_name = $this->database->uuid; - $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ "echo 'Starting {$database->name}.'", @@ -69,7 +69,7 @@ class StartMariadb ], ], ]; - if (!is_null($this->database->limits_cpuset)) { + if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { @@ -89,10 +89,10 @@ class StartMariadb if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } - if (!is_null($this->database->mariadb_conf) || !empty($this->database->mariadb_conf)) { + if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', - 'source' => $this->configuration_dir . '/custom-config.cnf', + 'source' => $this->configuration_dir.'/custom-config.cnf', 'target' => '/etc/mysql/conf.d/custom-config.cnf', 'read_only' => true, ]; @@ -120,10 +120,10 @@ class StartMariadb $local_persistent_volumes = []; foreach ($this->database->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { - $local_persistent_volumes[] = $persistentStorage->host_path . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path; } else { $volume_name = $persistentStorage->name; - $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } } @@ -154,18 +154,18 @@ class StartMariadb $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) { $environment_variables->push("MARIADB_ROOT_PASSWORD={$this->database->mariadb_root_password}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MARIADB_DATABASE'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_DATABASE'))->isEmpty()) { $environment_variables->push("MARIADB_DATABASE={$this->database->mariadb_database}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MARIADB_USER'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_USER'))->isEmpty()) { $environment_variables->push("MARIADB_USER={$this->database->mariadb_user}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MARIADB_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_PASSWORD'))->isEmpty()) { $environment_variables->push("MARIADB_PASSWORD={$this->database->mariadb_password}"); } diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 42fc8f348..5bff194d5 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -23,7 +23,7 @@ class StartMongodb $startCommand = 'mongod'; $container_name = $this->database->uuid; - $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ "echo 'Starting {$database->name}.'", @@ -77,7 +77,7 @@ class StartMongodb ], ], ]; - if (!is_null($this->database->limits_cpuset)) { + if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { @@ -97,19 +97,19 @@ class StartMongodb if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } - if (!is_null($this->database->mongo_conf) || !empty($this->database->mongo_conf)) { + if (! is_null($this->database->mongo_conf) || ! empty($this->database->mongo_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', - 'source' => $this->configuration_dir . '/mongod.conf', + 'source' => $this->configuration_dir.'/mongod.conf', 'target' => '/etc/mongo/mongod.conf', 'read_only' => true, ]; - $docker_compose['services'][$container_name]['command'] = $startCommand . ' --config /etc/mongo/mongod.conf'; + $docker_compose['services'][$container_name]['command'] = $startCommand.' --config /etc/mongo/mongod.conf'; } $this->add_default_database(); $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', - 'source' => $this->configuration_dir . '/docker-entrypoint-initdb.d', + 'source' => $this->configuration_dir.'/docker-entrypoint-initdb.d', 'target' => '/docker-entrypoint-initdb.d', 'read_only' => true, ]; @@ -136,10 +136,10 @@ class StartMongodb $local_persistent_volumes = []; foreach ($this->database->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { - $local_persistent_volumes[] = $persistentStorage->host_path . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path; } else { $volume_name = $persistentStorage->name; - $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } } @@ -170,15 +170,15 @@ class StartMongodb $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) { $environment_variables->push("MONGO_INITDB_ROOT_USERNAME={$this->database->mongo_initdb_root_username}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MONGO_INITDB_ROOT_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_PASSWORD'))->isEmpty()) { $environment_variables->push("MONGO_INITDB_ROOT_PASSWORD={$this->database->mongo_initdb_root_password}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MONGO_INITDB_DATABASE'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_DATABASE'))->isEmpty()) { $environment_variables->push("MONGO_INITDB_DATABASE={$this->database->mongo_initdb_database}"); } diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 2043342fe..cc4203580 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -21,7 +21,7 @@ class StartMysql $this->database = $database; $container_name = $this->database->uuid; - $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ "echo 'Starting {$database->name}.'", @@ -69,7 +69,7 @@ class StartMysql ], ], ]; - if (!is_null($this->database->limits_cpuset)) { + if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { @@ -89,10 +89,10 @@ class StartMysql if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } - if (!is_null($this->database->mysql_conf) || !empty($this->database->mysql_conf)) { + if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', - 'source' => $this->configuration_dir . '/custom-config.cnf', + 'source' => $this->configuration_dir.'/custom-config.cnf', 'target' => '/etc/mysql/conf.d/custom-config.cnf', 'read_only' => true, ]; @@ -120,10 +120,10 @@ class StartMysql $local_persistent_volumes = []; foreach ($this->database->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { - $local_persistent_volumes[] = $persistentStorage->host_path . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path; } else { $volume_name = $persistentStorage->name; - $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } } @@ -154,18 +154,18 @@ class StartMysql $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) { $environment_variables->push("MYSQL_ROOT_PASSWORD={$this->database->mysql_root_password}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MYSQL_DATABASE'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_DATABASE'))->isEmpty()) { $environment_variables->push("MYSQL_DATABASE={$this->database->mysql_database}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MYSQL_USER'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_USER'))->isEmpty()) { $environment_variables->push("MYSQL_USER={$this->database->mysql_user}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MYSQL_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_PASSWORD'))->isEmpty()) { $environment_variables->push("MYSQL_PASSWORD={$this->database->mysql_password}"); } diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index bc37fd5cf..2a8e5476c 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -37,7 +37,6 @@ class StartPostgresql $this->generate_init_scripts(); $this->add_custom_conf(); - $docker_compose = [ 'services' => [ $container_name => [ diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index b837414d6..eeddab924 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -24,7 +24,7 @@ class StartRedis $startCommand = "redis-server --requirepass {$this->database->redis_password} --appendonly yes"; $container_name = $this->database->uuid; - $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ "echo 'Starting {$database->name}.'", @@ -78,7 +78,7 @@ class StartRedis ], ], ]; - if (!is_null($this->database->limits_cpuset)) { + if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { @@ -98,10 +98,10 @@ class StartRedis if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } - if (!is_null($this->database->redis_conf) || !empty($this->database->redis_conf)) { + if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', - 'source' => $this->configuration_dir . '/redis.conf', + 'source' => $this->configuration_dir.'/redis.conf', 'target' => '/usr/local/etc/redis/redis.conf', 'read_only' => true, ]; @@ -130,10 +130,10 @@ class StartRedis $local_persistent_volumes = []; foreach ($this->database->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { - $local_persistent_volumes[] = $persistentStorage->host_path . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path; } else { $volume_name = $persistentStorage->name; - $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } } @@ -164,7 +164,7 @@ class StartRedis $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { $environment_variables->push("REDIS_PASSWORD={$this->database->redis_password}"); } diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index d562ec56f..e4cea7cee 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Server\CleanupDocker; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -10,25 +11,65 @@ use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use Illuminate\Support\Facades\Process; use Lorisleiva\Actions\Concerns\AsAction; class StopDatabase { use AsAction; - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database) + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $isDeleteOperation = false, bool $dockerCleanup = true) { $server = $database->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; } - instant_remote_process(command: ["docker stop --time=30 $database->uuid"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm $database->uuid"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f $database->uuid"], server: $server, throwError: false); + $this->stopContainer($database, $database->uuid, 300); + if (! $isDeleteOperation) { + if ($dockerCleanup) { + CleanupDocker::dispatch($server, true); + } + } if ($database->is_public) { StopDatabaseProxy::run($database); } + + return 'Database stopped successfully'; + } + + private function stopContainer($database, string $containerName, int $timeout = 300): void + { + $server = $database->destination->server; + + $process = Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + + $startTime = time(); + while ($process->running()) { + if (time() - $startTime >= $timeout) { + $this->forceStopContainer($containerName, $server); + break; + } + usleep(100000); + } + + $this->removeContainer($containerName, $server); + } + + private function forceStopContainer(string $containerName, $server): void + { + instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false); + } + + private function removeContainer(string $containerName, $server): void + { + instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false); + } + + private function deleteConnectedNetworks($uuid, $server) + { + instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$uuid}"], $server, false); } } diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index fdaa88ebf..ed563eaae 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -543,7 +543,7 @@ class GetContainersStatus } } } - $exitedServices = $exitedServices->unique('id'); + $exitedServices = $exitedServices->unique('uuid'); foreach ($exitedServices as $exitedService) { if (str($exitedService->status)->startsWith('exited')) { continue; @@ -651,8 +651,9 @@ class GetContainersStatus // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); } - // Check if proxy is running - $this->server->proxyType(); + if (! $this->server->proxySet() || $this->server->proxy->force_stop) { + return; + } $foundProxyContainer = $this->containers->filter(function ($value, $key) { if ($this->server->isSwarm()) { return data_get($value, 'Spec.Name') === 'coolify-proxy_traefik'; diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index f8882d12a..481757162 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -2,7 +2,6 @@ namespace App\Actions\Fortify; -use App\Models\InstanceSettings; use App\Models\User; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; @@ -20,7 +19,7 @@ class CreateNewUser implements CreatesNewUsers */ public function create(array $input): User { - $settings = InstanceSettings::get(); + $settings = instanceSettings(); if (! $settings->is_registration_enabled) { abort(403); } @@ -48,7 +47,7 @@ class CreateNewUser implements CreatesNewUsers $team = $user->teams()->first(); // Disable registration after first user is created - $settings = InstanceSettings::get(); + $settings = instanceSettings(); $settings->is_registration_enabled = false; $settings->save(); } else { diff --git a/app/Actions/License/CheckResaleLicense.php b/app/Actions/License/CheckResaleLicense.php index dcb4058c0..55af1a8c0 100644 --- a/app/Actions/License/CheckResaleLicense.php +++ b/app/Actions/License/CheckResaleLicense.php @@ -2,7 +2,6 @@ namespace App\Actions\License; -use App\Models\InstanceSettings; use Illuminate\Support\Facades\Http; use Lorisleiva\Actions\Concerns\AsAction; @@ -13,7 +12,7 @@ class CheckResaleLicense public function handle() { try { - $settings = InstanceSettings::get(); + $settings = instanceSettings(); if (isDev()) { $settings->update([ 'is_resale_license_active' => true, diff --git a/app/Actions/Proxy/CheckConfiguration.php b/app/Actions/Proxy/CheckConfiguration.php index f4fe650c5..bdeafd061 100644 --- a/app/Actions/Proxy/CheckConfiguration.php +++ b/app/Actions/Proxy/CheckConfiguration.php @@ -22,7 +22,7 @@ class CheckConfiguration ]; $proxy_configuration = instant_remote_process($payload, $server, false); if ($reset || ! $proxy_configuration || is_null($proxy_configuration)) { - $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value; + $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value(); } if (! $proxy_configuration || is_null($proxy_configuration)) { throw new \Exception('Could not generate proxy configuration'); diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index 735b972af..03a0beddf 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -2,14 +2,17 @@ namespace App\Actions\Proxy; +use App\Enums\ProxyTypes; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; +use Symfony\Component\Yaml\Yaml; class CheckProxy { use AsAction; - public function handle(Server $server, $fromUI = false) + // It should return if the proxy should be started (true) or not (false) + public function handle(Server $server, $fromUI = false): bool { if (! $server->isFunctional()) { return false; @@ -26,7 +29,7 @@ class CheckProxy if (is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop) { return false; } - ['uptime' => $uptime, 'error' => $error] = $server->validateConnection(); + ['uptime' => $uptime, 'error' => $error] = $server->validateConnection(false); if (! $uptime) { throw new \Exception($error); } @@ -62,22 +65,42 @@ class CheckProxy $ip = 'host.docker.internal'; } - $connection80 = @fsockopen($ip, '80'); - $connection443 = @fsockopen($ip, '443'); - $port80 = is_resource($connection80) && fclose($connection80); - $port443 = is_resource($connection443) && fclose($connection443); - if ($port80) { - if ($fromUI) { - throw new \Exception("Port 80 is in use.
You must stop the process using this port.
Docs: https://coolify.io/docs
Discord: https://coollabs.io/discord"); + $portsToCheck = ['80', '443']; + + try { + if ($server->proxyType() !== ProxyTypes::NONE->value) { + $proxyCompose = CheckConfiguration::run($server); + if (isset($proxyCompose)) { + $yaml = Yaml::parse($proxyCompose); + $portsToCheck = []; + if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { + $ports = data_get($yaml, 'services.traefik.ports'); + } elseif ($server->proxyType() === ProxyTypes::CADDY->value) { + $ports = data_get($yaml, 'services.caddy.ports'); + } + if (isset($ports)) { + foreach ($ports as $port) { + $portsToCheck[] = str($port)->before(':')->value(); + } + } + } } else { - return false; + $portsToCheck = []; } + } catch (\Exception $e) { + ray($e->getMessage()); } - if ($port443) { - if ($fromUI) { - throw new \Exception("Port 443 is in use.
You must stop the process using this port.
Docs: https://coolify.io/docs
Discord: https://coollabs.io/discord"); - } else { - return false; + if (count($portsToCheck) === 0) { + return false; + } + foreach ($portsToCheck as $port) { + $connection = @fsockopen($ip, $port); + if (is_resource($connection) && fclose($connection)) { + if ($fromUI) { + throw new \Exception("Port $port is in use.
You must stop the process using this port.
Docs: https://coolify.io/docs
Discord: https://coollabs.io/discord"); + } else { + return false; + } } } diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index 991c94b11..f20c10123 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -26,7 +26,7 @@ class StartProxy } SaveConfiguration::run($server, $configuration); $docker_compose_yml_base64 = base64_encode($configuration); - $server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value; + $server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); $server->save(); if ($server->isSwarm()) { $commands = $commands->merge([ @@ -35,7 +35,7 @@ class StartProxy "echo 'Creating required Docker Compose file.'", "echo 'Starting coolify-proxy.'", 'docker stack deploy -c docker-compose.yml coolify-proxy', - "echo 'Proxy started successfully.'", + "echo 'Successfully started coolify-proxy.'", ]); } else { $caddfile = 'import /dynamic/*.caddy'; @@ -46,11 +46,14 @@ class StartProxy "echo 'Creating required Docker Compose file.'", "echo 'Pulling docker image.'", 'docker compose pull', - "echo 'Stopping existing coolify-proxy.'", - 'docker compose down -v --remove-orphans > /dev/null 2>&1', + 'if docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then', + " echo 'Stopping and removing existing coolify-proxy.'", + ' docker rm -f coolify-proxy || true', + " echo 'Successfully stopped and removed existing coolify-proxy.'", + 'fi', "echo 'Starting coolify-proxy.'", 'docker compose up -d --remove-orphans', - "echo 'Proxy started successfully.'", + "echo 'Successfully started coolify-proxy.'", ]); $commands = $commands->merge(connectProxyToNetworks($server)); } diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 1034c13d6..dc6ac12bf 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -2,7 +2,6 @@ namespace App\Actions\Server; -use App\Models\InstanceSettings; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; @@ -12,28 +11,29 @@ class CleanupDocker public function handle(Server $server) { + $settings = instanceSettings(); + $helperImageVersion = data_get($settings, 'helper_version'); + $helperImage = config('coolify.helper_image'); + $helperImageWithVersion = "$helperImage:$helperImageVersion"; - $commands = $this->getCommands(); + $commands = [ + 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', + 'docker image prune -af --filter "label!=coolify.managed=true"', + 'docker builder prune -af', + "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", + ]; + + $serverSettings = $server->settings; + if ($serverSettings->delete_unused_volumes) { + $commands[] = 'docker volume prune -af'; + } + + if ($serverSettings->delete_unused_networks) { + $commands[] = 'docker network prune -f'; + } foreach ($commands as $command) { instant_remote_process([$command], $server, false); } } - - private function getCommands(): array - { - $settings = InstanceSettings::get(); - $helperImageVersion = data_get($settings, 'helper_version'); - $helperImage = config('coolify.helper_image'); - $helperImageWithVersion = config('coolify.helper_image').':'.$helperImageVersion; - - $commonCommands = [ - 'docker container prune -f --filter "label=coolify.managed=true"', - 'docker image prune -af --filter "label!=coolify.managed=true"', - 'docker builder prune -af', - "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi", - ]; - - return $commonCommands; - } } diff --git a/app/Actions/Server/ConfigureCloudflared.php b/app/Actions/Server/ConfigureCloudflared.php index 3946afe95..0d36e8863 100644 --- a/app/Actions/Server/ConfigureCloudflared.php +++ b/app/Actions/Server/ConfigureCloudflared.php @@ -2,6 +2,7 @@ namespace App\Actions\Server; +use App\Events\CloudflareTunnelConfigured; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -40,12 +41,17 @@ class ConfigureCloudflared instant_remote_process($commands, $server); } catch (\Throwable $e) { ray($e); + $server->settings->is_cloudflare_tunnel = false; + $server->settings->save(); throw $e; } finally { + CloudflareTunnelConfigured::dispatch($server->team_id); + $commands = collect([ 'rm -fr /tmp/cloudflared', ]); instant_remote_process($commands, $server); + } } } diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php new file mode 100644 index 000000000..15c892e75 --- /dev/null +++ b/app/Actions/Server/DeleteServer.php @@ -0,0 +1,17 @@ +forceDelete(); + } +} diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index f671f2d2a..2e1df8185 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -17,7 +17,7 @@ class InstallDocker throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: documentation.'); } ray('Installing Docker on server: '.$server->name.' ('.$server->ip.')'.' with OS type: '.$supported_os_type); - $dockerVersion = '24.0'; + $dockerVersion = '26.0'; $config = base64_encode('{ "log-driver": "json-file", "log-opts": { diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index b79bc8f67..cca8138b9 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -2,6 +2,7 @@ namespace App\Actions\Server; +use App\Models\InstanceSettings; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; @@ -9,18 +10,48 @@ class StartSentinel { use AsAction; - public function handle(Server $server, $version = 'latest', bool $restart = false) + public function handle(Server $server, $version = 'next', bool $restart = false) { if ($restart) { StopSentinel::run($server); } - $metrics_history = $server->settings->metrics_history_days; - $refresh_rate = $server->settings->metrics_refresh_rate_seconds; - $token = $server->settings->metrics_token; + $metrics_history = data_get($server, 'settings.sentinel_metrics_history_days'); + $refresh_rate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds'); + $push_interval = data_get($server, 'settings.sentinel_push_interval_seconds'); + $token = data_get($server, 'settings.sentinel_token'); + $endpoint = data_get($server, 'settings.sentinel_custom_url'); + $mount_dir = '/data/coolify/sentinel'; + $image = "ghcr.io/coollabsio/sentinel:$version"; + if (! $endpoint) { + throw new \Exception('You should set FQDN in Instance Settings.'); + } + $environments = [ + 'TOKEN' => $token, + 'PUSH_ENDPOINT' => $endpoint, + 'PUSH_INTERVAL_SECONDS' => $push_interval, + 'COLLECTOR_ENABLED' => $server->isMetricsEnabled() ? 'true' : 'false', + 'COLLECTOR_REFRESH_RATE_SECONDS' => $refresh_rate, + 'COLLECTOR_RETENTION_PERIOD_DAYS' => $metrics_history, + ]; + if (isDev()) { + // data_set($environments, 'DEBUG', 'true'); + $mount_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel'; + // $image = 'sentinel'; + } + $docker_environments = '-e "' . implode('" -e "', array_map(fn($key, $value) => "$key=$value", array_keys($environments), $environments)) . '"'; + + $docker_command = "docker run -d $docker_environments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mount_dir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway $image"; + instant_remote_process([ - "docker run --rm --pull always -d -e \"TOKEN={$token}\" -e \"SCHEDULER=true\" -e \"METRICS_HISTORY={$metrics_history}\" -e \"REFRESH_RATE={$refresh_rate}\" --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v /data/coolify/metrics:/app/metrics -v /data/coolify/logs:/app/logs --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 ghcr.io/coollabsio/sentinel:$version", - 'chown -R 9999:root /data/coolify/metrics /data/coolify/logs', - 'chmod -R 700 /data/coolify/metrics /data/coolify/logs', - ], $server, true); + 'docker rm -f coolify-sentinel || true', + "mkdir -p $mount_dir", + $docker_command, + "chown -R 9999:root $mount_dir", + "chmod -R 700 $mount_dir", + ], $server); + + $server->settings->is_sentinel_enabled = true; + $server->settings->save(); + $server->sentinelHeartbeat(); } } diff --git a/app/Actions/Server/StopSentinel.php b/app/Actions/Server/StopSentinel.php index 21ffca3bd..edb6843af 100644 --- a/app/Actions/Server/StopSentinel.php +++ b/app/Actions/Server/StopSentinel.php @@ -3,6 +3,7 @@ namespace App\Actions\Server; use App\Models\Server; +use Carbon\Carbon; use Lorisleiva\Actions\Concerns\AsAction; class StopSentinel @@ -12,5 +13,6 @@ class StopSentinel public function handle(Server $server) { instant_remote_process(['docker rm -f coolify-sentinel'], $server, false); + $server->sentinelHeartbeat(isReset: true); } } diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index c4af6bb21..30664df26 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -2,7 +2,7 @@ namespace App\Actions\Server; -use App\Models\InstanceSettings; +use App\Jobs\PullHelperImageJob; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; @@ -19,7 +19,7 @@ class UpdateCoolify public function handle($manual_update = false) { try { - $settings = InstanceSettings::get(); + $settings = instanceSettings(); $this->server = Server::find(0); if (! $this->server) { return; @@ -55,6 +55,13 @@ class UpdateCoolify return; } + + $all_servers = Server::all(); + $servers = $all_servers->where('settings.is_usable', true)->where('settings.is_reachable', true)->where('ip', '!=', '1.2.3.4'); + foreach ($servers as $server) { + PullHelperImageJob::dispatch($server); + } + instant_remote_process(["docker pull -q ghcr.io/coollabsio/coolify:{$this->latestVersion}"], $this->server, false); remote_process([ diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 194cf4db9..f28e5490e 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -2,6 +2,7 @@ namespace App\Actions\Service; +use App\Actions\Server\CleanupDocker; use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; @@ -9,11 +10,11 @@ class DeleteService { use AsAction; - public function handle(Service $service) + public function handle(Service $service, bool $deleteConfigurations, bool $deleteVolumes, bool $dockerCleanup, bool $deleteConnectedNetworks) { try { $server = data_get($service, 'server'); - if ($server->isFunctional()) { + if ($deleteVolumes && $server->isFunctional()) { $storagesToDelete = collect([]); $service->environment_variables()->delete(); @@ -33,13 +34,29 @@ class DeleteService foreach ($storagesToDelete as $storage) { $commands[] = "docker volume rm -f $storage->name"; } - $commands[] = "docker rm -f $service->uuid"; - instant_remote_process($commands, $server, false); + // Execute volume deletion first, this must be done first otherwise volumes will not be deleted. + if (! empty($commands)) { + foreach ($commands as $command) { + $result = instant_remote_process([$command], $server, false); + if ($result !== 0) { + ray("Failed to execute: $command"); + } + } + } } + + if ($deleteConnectedNetworks) { + $service->delete_connected_networks($service->uuid); + } + + instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false); } catch (\Exception $e) { throw new \Exception($e->getMessage()); } finally { + if ($deleteConfigurations) { + $service->delete_configurations(); + } foreach ($service->applications()->get() as $application) { $application->forceDelete(); } @@ -50,6 +67,11 @@ class DeleteService $task->delete(); } $service->tags()->detach(); + $service->forceDelete(); + + if ($dockerCleanup) { + CleanupDocker::dispatch($server, true); + } } } } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 7aef457a1..06d2e0efb 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -16,7 +16,7 @@ class StartService $service->saveComposeConfigs(); $commands[] = 'cd '.$service->workdir(); $commands[] = "echo 'Saved configuration files to {$service->workdir()}.'"; - if($service->networks()->count() > 0){ + if ($service->networks()->count() > 0) { $commands[] = "echo 'Creating Docker network.'"; $commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid"; } @@ -31,7 +31,7 @@ class StartService $network = $service->destination->network; $serviceNames = data_get(Yaml::parse($compose), 'services', []); foreach ($serviceNames as $serviceName => $serviceConfig) { - $commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} $network {$serviceName}-{$service->uuid} || true"; + $commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} $network {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true"; } } $activity = remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 82b0b3ece..5c7bbc2aa 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -2,6 +2,7 @@ namespace App\Actions\Service; +use App\Actions\Server\CleanupDocker; use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; @@ -9,40 +10,27 @@ class StopService { use AsAction; - public function handle(Service $service) + public function handle(Service $service, bool $isDeleteOperation = false, bool $dockerCleanup = true) { try { $server = $service->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; } - ray('Stopping service: '.$service->name); - $applications = $service->applications()->get(); - foreach ($applications as $application) { - if ($applications->count() < 6) { - instant_remote_process(command: ["docker stop --time=10 {$application->name}-{$service->uuid}"], server: $server, throwError: false); - } - instant_remote_process(command: ["docker rm {$application->name}-{$service->uuid}"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f {$application->name}-{$service->uuid}"], server: $server, throwError: false); - $application->update(['status' => 'exited']); - } - $dbs = $service->databases()->get(); - foreach ($dbs as $db) { - if ($dbs->count() < 6) { - instant_remote_process(command: ["docker stop --time=10 {$db->name}-{$service->uuid}"], server: $server, throwError: false); + $containersToStop = $service->getContainersToStop(); + $service->stopContainers($containersToStop, $server); + + if (! $isDeleteOperation) { + $service->delete_connected_networks($service->uuid); + if ($dockerCleanup) { + CleanupDocker::dispatch($server, true); } - instant_remote_process(command: ["docker rm {$db->name}-{$service->uuid}"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f {$db->name}-{$service->uuid}"], server: $server, throwError: false); - $db->update(['status' => 'exited']); } - instant_remote_process(["docker network disconnect {$service->uuid} coolify-proxy"], $service->server); - instant_remote_process(["docker network rm {$service->uuid}"], $service->server); } catch (\Exception $e) { ray($e->getMessage()); return $e->getMessage(); } - } } diff --git a/app/Console/Commands/CheckApplicationDeploymentQueue.php b/app/Console/Commands/CheckApplicationDeploymentQueue.php new file mode 100644 index 000000000..e89d26f2c --- /dev/null +++ b/app/Console/Commands/CheckApplicationDeploymentQueue.php @@ -0,0 +1,50 @@ +option('seconds'); + $deployments = ApplicationDeploymentQueue::whereIn('status', [ + ApplicationDeploymentStatus::IN_PROGRESS, + ApplicationDeploymentStatus::QUEUED, + ])->where('created_at', '<=', now()->subSeconds($seconds))->get(); + if ($deployments->isEmpty()) { + $this->info('No deployments found in the last '.$seconds.' seconds.'); + + return; + } + + $this->info('Found '.$deployments->count().' deployments created in the last '.$seconds.' seconds.'); + + foreach ($deployments as $deployment) { + if ($this->option('force')) { + $this->info('Deployment '.$deployment->id.' created at '.$deployment->created_at.' is older than '.$seconds.' seconds. Setting status to failed.'); + $this->cancelDeployment($deployment); + } else { + $this->info('Deployment '.$deployment->id.' created at '.$deployment->created_at.' is older than '.$seconds.' seconds. Setting status to failed.'); + if ($this->confirm('Do you want to cancel this deployment?', true)) { + $this->cancelDeployment($deployment); + } + } + } + } + + private function cancelDeployment(ApplicationDeploymentQueue $deployment) + { + $deployment->update(['status' => ApplicationDeploymentStatus::FAILED]); + if ($deployment->server?->isFunctional()) { + remote_process(['docker rm -f '.$deployment->deployment_uuid], $deployment->server, false); + } + } +} diff --git a/app/Console/Commands/CleanupApplicationDeploymentQueue.php b/app/Console/Commands/CleanupApplicationDeploymentQueue.php index f068e3eb2..3aae28ae6 100644 --- a/app/Console/Commands/CleanupApplicationDeploymentQueue.php +++ b/app/Console/Commands/CleanupApplicationDeploymentQueue.php @@ -7,9 +7,9 @@ use Illuminate\Console\Command; class CleanupApplicationDeploymentQueue extends Command { - protected $signature = 'cleanup:application-deployment-queue {--team-id=}'; + protected $signature = 'cleanup:deployment-queue {--team-id=}'; - protected $description = 'CleanupApplicationDeploymentQueue'; + protected $description = 'Cleanup application deployment queue.'; public function handle() { diff --git a/app/Console/Commands/CleanupQueue.php b/app/Console/Commands/CleanupQueue.php deleted file mode 100644 index fd2b637ac..000000000 --- a/app/Console/Commands/CleanupQueue.php +++ /dev/null @@ -1,24 +0,0 @@ -keys('*:laravel*'); - foreach ($keys as $key) { - $keyWithoutPrefix = str_replace($prefix, '', $key); - Redis::connection()->del($keyWithoutPrefix); - } - } -} diff --git a/app/Console/Commands/CleanupRedis.php b/app/Console/Commands/CleanupRedis.php new file mode 100644 index 000000000..ed0740d34 --- /dev/null +++ b/app/Console/Commands/CleanupRedis.php @@ -0,0 +1,31 @@ +keys('*:laravel*'); + collect($keys)->each(function ($key) use ($prefix) { + $keyWithoutPrefix = str_replace($prefix, '', $key); + Redis::connection()->del($keyWithoutPrefix); + }); + + $queueOverlaps = Redis::connection()->keys('*laravel-queue-overlap*'); + collect($queueOverlaps)->each(function ($key) { + Redis::connection()->del($key); + }); + + } +} diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php index 68beb448a..66c25ec27 100644 --- a/app/Console/Commands/CleanupStuckedResources.php +++ b/app/Console/Commands/CleanupStuckedResources.php @@ -2,10 +2,13 @@ namespace App\Console\Commands; +use App\Jobs\CleanupHelperContainersJob; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledTask; +use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; @@ -35,6 +38,27 @@ class CleanupStuckedResources extends Command private function cleanup_stucked_resources() { + try { + $servers = Server::all()->filter(function ($server) { + return $server->isFunctional(); + }); + foreach ($servers as $server) { + CleanupHelperContainersJob::dispatch($server); + } + } catch (\Throwable $e) { + echo "Error in cleaning stucked resources: {$e->getMessage()}\n"; + } + try { + $applicationsDeploymentQueue = ApplicationDeploymentQueue::get(); + foreach ($applicationsDeploymentQueue as $applicationDeploymentQueue) { + if (is_null($applicationDeploymentQueue->application)) { + echo "Deleting stuck application deployment queue: {$applicationDeploymentQueue->id}\n"; + $applicationDeploymentQueue->delete(); + } + } + } catch (\Throwable $e) { + echo "Error in cleaning stuck application deployment queue: {$e->getMessage()}\n"; + } try { $applications = Application::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($applications as $application) { diff --git a/app/Console/Commands/Dev.php b/app/Console/Commands/Dev.php index 964b8e46e..20a2667c3 100644 --- a/app/Console/Commands/Dev.php +++ b/app/Console/Commands/Dev.php @@ -48,6 +48,13 @@ class Dev extends Command echo "Generating APP_KEY.\n"; Artisan::call('key:generate'); } + + // Generate STORAGE link if not exists + if (! file_exists(public_path('storage'))) { + echo "Generating STORAGE link.\n"; + Artisan::call('storage:link'); + } + // Seed database if it's empty $settings = InstanceSettings::find(0); if (! $settings) { diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index 7bfd1a14f..ad7bff86d 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -5,10 +5,8 @@ namespace App\Console\Commands; use App\Actions\Server\StopSentinel; use App\Enums\ActivityTypes; use App\Enums\ApplicationDeploymentStatus; -use App\Jobs\CleanupHelperContainersJob; use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; -use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandalonePostgresql; @@ -18,7 +16,7 @@ use Illuminate\Support\Facades\Http; class Init extends Command { - protected $signature = 'app:init {--full-cleanup} {--cleanup-deployments} {--cleanup-proxy-networks}'; + protected $signature = 'app:init {--force-cloud}'; protected $description = 'Cleanup instance related stuffs'; @@ -26,9 +24,63 @@ class Init extends Command public function handle() { + if (isCloud() && ! $this->option('force-cloud')) { + echo "Skipping init as we are on cloud and --force-cloud option is not set\n"; + + return; + } + $this->servers = Server::all(); - $this->alive(); - get_public_ips(); + if (isCloud()) { + + } else { + $this->send_alive_signal(); + get_public_ips(); + } + + // Backward compatibility + $this->disable_metrics(); + $this->replace_slash_in_environment_name(); + $this->restore_coolify_db_backup(); + // + $this->update_traefik_labels(); + if (! isCloud() || $this->option('force-cloud')) { + $this->cleanup_unused_network_from_coolify_proxy(); + } + if (isCloud()) { + $this->cleanup_unnecessary_dynamic_proxy_configuration(); + } else { + $this->cleanup_in_progress_application_deployments(); + } + $this->call('cleanup:redis'); + $this->call('cleanup:stucked-resources'); + + if (isCloud()) { + $response = Http::retry(3, 1000)->get(config('constants.services.official')); + if ($response->successful()) { + $services = $response->json(); + File::put(base_path('templates/service-templates.json'), json_encode($services)); + } + } else { + try { + $localhost = $this->servers->where('id', 0)->first(); + $localhost->setupDynamicProxyConfiguration(); + } catch (\Throwable $e) { + echo "Could not setup dynamic configuration: {$e->getMessage()}\n"; + } + $settings = instanceSettings(); + if (! is_null(env('AUTOUPDATE', null))) { + if (env('AUTOUPDATE') == true) { + $settings->update(['is_auto_update_enabled' => true]); + } else { + $settings->update(['is_auto_update_enabled' => false]); + } + } + } + } + + private function disable_metrics() + { if (version_compare('4.0.0-beta.312', config('version'), '<=')) { foreach ($this->servers as $server) { if ($server->settings->is_metrics_enabled === true) { @@ -39,62 +91,6 @@ class Init extends Command } } } - - $full_cleanup = $this->option('full-cleanup'); - $cleanup_deployments = $this->option('cleanup-deployments'); - $cleanup_proxy_networks = $this->option('cleanup-proxy-networks'); - $this->replace_slash_in_environment_name(); - if ($cleanup_deployments) { - echo "Running cleanup deployments.\n"; - $this->cleanup_in_progress_application_deployments(); - - return; - } - if ($cleanup_proxy_networks) { - echo "Running cleanup proxy networks.\n"; - $this->cleanup_unused_network_from_coolify_proxy(); - - return; - } - if ($full_cleanup) { - // Required for falsely deleted coolify db - $this->restore_coolify_db_backup(); - $this->update_traefik_labels(); - $this->cleanup_unused_network_from_coolify_proxy(); - $this->cleanup_unnecessary_dynamic_proxy_configuration(); - $this->cleanup_in_progress_application_deployments(); - $this->cleanup_stucked_helper_containers(); - $this->call('cleanup:queue'); - $this->call('cleanup:stucked-resources'); - if (! isCloud()) { - try { - $localhost = $this->servers->where('id', 0)->first(); - $localhost->setupDynamicProxyConfiguration(); - } catch (\Throwable $e) { - echo "Could not setup dynamic configuration: {$e->getMessage()}\n"; - } - } - - $settings = InstanceSettings::get(); - if (! is_null(env('AUTOUPDATE', null))) { - if (env('AUTOUPDATE') == true) { - $settings->update(['is_auto_update_enabled' => true]); - } else { - $settings->update(['is_auto_update_enabled' => false]); - } - } - if (isCloud()) { - $response = Http::retry(3, 1000)->get(config('constants.services.official')); - if ($response->successful()) { - $services = $response->json(); - File::put(base_path('templates/service-templates.json'), json_encode($services)); - } - } - - return; - } - $this->cleanup_stucked_helper_containers(); - $this->call('cleanup:stucked-resources'); } private function update_traefik_labels() @@ -108,33 +104,28 @@ class Init extends Command private function cleanup_unnecessary_dynamic_proxy_configuration() { - if (isCloud()) { - foreach ($this->servers as $server) { - try { - if (! $server->isFunctional()) { - continue; - } - if ($server->id === 0) { - continue; - } - $file = $server->proxyPath().'/dynamic/coolify.yaml'; - - return instant_remote_process([ - "rm -f $file", - ], $server, false); - } catch (\Throwable $e) { - echo "Error in cleaning up unnecessary dynamic proxy configuration: {$e->getMessage()}\n"; + foreach ($this->servers as $server) { + try { + if (! $server->isFunctional()) { + continue; } + if ($server->id === 0) { + continue; + } + $file = $server->proxyPath().'/dynamic/coolify.yaml'; + return instant_remote_process([ + "rm -f $file", + ], $server, false); + } catch (\Throwable $e) { + echo "Error in cleaning up unnecessary dynamic proxy configuration: {$e->getMessage()}\n"; } + } } private function cleanup_unused_network_from_coolify_proxy() { - if (isCloud()) { - return; - } foreach ($this->servers as $server) { if (! $server->isFunctional()) { continue; @@ -175,43 +166,36 @@ class Init extends Command private function restore_coolify_db_backup() { - try { - $database = StandalonePostgresql::withTrashed()->find(0); - if ($database && $database->trashed()) { - echo "Restoring coolify db backup\n"; - $database->restore(); - $scheduledBackup = ScheduledDatabaseBackup::find(0); - if (! $scheduledBackup) { - ScheduledDatabaseBackup::create([ - 'id' => 0, - 'enabled' => true, - 'save_s3' => false, - 'frequency' => '0 0 * * *', - 'database_id' => $database->id, - 'database_type' => 'App\Models\StandalonePostgresql', - 'team_id' => 0, - ]); + if (version_compare('4.0.0-beta.179', config('version'), '<=')) { + try { + $database = StandalonePostgresql::withTrashed()->find(0); + if ($database && $database->trashed()) { + echo "Restoring coolify db backup\n"; + $database->restore(); + $scheduledBackup = ScheduledDatabaseBackup::find(0); + if (! $scheduledBackup) { + ScheduledDatabaseBackup::create([ + 'id' => 0, + 'enabled' => true, + 'save_s3' => false, + 'frequency' => '0 0 * * *', + 'database_id' => $database->id, + 'database_type' => 'App\Models\StandalonePostgresql', + 'team_id' => 0, + ]); + } } - } - } catch (\Throwable $e) { - echo "Error in restoring coolify db backup: {$e->getMessage()}\n"; - } - } - - private function cleanup_stucked_helper_containers() - { - foreach ($this->servers as $server) { - if ($server->isFunctional()) { - CleanupHelperContainersJob::dispatch($server); + } catch (\Throwable $e) { + echo "Error in restoring coolify db backup: {$e->getMessage()}\n"; } } } - private function alive() + private function send_alive_signal() { $id = config('app.id'); $version = config('version'); - $settings = InstanceSettings::get(); + $settings = instanceSettings(); $do_not_track = data_get($settings, 'do_not_track'); if ($do_not_track == true) { echo "Skipping alive as do_not_track is enabled\n"; @@ -225,23 +209,7 @@ class Init extends Command echo "Error in alive: {$e->getMessage()}\n"; } } - // private function cleanup_ssh() - // { - // TODO: it will cleanup id.root@host.docker.internal - // try { - // $files = Storage::allFiles('ssh/keys'); - // foreach ($files as $file) { - // Storage::delete($file); - // } - // $files = Storage::allFiles('ssh/mux'); - // foreach ($files as $file) { - // Storage::delete($file); - // } - // } catch (\Throwable $e) { - // echo "Error in cleaning ssh: {$e->getMessage()}\n"; - // } - // } private function cleanup_in_progress_application_deployments() { // Cleanup any failed deployments @@ -263,11 +231,13 @@ class Init extends Command private function replace_slash_in_environment_name() { - $environments = Environment::all(); - foreach ($environments as $environment) { - if (str_contains($environment->name, '/')) { - $environment->name = str_replace('/', '-', $environment->name); - $environment->save(); + if (version_compare('4.0.0-beta.298', config('version'), '<=')) { + $environments = Environment::all(); + foreach ($environments as $environment) { + if (str_contains($environment->name, '/')) { + $environment->name = str_replace('/', '-', $environment->name); + $environment->save(); + } } } } diff --git a/app/Console/Commands/ServicesGenerate.php b/app/Console/Commands/ServicesGenerate.php index de64afefa..9720e81ac 100644 --- a/app/Console/Commands/ServicesGenerate.php +++ b/app/Console/Commands/ServicesGenerate.php @@ -39,8 +39,8 @@ class ServicesGenerate extends Command $serviceTemplatesJson[$name] = $parsed; } } - $serviceTemplatesJson = json_encode($serviceTemplatesJson); - file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesJson); + $serviceTemplatesJson = json_encode($serviceTemplatesJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesJson.PHP_EOL); } private function process_file($file) @@ -78,7 +78,7 @@ class ServicesGenerate extends Command if ($logo->count() > 0) { $logo = str($logo[0])->after('# logo:')->trim()->value(); } else { - $logo = 'svgs/unknown.svg'; + $logo = 'svgs/coolify.png'; } $minversion = collect(preg_grep('/^# minversion:/', explode("\n", $content)))->values(); if ($minversion->count() > 0) { diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index b960a4a8b..a689b35b8 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -13,13 +13,13 @@ use App\Jobs\PullTemplatesFromCDN; use App\Jobs\ScheduledTaskJob; use App\Jobs\ServerCheckJob; use App\Jobs\UpdateCoolifyJob; -use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledTask; use App\Models\Server; use App\Models\Team; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; +use Illuminate\Support\Carbon; class Kernel extends ConsoleKernel { @@ -28,7 +28,7 @@ class Kernel extends ConsoleKernel protected function schedule(Schedule $schedule): void { $this->all_servers = Server::all(); - $settings = InstanceSettings::get(); + $settings = instanceSettings(); $schedule->job(new CleanupStaleMultiplexedConnections)->hourly(); @@ -43,6 +43,8 @@ class Kernel extends ConsoleKernel $schedule->command('uploads:clear')->everyTwoMinutes(); $schedule->command('telescope:prune')->daily(); + + $schedule->job(new PullHelperImageJob)->everyFiveMinutes()->onOneServer(); } else { // Instance Jobs $schedule->command('horizon:snapshot')->everyFiveMinutes(); @@ -64,7 +66,7 @@ class Kernel extends ConsoleKernel private function pull_images($schedule) { - $settings = InstanceSettings::get(); + $settings = instanceSettings(); $servers = $this->all_servers->where('settings.is_usable', true)->where('settings.is_reachable', true)->where('ip', '!=', '1.2.3.4'); foreach ($servers as $server) { if ($server->isSentinelEnabled()) { @@ -77,16 +79,16 @@ class Kernel extends ConsoleKernel } })->cron($settings->update_check_frequency)->timezone($settings->instance_timezone)->onOneServer(); } - $schedule->job(new PullHelperImageJob($server)) - ->cron($settings->update_check_frequency) - ->timezone($settings->instance_timezone) - ->onOneServer(); } + $schedule->job(new PullHelperImageJob) + ->cron($settings->update_check_frequency) + ->timezone($settings->instance_timezone) + ->onOneServer(); } private function schedule_updates($schedule) { - $settings = InstanceSettings::get(); + $settings = instanceSettings(); $updateCheckFrequency = $settings->update_check_frequency; $schedule->job(new CheckForUpdatesJob) @@ -113,7 +115,11 @@ class Kernel extends ConsoleKernel $servers = $this->all_servers->where('ip', '!=', '1.2.3.4'); } foreach ($servers as $server) { - $schedule->job(new ServerCheckJob($server))->everyMinute()->onOneServer(); + $last_sentinel_update = $server->sentinel_updated_at; + if (Carbon::parse($last_sentinel_update)->isBefore(now()->subMinutes(4))) { + $schedule->job(new ServerCheckJob($server))->everyMinute()->onOneServer(); + } + // $schedule->job(new ServerStorageCheckJob($server))->everyMinute()->onOneServer(); $serverTimezone = $server->settings->server_timezone; if ($server->settings->force_docker_cleanup) { $schedule->job(new DockerCleanupJob($server))->cron($server->settings->docker_cleanup_frequency)->timezone($serverTimezone)->onOneServer(); diff --git a/app/Enums/ContainerStatusTypes.php b/app/Enums/ContainerStatusTypes.php new file mode 100644 index 000000000..ffcb6d5b5 --- /dev/null +++ b/app/Enums/ContainerStatusTypes.php @@ -0,0 +1,14 @@ +user()->currentTeam()->id ?? null; + } + if (is_null($teamId)) { + throw new \Exception('Team id is null'); + } + $this->teamId = $teamId; + } + + public function broadcastOn(): array + { + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 6b69350fe..63fbfc862 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -65,7 +65,7 @@ class Handler extends ExceptionHandler if ($e instanceof RuntimeException) { return; } - $this->settings = \App\Models\InstanceSettings::get(); + $this->settings = instanceSettings(); if ($this->settings->do_not_track) { return; } diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php new file mode 100644 index 000000000..1a2146799 --- /dev/null +++ b/app/Helpers/SshMultiplexingHelper.php @@ -0,0 +1,186 @@ +private_key_id); + $sshKeyLocation = $privateKey->getKeyLocation(); + $muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid; + + return [ + 'sshKeyLocation' => $sshKeyLocation, + 'muxFilename' => $muxFilename, + ]; + } + + public static function ensureMultiplexedConnection(Server $server) + { + if (! self::isMultiplexingEnabled()) { + return; + } + + $sshConfig = self::serverSshConfiguration($server); + $muxSocket = $sshConfig['muxFilename']; + $sshKeyLocation = $sshConfig['sshKeyLocation']; + + self::validateSshKey($sshKeyLocation); + + $checkCommand = "ssh -O check -o ControlPath=$muxSocket "; + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + $checkCommand .= "{$server->user}@{$server->ip}"; + $process = Process::run($checkCommand); + + if ($process->exitCode() !== 0) { + self::establishNewMultiplexedConnection($server); + } + } + + public static function establishNewMultiplexedConnection(Server $server) + { + $sshConfig = self::serverSshConfiguration($server); + $sshKeyLocation = $sshConfig['sshKeyLocation']; + $muxSocket = $sshConfig['muxFilename']; + + $connectionTimeout = config('constants.ssh.connection_timeout'); + $serverInterval = config('constants.ssh.server_interval'); + $muxPersistTime = config('constants.ssh.mux_persist_time'); + + $establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + + $establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval); + $establishCommand .= "{$server->user}@{$server->ip}"; + + $establishProcess = Process::run($establishCommand); + + if ($establishProcess->exitCode() !== 0) { + throw new \RuntimeException('Failed to establish multiplexed connection: '.$establishProcess->errorOutput()); + } + } + + public static function removeMuxFile(Server $server) + { + $sshConfig = self::serverSshConfiguration($server); + $muxSocket = $sshConfig['muxFilename']; + + $closeCommand = "ssh -O exit -o ControlPath=$muxSocket "; + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + $closeCommand .= "{$server->user}@{$server->ip}"; + Process::run($closeCommand); + } + + public static function generateScpCommand(Server $server, string $source, string $dest) + { + $sshConfig = self::serverSshConfiguration($server); + $sshKeyLocation = $sshConfig['sshKeyLocation']; + $muxSocket = $sshConfig['muxFilename']; + + $timeout = config('constants.ssh.command_timeout'); + $muxPersistTime = config('constants.ssh.mux_persist_time'); + + $scp_command = "timeout $timeout scp "; + if ($server->isIpv6()) { + $scp_command .= '-6 '; + } + if (self::isMultiplexingEnabled()) { + $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + self::ensureMultiplexedConnection($server); + } + + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + + $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true); + $scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}"; + + return $scp_command; + } + + public static function generateSshCommand(Server $server, string $command) + { + if ($server->settings->force_disabled) { + throw new \RuntimeException('Server is disabled.'); + } + + $sshConfig = self::serverSshConfiguration($server); + $sshKeyLocation = $sshConfig['sshKeyLocation']; + $muxSocket = $sshConfig['muxFilename']; + + $timeout = config('constants.ssh.command_timeout'); + $muxPersistTime = config('constants.ssh.mux_persist_time'); + + $ssh_command = "timeout $timeout ssh "; + + if (self::isMultiplexingEnabled()) { + $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + self::ensureMultiplexedConnection($server); + } + + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' "; + } + + $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval')); + + $delimiter = Hash::make($command); + $delimiter = base64_encode($delimiter); + $command = str_replace($delimiter, '', $command); + + $ssh_command .= "{$server->user}@{$server->ip} 'bash -se' << \\$delimiter".PHP_EOL + .$command.PHP_EOL + .$delimiter; + + return $ssh_command; + } + + private static function isMultiplexingEnabled(): bool + { + return config('constants.ssh.mux_enabled') && ! config('coolify.is_windows_docker_desktop'); + } + + private static function validateSshKey(string $sshKeyLocation): void + { + $checkKeyCommand = "ls $sshKeyLocation 2>/dev/null"; + $keyCheckProcess = Process::run($checkKeyCommand); + + if ($keyCheckProcess->exitCode() !== 0) { + throw new \RuntimeException("SSH key file not accessible: $sshKeyLocation"); + } + } + + private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string + { + $options = "-i {$sshKeyLocation} " + .'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ' + .'-o PasswordAuthentication=no ' + ."-o ConnectTimeout=$connectionTimeout " + ."-o ServerAliveInterval=$serverInterval " + .'-o RequestTTY=no ' + .'-o LogLevel=ERROR '; + + // Bruh + if ($isScp) { + $options .= "-P {$server->port} "; + } else { + $options .= "-p {$server->port} "; + } + + return $options; + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 81b173011..2a1f846d3 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -132,6 +132,7 @@ class ApplicationsController extends Controller 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], 'is_static' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is static.'], + 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'], 'start_command' => ['type' => 'string', 'description' => 'The start command.'], @@ -177,6 +178,7 @@ class ApplicationsController extends Controller 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], 'docker_compose_domains' => ['type' => 'array', 'description' => 'The Docker Compose domains.'], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], + 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], ], )), ]), @@ -235,6 +237,7 @@ class ApplicationsController extends Controller 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], 'is_static' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is static.'], + 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'], 'start_command' => ['type' => 'string', 'description' => 'The start command.'], @@ -279,6 +282,7 @@ class ApplicationsController extends Controller 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], 'docker_compose_domains' => ['type' => 'array', 'description' => 'The Docker Compose domains.'], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], + 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], ], )), ]), @@ -337,6 +341,7 @@ class ApplicationsController extends Controller 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], 'is_static' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is static.'], + 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'], 'start_command' => ['type' => 'string', 'description' => 'The start command.'], @@ -381,6 +386,7 @@ class ApplicationsController extends Controller 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], 'docker_compose_domains' => ['type' => 'array', 'description' => 'The Docker Compose domains.'], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], + 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], ], )), ]), @@ -468,6 +474,7 @@ class ApplicationsController extends Controller 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'], 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], + 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], ], )), ]), @@ -552,6 +559,7 @@ class ApplicationsController extends Controller 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'], 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], + 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], ], )), ]), @@ -602,6 +610,7 @@ class ApplicationsController extends Controller 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], + 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], ], )), ]), @@ -627,7 +636,7 @@ class ApplicationsController extends Controller private function create_application(Request $request, $type) { - $allowedFields = ['project_uuid', 'environment_name', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths']; + $allowedFields = ['project_uuid', 'environment_name', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); @@ -665,6 +674,8 @@ class ApplicationsController extends Controller $fqdn = $request->domains; $instantDeploy = $request->instant_deploy; $githubAppUuid = $request->github_app_uuid; + $useBuildServer = $request->use_build_server; + $isStatic = $request->is_static; $project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first(); if (! $project) { @@ -693,8 +704,7 @@ class ApplicationsController extends Controller if ($request->build_pack === 'dockercompose') { $request->offsetSet('ports_exposes', '80'); } - $validator = customApiValidator($request->all(), [ - sharedDataApplications(), + $validationRules = [ 'git_repository' => 'string|required', 'git_branch' => 'string|required', 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], @@ -702,19 +712,21 @@ class ApplicationsController extends Controller 'docker_compose_location' => 'string', 'docker_compose_raw' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', - 'docker_compose_custom_start_command' => 'string|nullable', - 'docker_compose_custom_build_command' => 'string|nullable', - ]); + ]; + $validationRules = array_merge($validationRules, sharedDataApplications()); + $validator = customApiValidator($request->all(), $validationRules); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', 'errors' => $validator->errors(), ], 422); } + $return = $this->validateDataApplications($request, $server); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } + $application = new Application; removeUnnecessaryFieldsFromRequest($request); @@ -738,6 +750,14 @@ class ApplicationsController extends Controller $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; $application->save(); + if (isset($isStatic)) { + $application->settings->is_static = $isStatic; + $application->settings->save(); + } + if (isset($useBuildServer)) { + $application->settings->is_build_server_enabled = $useBuildServer; + $application->settings->save(); + } $application->refresh(); if (! $application->settings->is_container_label_readonly_enabled) { $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); @@ -771,8 +791,7 @@ class ApplicationsController extends Controller if ($request->build_pack === 'dockercompose') { $request->offsetSet('ports_exposes', '80'); } - $validator = customApiValidator($request->all(), [ - sharedDataApplications(), + $validationRules = [ 'git_repository' => 'string|required', 'git_branch' => 'string|required', 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], @@ -781,10 +800,10 @@ class ApplicationsController extends Controller 'watch_paths' => 'string|nullable', 'docker_compose_location' => 'string', 'docker_compose_raw' => 'string|nullable', - 'docker_compose_domains' => 'array|nullable', - 'docker_compose_custom_start_command' => 'string|nullable', - 'docker_compose_custom_build_command' => 'string|nullable', - ]); + ]; + $validationRules = array_merge($validationRules, sharedDataApplications()); + + $validator = customApiValidator($request->all(), $validationRules); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', @@ -833,6 +852,10 @@ class ApplicationsController extends Controller $application->environment_id = $environment->id; $application->source_type = $githubApp->getMorphClass(); $application->source_id = $githubApp->id; + if (isset($useBuildServer)) { + $application->settings->is_build_server_enabled = $useBuildServer; + $application->settings->save(); + } $application->save(); $application->refresh(); if (! $application->settings->is_container_label_readonly_enabled) { @@ -867,8 +890,8 @@ class ApplicationsController extends Controller if ($request->build_pack === 'dockercompose') { $request->offsetSet('ports_exposes', '80'); } - $validator = customApiValidator($request->all(), [ - sharedDataApplications(), + + $validationRules = [ 'git_repository' => 'string|required', 'git_branch' => 'string|required', 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], @@ -877,10 +900,10 @@ class ApplicationsController extends Controller 'watch_paths' => 'string|nullable', 'docker_compose_location' => 'string', 'docker_compose_raw' => 'string|nullable', - 'docker_compose_domains' => 'array|nullable', - 'docker_compose_custom_start_command' => 'string|nullable', - 'docker_compose_custom_build_command' => 'string|nullable', - ]); + ]; + + $validationRules = array_merge($validationRules, sharedDataApplications()); + $validator = customApiValidator($request->all(), $validationRules); if ($validator->fails()) { return response()->json([ @@ -925,6 +948,10 @@ class ApplicationsController extends Controller $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; + if (isset($useBuildServer)) { + $application->settings->is_build_server_enabled = $useBuildServer; + $application->settings->save(); + } $application->save(); $application->refresh(); if (! $application->settings->is_container_label_readonly_enabled) { @@ -956,10 +983,13 @@ class ApplicationsController extends Controller if (! $request->has('name')) { $request->offsetSet('name', 'dockerfile-'.new Cuid2); } - $validator = customApiValidator($request->all(), [ - sharedDataApplications(), + + $validationRules = [ 'dockerfile' => 'string|required', - ]); + ]; + $validationRules = array_merge($validationRules, sharedDataApplications()); + $validator = customApiValidator($request->all(), $validationRules); + if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', @@ -1004,6 +1034,10 @@ class ApplicationsController extends Controller $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; + if (isset($useBuildServer)) { + $application->settings->is_build_server_enabled = $useBuildServer; + $application->settings->save(); + } $application->git_repository = 'coollabsio/coolify'; $application->git_branch = 'main'; @@ -1034,12 +1068,14 @@ class ApplicationsController extends Controller if (! $request->has('name')) { $request->offsetSet('name', 'docker-image-'.new Cuid2); } - $validator = customApiValidator($request->all(), [ - sharedDataApplications(), + $validationRules = [ 'docker_registry_image_name' => 'string|required', 'docker_registry_image_tag' => 'string', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', - ]); + ]; + $validationRules = array_merge($validationRules, sharedDataApplications()); + $validator = customApiValidator($request->all(), $validationRules); + if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', @@ -1062,6 +1098,10 @@ class ApplicationsController extends Controller $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; + if (isset($useBuildServer)) { + $application->settings->is_build_server_enabled = $useBuildServer; + $application->settings->save(); + } $application->git_repository = 'coollabsio/coolify'; $application->git_branch = 'main'; @@ -1108,10 +1148,12 @@ class ApplicationsController extends Controller if (! $request->has('name')) { $request->offsetSet('name', 'service'.new Cuid2); } - $validator = customApiValidator($request->all(), [ - sharedDataApplications(), + $validationRules = [ 'docker_compose_raw' => 'string|required', - ]); + ]; + $validationRules = array_merge($validationRules, sharedDataApplications()); + $validator = customApiValidator($request->all(), $validationRules); + if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', @@ -1259,16 +1301,10 @@ class ApplicationsController extends Controller format: 'uuid', ) ), - new OA\Parameter( - name: 'cleanup', - in: 'query', - description: 'Delete configurations and volumes.', - required: false, - schema: new OA\Schema( - type: 'boolean', - default: true, - ) - ), + new OA\Parameter(name: 'delete_configurations', in: 'query', required: false, description: 'Delete configurations.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'delete_volumes', in: 'query', required: false, description: 'Delete volumes.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'delete_connected_networks', in: 'query', required: false, description: 'Delete connected networks.', schema: new OA\Schema(type: 'boolean', default: true)), ], responses: [ new OA\Response( @@ -1316,10 +1352,14 @@ class ApplicationsController extends Controller 'message' => 'Application not found', ], 404); } + DeleteResourceJob::dispatch( resource: $application, - deleteConfigurations: $cleanup, - deleteVolumes: $cleanup); + deleteConfigurations: $request->query->get('delete_configurations', true), + deleteVolumes: $request->query->get('delete_volumes', true), + dockerCleanup: $request->query->get('docker_cleanup', true), + deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) + ); return response()->json([ 'message' => 'Application deletion request queued.', @@ -1404,6 +1444,7 @@ class ApplicationsController extends Controller 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], 'docker_compose_domains' => ['type' => 'array', 'description' => 'The Docker Compose domains.'], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], + 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], ], )), ]), @@ -1460,10 +1501,9 @@ class ApplicationsController extends Controller ], 404); } $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy']; + $allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server']; - $validator = customApiValidator($request->all(), [ - sharedDataApplications(), + $validationRules = [ 'name' => 'string|max:255', 'description' => 'string|nullable', 'static_image' => 'string', @@ -1473,7 +1513,9 @@ class ApplicationsController extends Controller 'docker_compose_domains' => 'array|nullable', 'docker_compose_custom_start_command' => 'string|nullable', 'docker_compose_custom_build_command' => 'string|nullable', - ]); + ]; + $validationRules = array_merge($validationRules, sharedDataApplications()); + $validator = customApiValidator($request->all(), $validationRules); // Validate ports_exposes if ($request->has('ports_exposes')) { @@ -1538,6 +1580,13 @@ class ApplicationsController extends Controller } $instantDeploy = $request->instant_deploy; + $use_build_server = $request->use_build_server; + + if (isset($use_build_server)) { + $application->settings->is_build_server_enabled = $use_build_server; + $application->settings->save(); + } + removeUnnecessaryFieldsFromRequest($request); $data = $request->all(); @@ -2529,6 +2578,131 @@ class ApplicationsController extends Controller } + #[OA\Post( + summary: 'Execute Command', + description: "Execute a command on the application's current container.", + path: '/applications/{uuid}/execute', + operationId: 'execute-command-application', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + ], + requestBody: new OA\RequestBody( + required: true, + description: 'Command to execute.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'command' => ['type' => 'string', 'description' => 'Command to execute.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 200, + description: "Execute a command on the application's current container.", + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Command executed.'], + 'response' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function execute_command_by_uuid(Request $request) + { + // TODO: Need to review this from security perspective, to not allow arbitrary command execution + $allowedFields = ['command']; + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + $validator = customApiValidator($request->all(), [ + 'command' => 'string|required', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $container = getCurrentApplicationContainerStatus($application->destination->server, $application->id)->firstOrFail(); + $status = getContainerStatus($application->destination->server, $container['Names']); + + if ($status !== 'running') { + return response()->json([ + 'message' => 'Application is not running.', + ], 400); + } + + $commands = collect([ + executeInDocker($container['Names'], $request->command), + ]); + + $res = instant_remote_process(command: $commands, server: $application->destination->server); + + return response()->json([ + 'message' => 'Command executed.', + 'response' => $res, + ]); + } + private function validateDataApplications(Request $request, Server $server) { $teamId = getTeamIdFromToken(); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index a205704cc..65873f818 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1541,16 +1541,10 @@ class DatabasesController extends Controller format: 'uuid', ) ), - new OA\Parameter( - name: 'cleanup', - in: 'query', - description: 'Delete configurations and volumes.', - required: false, - schema: new OA\Schema( - type: 'boolean', - default: true, - ) - ), + new OA\Parameter(name: 'delete_configurations', in: 'query', required: false, description: 'Delete configurations.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'delete_volumes', in: 'query', required: false, description: 'Delete volumes.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'delete_connected_networks', in: 'query', required: false, description: 'Delete connected networks.', schema: new OA\Schema(type: 'boolean', default: true)), ], responses: [ new OA\Response( @@ -1595,10 +1589,14 @@ class DatabasesController extends Controller if (! $database) { return response()->json(['message' => 'Database not found.'], 404); } + DeleteResourceJob::dispatch( resource: $database, - deleteConfigurations: $cleanup, - deleteVolumes: $cleanup); + deleteConfigurations: $request->query->get('delete_configurations', true), + deleteVolumes: $request->query->get('delete_volumes', true), + dockerCleanup: $request->query->get('docker_cleanup', true), + deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) + ); return response()->json([ 'message' => 'Database deletion request queued.', diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index c085b88a5..2414b7a42 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -86,7 +86,7 @@ class OtherController extends Controller if ($teamId !== '0') { return response()->json(['message' => 'You are not allowed to enable the API.'], 403); } - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); $settings->update(['is_api_enabled' => true]); return response()->json(['message' => 'API enabled.'], 200); @@ -138,7 +138,7 @@ class OtherController extends Controller if ($teamId !== '0') { return response()->json(['message' => 'You are not allowed to disable the API.'], 403); } - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); $settings->update(['is_api_enabled' => false]); return response()->json(['message' => 'API disabled.'], 200); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 5f0d6bb12..540069f85 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Server\DeleteServer; use App\Actions\Server\ValidateServer; use App\Enums\ProxyStatus; use App\Enums\ProxyTypes; @@ -23,7 +24,7 @@ class ServersController extends Controller return serializeApiResponse($settings); } $settings = $settings->makeHidden([ - 'metrics_token', + 'sentinel_token', ]); return serializeApiResponse($settings); @@ -308,7 +309,7 @@ class ServersController extends Controller $projects = Project::where('team_id', $teamId)->get(); $domains = collect(); $applications = $projects->pluck('applications')->flatten(); - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); if ($applications->count() > 0) { foreach ($applications as $application) { $ip = $application->destination->server->ip; @@ -726,6 +727,7 @@ class ServersController extends Controller return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400); } $server->delete(); + DeleteServer::dispatch($server); return response()->json(['message' => 'Server deleted.']); } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 0a6154410..89418517b 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -432,6 +432,10 @@ class ServicesController extends Controller tags: ['Services'], parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'delete_configurations', in: 'query', required: false, description: 'Delete configurations.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'delete_volumes', in: 'query', required: false, description: 'Delete volumes.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'delete_connected_networks', in: 'query', required: false, description: 'Delete connected networks.', schema: new OA\Schema(type: 'boolean', default: true)), ], responses: [ new OA\Response( @@ -476,7 +480,14 @@ class ServicesController extends Controller if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } - DeleteResourceJob::dispatch($service); + + DeleteResourceJob::dispatch( + resource: $service, + deleteConfigurations: $request->query->get('delete_configurations', true), + deleteVolumes: $request->query->get('delete_volumes', true), + dockerCleanup: $request->query->get('docker_cleanup', true), + deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) + ); return response()->json([ 'message' => 'Service deletion request queued.', @@ -516,7 +527,8 @@ class ServicesController extends Controller items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable') ) ), - ]), + ] + ), new OA\Response( response: 401, ref: '#/components/responses/401', @@ -619,7 +631,8 @@ class ServicesController extends Controller ] ) ), - ]), + ] + ), new OA\Response( response: 401, ref: '#/components/responses/401', @@ -738,7 +751,8 @@ class ServicesController extends Controller ] ) ), - ]), + ] + ), new OA\Response( response: 401, ref: '#/components/responses/401', @@ -853,7 +867,8 @@ class ServicesController extends Controller ] ) ), - ]), + ] + ), new OA\Response( response: 401, ref: '#/components/responses/401', @@ -953,7 +968,8 @@ class ServicesController extends Controller ] ) ), - ]), + ] + ), new OA\Response( response: 401, ref: '#/components/responses/401', @@ -1025,9 +1041,11 @@ class ServicesController extends Controller type: 'object', properties: [ 'message' => ['type' => 'string', 'example' => 'Service starting request queued.'], - ]) + ] + ) ), - ]), + ] + ), new OA\Response( response: 401, ref: '#/components/responses/401', @@ -1101,9 +1119,11 @@ class ServicesController extends Controller type: 'object', properties: [ 'message' => ['type' => 'string', 'example' => 'Service stopping request queued.'], - ]) + ] + ) ), - ]), + ] + ), new OA\Response( response: 401, ref: '#/components/responses/401', @@ -1177,9 +1197,11 @@ class ServicesController extends Controller type: 'object', properties: [ 'message' => ['type' => 'string', 'example' => 'Service restaring request queued.'], - ]) + ] + ) ), - ]), + ] + ), new OA\Response( response: 401, ref: '#/components/responses/401', diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 9569e8cfa..630d01045 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers; -use App\Models\InstanceSettings; use App\Models\User; use Illuminate\Support\Facades\Auth; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -22,7 +21,7 @@ class OauthController extends Controller $oauthUser = get_socialite_provider($provider)->user(); $user = User::whereEmail($oauthUser->email)->first(); if (! $user) { - $settings = InstanceSettings::get(); + $settings = instanceSettings(); if (! $settings->is_registration_enabled) { abort(403, 'Registration is disabled'); } diff --git a/app/Http/Middleware/ApiAllowed.php b/app/Http/Middleware/ApiAllowed.php index 648720ba4..471e6d602 100644 --- a/app/Http/Middleware/ApiAllowed.php +++ b/app/Http/Middleware/ApiAllowed.php @@ -14,7 +14,7 @@ class ApiAllowed if (isCloud()) { return $next($request); } - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); if ($settings->is_api_enabled === false) { return response()->json(['success' => true, 'message' => 'API is disabled.'], 403); } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 718cea639..9ae383a9f 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -12,7 +12,6 @@ use App\Models\ApplicationPreview; use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GitlabApp; -use App\Models\InstanceSettings; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; @@ -27,6 +26,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Sleep; use Illuminate\Support\Str; use RuntimeException; @@ -210,7 +210,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } ray('New container name: ', $this->container_name)->green(); - savePrivateKeyToFs($this->server); $this->saved_outputs = collect(); // Set preview fqdn @@ -514,7 +513,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue 'hidden' => true, 'ignore_errors' => true, ], [ - "docker network connect {$networkId} coolify-proxy || true", + "docker network connect {$networkId} coolify-proxy >/dev/null 2>&1 || true", 'hidden' => true, 'ignore_errors' => true, ]); @@ -919,10 +918,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } if ($this->application->build_pack !== 'dockercompose' || $this->application->compose_parsing_version === '1' || $this->application->compose_parsing_version === '2') { if ($this->application->environment_variables_preview->where('key', 'COOLIFY_BRANCH')->isEmpty()) { - $envs->push("COOLIFY_BRANCH={$local_branch}"); + $envs->push("COOLIFY_BRANCH=\"{$local_branch}\""); } if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { - $envs->push("COOLIFY_CONTAINER_NAME={$this->container_name}"); + $envs->push("COOLIFY_CONTAINER_NAME=\"{$this->container_name}\""); } } @@ -962,7 +961,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) { - if ($this->application->compose_parsing_version === '3') { + if ((int) $this->application->compose_parsing_version >= 3) { $envs->push("COOLIFY_URL={$this->application->fqdn}"); } else { $envs->push("COOLIFY_FQDN={$this->application->fqdn}"); @@ -970,7 +969,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } if ($this->application->environment_variables->where('key', 'COOLIFY_URL')->isEmpty()) { $url = str($this->application->fqdn)->replace('http://', '')->replace('https://', ''); - if ($this->application->compose_parsing_version === '3') { + if ((int) $this->application->compose_parsing_version >= 3) { $envs->push("COOLIFY_FQDN={$url}"); } else { $envs->push("COOLIFY_URL={$url}"); @@ -978,10 +977,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } if ($this->application->build_pack !== 'dockercompose' || $this->application->compose_parsing_version === '1' || $this->application->compose_parsing_version === '2') { if ($this->application->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) { - $envs->push("COOLIFY_BRANCH={$local_branch}"); + $envs->push("COOLIFY_BRANCH=\"{$local_branch}\""); } if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { - $envs->push("COOLIFY_CONTAINER_NAME={$this->container_name}"); + $envs->push("COOLIFY_CONTAINER_NAME=\"{$this->container_name}\""); } } @@ -1334,7 +1333,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function prepare_builder_image() { - $settings = InstanceSettings::get(); + $settings = instanceSettings(); $helperImage = config('coolify.helper_image'); $helperImage = "{$helperImage}:{$settings->helper_version}"; // Get user home directory @@ -1456,10 +1455,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), ], [ - executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$local_branch}"), + executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$local_branch}"), 'hidden' => true, 'save' => 'git_commit_sha', - ], + ] ); } else { $this->execute_remote_command( @@ -2049,6 +2048,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), 'hidden' => true, ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], [ executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), 'hidden' => true, @@ -2068,6 +2071,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), 'hidden' => true, ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], [ executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), 'hidden' => true, @@ -2110,6 +2117,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), 'hidden' => true, ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], [ executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), 'hidden' => true, @@ -2129,6 +2140,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), 'hidden' => true, ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], [ executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), 'hidden' => true, @@ -2157,6 +2172,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), 'hidden' => true, ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], [ executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), 'hidden' => true, @@ -2176,6 +2195,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), 'hidden' => true, ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], [ executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), 'hidden' => true, @@ -2187,20 +2210,40 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->application_deployment_queue->addLogEntry('Building docker image completed.'); } - /** - * @param int $timeout in seconds - */ - private function graceful_shutdown_container(string $containerName, int $timeout = 30) + private function graceful_shutdown_container(string $containerName, int $timeout = 300) { try { - $this->execute_remote_command( - ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], - ["docker rm $containerName", 'hidden' => true, 'ignore_errors' => true] - ); + $process = Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + + $startTime = time(); + while ($process->running()) { + if (time() - $startTime >= $timeout) { + $this->execute_remote_command( + ["docker kill $containerName", 'hidden' => true, 'ignore_errors' => true] + ); + break; + } + usleep(100000); + } + + $isRunning = $this->execute_remote_command( + ["docker inspect -f '{{.State.Running}}' $containerName", 'hidden' => true, 'ignore_errors' => true] + ) === 'true'; + + if ($isRunning) { + $this->execute_remote_command( + ["docker kill $containerName", 'hidden' => true, 'ignore_errors' => true] + ); + } } catch (\Exception $error) { - // report error if needed + $this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr'); } + $this->remove_container($containerName); + } + + private function remove_container(string $containerName) + { $this->execute_remote_command( ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ); diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php index ddc264839..f2348118a 100644 --- a/app/Jobs/CheckForUpdatesJob.php +++ b/app/Jobs/CheckForUpdatesJob.php @@ -2,15 +2,14 @@ namespace App\Jobs; -use App\Models\InstanceSettings; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Http; class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue { @@ -22,7 +21,7 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue if (isDev() || isCloud()) { return; } - $settings = InstanceSettings::get(); + $settings = instanceSettings(); $response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json'); if ($response->successful()) { $versions = $response->json(); diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index 7b064a464..b8ca8b7ed 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -21,11 +21,10 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S { try { ray('Cleaning up helper containers on '.$this->server->name); - $containers = instant_remote_process(['docker container ps --filter "ancestor=ghcr.io/coollabsio/coolify-helper:next" --filter "ancestor=ghcr.io/coollabsio/coolify-helper:latest" --format \'{{json .}}\''], $this->server, false); - $containers = format_docker_command_output_to_json($containers); - if ($containers->count() > 0) { - foreach ($containers as $container) { - $containerId = data_get($container, 'ID'); + $containers = instant_remote_process(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("ghcr.io/coollabsio/coolify-helper")))\''], $this->server, false); + $containerIds = collect(json_decode($containers))->pluck('ID'); + if ($containerIds->count() > 0) { + foreach ($containerIds as $containerId) { ray('Removing container '.$containerId); instant_remote_process(['docker container rm -f '.$containerId], $this->server, false); } diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php index bcca77c18..6d49bee4b 100644 --- a/app/Jobs/CleanupStaleMultiplexedConnections.php +++ b/app/Jobs/CleanupStaleMultiplexedConnections.php @@ -3,12 +3,14 @@ namespace App\Jobs; use App\Models\Server; +use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Storage; class CleanupStaleMultiplexedConnections implements ShouldQueue { @@ -16,22 +18,65 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue public function handle() { - Server::chunk(100, function ($servers) { - foreach ($servers as $server) { - $this->cleanupStaleConnection($server); - } - }); + $this->cleanupStaleConnections(); + $this->cleanupNonExistentServerConnections(); } - private function cleanupStaleConnection(Server $server) + private function cleanupStaleConnections() { - $muxSocket = "/tmp/mux_{$server->id}"; - $checkCommand = "ssh -O check -o ControlPath=$muxSocket {$server->user}@{$server->ip} 2>/dev/null"; - $checkProcess = Process::run($checkCommand); + $muxFiles = Storage::disk('ssh-mux')->files(); - if ($checkProcess->exitCode() !== 0) { - $closeCommand = "ssh -O exit -o ControlPath=$muxSocket {$server->user}@{$server->ip} 2>/dev/null"; - Process::run($closeCommand); + foreach ($muxFiles as $muxFile) { + $serverUuid = $this->extractServerUuidFromMuxFile($muxFile); + $server = Server::where('uuid', $serverUuid)->first(); + + if (! $server) { + $this->removeMultiplexFile($muxFile); + + continue; + } + + $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; + $checkCommand = "ssh -O check -o ControlPath={$muxSocket} {$server->user}@{$server->ip} 2>/dev/null"; + $checkProcess = Process::run($checkCommand); + + if ($checkProcess->exitCode() !== 0) { + $this->removeMultiplexFile($muxFile); + } else { + $muxContent = Storage::disk('ssh-mux')->get($muxFile); + $establishedAt = Carbon::parse(substr($muxContent, 37)); + $expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time')); + + if (Carbon::now()->isAfter($expirationTime)) { + $this->removeMultiplexFile($muxFile); + } + } } } + + private function cleanupNonExistentServerConnections() + { + $muxFiles = Storage::disk('ssh-mux')->files(); + $existingServerUuids = Server::pluck('uuid')->toArray(); + + foreach ($muxFiles as $muxFile) { + $serverUuid = $this->extractServerUuidFromMuxFile($muxFile); + if (! in_array($serverUuid, $existingServerUuids)) { + $this->removeMultiplexFile($muxFile); + } + } + } + + private function extractServerUuidFromMuxFile($muxFile) + { + return substr($muxFile, 4); + } + + private function removeMultiplexFile($muxFile) + { + $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; + $closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null"; + Process::run($closeCommand); + Storage::disk('ssh-mux')->delete($muxFile); + } } diff --git a/app/Jobs/ContainerStatusJob.php b/app/Jobs/ContainerStatusJob.php index e919855d5..22ae06ebd 100644 --- a/app/Jobs/ContainerStatusJob.php +++ b/app/Jobs/ContainerStatusJob.php @@ -9,7 +9,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; class ContainerStatusJob implements ShouldBeEncrypted, ShouldQueue @@ -25,16 +24,6 @@ class ContainerStatusJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public Server $server) {} - public function middleware(): array - { - return [(new WithoutOverlapping($this->server->uuid))]; - } - - public function uniqueId(): int - { - return $this->server->uuid; - } - public function handle() { GetContainersStatus::run($this->server); diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index e6fa05b55..769739d5e 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -2,7 +2,6 @@ namespace App\Jobs; -use App\Actions\Database\StopDatabase; use App\Events\BackupCreated; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; @@ -22,10 +21,8 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Str; -use Visus\Cuid2\Cuid2; class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue { @@ -64,42 +61,32 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public function __construct($backup) { $this->backup = $backup; - $this->team = Team::find($backup->team_id); - if (is_null($this->team)) { - return; - } - if (data_get($this->backup, 'database_type') === 'App\Models\ServiceDatabase') { - $this->database = data_get($this->backup, 'database'); - $this->server = $this->database->service->server; - $this->s3 = $this->backup->s3; - } else { - $this->database = data_get($this->backup, 'database'); - $this->server = $this->database->destination->server; - $this->s3 = $this->backup->s3; - } - } - - public function middleware(): array - { - return [new WithoutOverlapping($this->backup->id)]; - } - - public function uniqueId(): int - { - return $this->backup->id; } public function handle(): void { try { - // Check if team is exists - if (is_null($this->team)) { - $this->backup->update(['status' => 'failed']); - StopDatabase::run($this->database); - $this->database->delete(); + $this->team = Team::find($this->backup->team_id); + if (! $this->team) { + $this->backup->delete(); return; } + if (data_get($this->backup, 'database_type') === 'App\Models\ServiceDatabase') { + $this->database = data_get($this->backup, 'database'); + $this->server = $this->database->service->server; + $this->s3 = $this->backup->s3; + } else { + $this->database = data_get($this->backup, 'database'); + $this->server = $this->database->destination->server; + $this->s3 = $this->backup->s3; + } + if (is_null($this->server)) { + throw new \Exception('Server not found?!'); + } + if (is_null($this->database)) { + throw new \Exception('Database not found?!'); + } BackupCreated::dispatch($this->team->id); @@ -249,7 +236,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } $this->backup_dir = backup_dir().'/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name; - if ($this->database->name === 'coolify-db') { $databasesToBackup = ['coolify']; $this->directory_name = $this->container_name = 'coolify-db'; @@ -262,6 +248,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue try { if (str($databaseType)->contains('postgres')) { $this->backup_file = "/pg-dump-$database-".Carbon::now()->timestamp.'.dmp'; + if ($this->backup->dump_all) { + $this->backup_file = '/pg-dump-all-'.Carbon::now()->timestamp.'.gz'; + } $this->backup_location = $this->backup_dir.$this->backup_file; $this->backup_log = ScheduledDatabaseBackupExecution::create([ 'database_name' => $database, @@ -290,6 +279,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_standalone_mongodb($database); } elseif (str($databaseType)->contains('mysql')) { $this->backup_file = "/mysql-dump-$database-".Carbon::now()->timestamp.'.dmp'; + if ($this->backup->dump_all) { + $this->backup_file = '/mysql-dump-all-'.Carbon::now()->timestamp.'.gz'; + } $this->backup_location = $this->backup_dir.$this->backup_file; $this->backup_log = ScheduledDatabaseBackupExecution::create([ 'database_name' => $database, @@ -299,6 +291,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_standalone_mysql($database); } elseif (str($databaseType)->contains('mariadb')) { $this->backup_file = "/mariadb-dump-$database-".Carbon::now()->timestamp.'.dmp'; + if ($this->backup->dump_all) { + $this->backup_file = '/mariadb-dump-all-'.Carbon::now()->timestamp.'.gz'; + } $this->backup_location = $this->backup_dir.$this->backup_file; $this->backup_log = ScheduledDatabaseBackupExecution::create([ 'database_name' => $database, @@ -337,7 +332,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue send_internal_notification('DatabaseBackupJob failed with: '.$e->getMessage()); throw $e; } finally { - BackupCreated::dispatch($this->team->id); + if ($this->team) { + BackupCreated::dispatch($this->team->id); + } } } @@ -396,7 +393,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue if ($this->postgres_password) { $backupCommand .= " -e PGPASSWORD=$this->postgres_password"; } - $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $database > $this->backup_location"; + if ($this->backup->dump_all) { + $backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location"; + } else { + $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $database > $this->backup_location"; + } $commands[] = $backupCommand; ray($commands); @@ -417,8 +418,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue { try { $commands[] = 'mkdir -p '.$this->backup_dir; - $commands[] = "docker exec $this->container_name mysqldump -u root -p{$this->database->mysql_root_password} $database > $this->backup_location"; - ray($commands); + if ($this->backup->dump_all) { + $commands[] = "docker exec $this->container_name mysqldump -u root -p{$this->database->mysql_root_password} --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location"; + } else { + $commands[] = "docker exec $this->container_name mysqldump -u root -p{$this->database->mysql_root_password} $database > $this->backup_location"; + } $this->backup_output = instant_remote_process($commands, $this->server); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { @@ -436,7 +440,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue { try { $commands[] = 'mkdir -p '.$this->backup_dir; - $commands[] = "docker exec $this->container_name mariadb-dump -u root -p{$this->database->mariadb_root_password} $database > $this->backup_location"; + if ($this->backup->dump_all) { + $commands[] = "docker exec $this->container_name mariadb-dump -u root -p{$this->database->mariadb_root_password} --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location"; + } else { + $commands[] = "docker exec $this->container_name mariadb-dump -u root -p{$this->database->mariadb_root_password} $database > $this->backup_location"; + } ray($commands); $this->backup_output = instant_remote_process($commands, $this->server); $this->backup_output = trim($this->backup_output); @@ -481,7 +489,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue private function upload_to_s3(): void { try { - ray($this->backup_location); if (is_null($this->s3)) { return; } @@ -491,20 +498,81 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $bucket = $this->s3->bucket; $endpoint = $this->s3->endpoint; $this->s3->testConnection(shouldSave: true); - $configName = new Cuid2; + if (data_get($this->backup, 'database_type') === 'App\Models\ServiceDatabase') { + $network = $this->database->service->destination->network; + } else { + $network = $this->database->destination->network; + } - $s3_copy_dir = str($this->backup_location)->replace(backup_dir(), '/var/www/html/storage/app/backups/'); - $commands[] = "docker exec coolify bash -c 'mc config host add {$configName} {$endpoint} $key $secret'"; - $commands[] = "docker exec coolify bash -c 'mc cp $s3_copy_dir {$configName}/{$bucket}{$this->backup_dir}/'"; + $this->ensureHelperImageAvailable(); + + $fullImageName = $this->getFullImageName(); + + if (isDev()) { + if ($this->database->name === 'coolify-db') { + $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file; + $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + } else { + $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file; + $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + } + } else { + $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; + } + if ($this->s3->isHetzner()) { + $endpointWithoutBucket = 'https://'.str($endpoint)->after('https://')->after('.')->value(); + $commands[] = "docker exec backup-of-{$this->backup->uuid} mc alias set --path=off --api=S3v4 temporary {$endpointWithoutBucket} $key $secret"; + } else { + $commands[] = "docker exec backup-of-{$this->backup->uuid} mc config host add temporary {$endpoint} $key $secret"; + } + $commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; instant_remote_process($commands, $this->server); + $this->add_to_backup_output('Uploaded to S3.'); } catch (\Throwable $e) { $this->add_to_backup_output($e->getMessage()); throw $e; } finally { - $removeConfigCommands[] = "docker exec coolify bash -c 'mc config remove {$configName}'"; - $removeConfigCommands[] = "docker exec coolify bash -c 'mc alias rm {$configName}'"; - instant_remote_process($removeConfigCommands, $this->server, false); + $command = "docker rm -f backup-of-{$this->backup->uuid}"; + instant_remote_process([$command], $this->server); } } + + private function ensureHelperImageAvailable(): void + { + $fullImageName = $this->getFullImageName(); + + $imageExists = $this->checkImageExists($fullImageName); + + if (! $imageExists) { + $this->pullHelperImage($fullImageName); + } + } + + private function checkImageExists(string $fullImageName): bool + { + $result = instant_remote_process(["docker image inspect {$fullImageName} >/dev/null 2>&1 && echo 'exists' || echo 'not exists'"], $this->server, false); + + return trim($result) === 'exists'; + } + + private function pullHelperImage(string $fullImageName): void + { + try { + instant_remote_process(["docker pull {$fullImageName}"], $this->server); + } catch (\Exception $e) { + $errorMessage = 'Failed to pull helper image: '.$e->getMessage(); + $this->add_to_backup_output($errorMessage); + throw new \RuntimeException($errorMessage); + } + } + + private function getFullImageName(): string + { + $settings = instanceSettings(); + $helperImage = config('coolify.helper_image'); + $latestVersion = $settings->helper_version; + + return "{$helperImage}:{$latestVersion}"; + } } diff --git a/app/Jobs/DatabaseBackupStatusJob.php b/app/Jobs/DatabaseBackupStatusJob.php deleted file mode 100644 index d3b0e99cf..000000000 --- a/app/Jobs/DatabaseBackupStatusJob.php +++ /dev/null @@ -1,62 +0,0 @@ -scheduledDatabaseBackups()->get(); - // if ($scheduled_backups->isEmpty()) { - // continue; - // } - // foreach ($scheduled_backups as $scheduled_backup) { - // $last_days_backups = $scheduled_backup->get_last_days_backup_status(); - // if ($last_days_backups->isEmpty()) { - // continue; - // } - // $failed = $last_days_backups->where('status', 'failed'); - // } - // } - - // $scheduled_backups = ScheduledDatabaseBackup::all(); - // $databases = collect(); - // $teams = collect(); - // foreach ($scheduled_backups as $scheduled_backup) { - // $last_days_backups = $scheduled_backup->get_last_days_backup_status(); - // if ($last_days_backups->isEmpty()) { - // continue; - // } - // $failed = $last_days_backups->where('status', 'failed'); - // $database = $scheduled_backup->database; - // $team = $database->team(); - // $teams->put($team->id, $team); - // $databases->put("{$team->id}:{$database->name}", [ - // 'failed_count' => $failed->count(), - // ]); - // } - // foreach ($databases as $name => $database) { - // [$team_id, $name] = explode(':', $name); - // $team = $teams->get($team_id); - // $team?->notify(new DailyBackup($databases)); - // } - } -} diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index dbf44dd5d..2442d5b06 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Actions\Application\StopApplication; use App\Actions\Database\StopDatabase; +use App\Actions\Server\CleanupDocker; use App\Actions\Service\DeleteService; use App\Actions\Service\StopService; use App\Models\Application; @@ -30,8 +31,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue public function __construct( public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource, - public bool $deleteConfigurations = false, - public bool $deleteVolumes = false) {} + public bool $deleteConfigurations = true, + public bool $deleteVolumes = true, + public bool $dockerCleanup = true, + public bool $deleteConnectedNetworks = true + ) {} public function handle() { @@ -51,11 +55,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue case 'standalone-dragonfly': case 'standalone-clickhouse': $persistentStorages = $this->resource?->persistentStorages()?->get(); - StopDatabase::run($this->resource); + StopDatabase::run($this->resource, true); break; case 'service': - StopService::run($this->resource); - DeleteService::run($this->resource); + StopService::run($this->resource, true); + DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks); break; } @@ -65,12 +69,31 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue if ($this->deleteConfigurations) { $this->resource?->delete_configurations(); } + + $isDatabase = $this->resource instanceof StandalonePostgresql + || $this->resource instanceof StandaloneRedis + || $this->resource instanceof StandaloneMongodb + || $this->resource instanceof StandaloneMysql + || $this->resource instanceof StandaloneMariadb + || $this->resource instanceof StandaloneKeydb + || $this->resource instanceof StandaloneDragonfly + || $this->resource instanceof StandaloneClickhouse; + $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server'); + if (($this->dockerCleanup || $isDatabase) && $server) { + CleanupDocker::dispatch($server, true); + } + + if ($this->deleteConnectedNetworks && ! $isDatabase) { + $this->resource?->delete_connected_networks($this->resource->uuid); + } } catch (\Throwable $e) { - ray($e->getMessage()); send_internal_notification('ContainerStoppingJob failed with: '.$e->getMessage()); throw $e; } finally { $this->resource->forceDelete(); + if ($this->dockerCleanup) { + CleanupDocker::dispatch($server, true); + } Artisan::queue('cleanup:stucked-resources'); } } diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index f95cd2920..900bae99c 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -10,7 +10,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; @@ -24,17 +23,7 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue public ?string $usageBefore = null; - public function __construct(public Server $server) {} - - public function middleware(): array - { - return [new WithoutOverlapping($this->server->id)]; - } - - public function uniqueId(): int - { - return $this->server->id; - } + public function __construct(public Server $server, public bool $manualCleanup = false) {} public function handle(): void { @@ -42,8 +31,9 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue if (! $this->server->isFunctional()) { return; } - if ($this->server->settings->force_docker_cleanup) { - Log::info('DockerCleanupJob force cleanup on '.$this->server->name); + + if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) { + Log::info('DockerCleanupJob '.($this->manualCleanup ? 'manual' : 'force').' cleanup on '.$this->server->name); CleanupDocker::run(server: $this->server); return; diff --git a/app/Jobs/GithubAppPermissionJob.php b/app/Jobs/GithubAppPermissionJob.php index 3188d35d6..9c0a2b55b 100644 --- a/app/Jobs/GithubAppPermissionJob.php +++ b/app/Jobs/GithubAppPermissionJob.php @@ -8,7 +8,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; @@ -25,16 +24,6 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public GithubApp $github_app) {} - public function middleware(): array - { - return [(new WithoutOverlapping($this->github_app->uuid))]; - } - - public function uniqueId(): int - { - return $this->github_app->uuid; - } - public function handle() { try { diff --git a/app/Jobs/PullHelperImageJob.php b/app/Jobs/PullHelperImageJob.php index 420119069..4b208fc31 100644 --- a/app/Jobs/PullHelperImageJob.php +++ b/app/Jobs/PullHelperImageJob.php @@ -2,14 +2,12 @@ namespace App\Jobs; -use App\Models\InstanceSettings; use App\Models\Server; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; @@ -19,17 +17,7 @@ class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue public $timeout = 1000; - public function middleware(): array - { - return [(new WithoutOverlapping($this->server->uuid))]; - } - - public function uniqueId(): string - { - return $this->server->uuid; - } - - public function __construct(public Server $server) {} + public function __construct() {} public function handle(): void { @@ -37,13 +25,13 @@ class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue $response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json'); if ($response->successful()) { $versions = $response->json(); - $settings = InstanceSettings::get(); + $settings = instanceSettings(); $latest_version = data_get($versions, 'coolify.helper.version'); $current_version = $settings->helper_version; if (version_compare($latest_version, $current_version, '>')) { // New version available - $helperImage = config('coolify.helper_image'); - instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server); + // $helperImage = config('coolify.helper_image'); + // instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server); $settings->update(['helper_version' => $latest_version]); } } diff --git a/app/Jobs/PullSentinelImageJob.php b/app/Jobs/PullSentinelImageJob.php index f8c769382..32f84e6d5 100644 --- a/app/Jobs/PullSentinelImageJob.php +++ b/app/Jobs/PullSentinelImageJob.php @@ -9,7 +9,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; class PullSentinelImageJob implements ShouldBeEncrypted, ShouldQueue @@ -18,16 +17,6 @@ class PullSentinelImageJob implements ShouldBeEncrypted, ShouldQueue public $timeout = 1000; - public function middleware(): array - { - return [(new WithoutOverlapping($this->server->uuid))]; - } - - public function uniqueId(): string - { - return $this->server->uuid; - } - public function __construct(public Server $server) {} public function handle(): void diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php new file mode 100644 index 000000000..cdc3788e5 --- /dev/null +++ b/app/Jobs/PushServerUpdateJob.php @@ -0,0 +1,407 @@ +containers = collect(); + $this->foundApplicationIds = collect(); + $this->foundDatabaseUuids = collect(); + $this->foundServiceApplicationIds = collect(); + $this->foundApplicationPreviewsIds = collect(); + $this->foundServiceDatabaseIds = collect(); + $this->allApplicationIds = collect(); + $this->allDatabaseUuids = collect(); + $this->allTcpProxyUuids = collect(); + $this->allServiceApplicationIds = collect(); + $this->allServiceDatabaseIds = collect(); + } + + public function handle() + { + try { + if (! $this->data) { + throw new \Exception('No data provided'); + } + $data = collect($this->data); + + $this->serverStatus(); + + $this->server->sentinelHeartbeat(); + + $this->containers = collect(data_get($data, 'containers')); + if ($this->containers->isEmpty()) { + return; + } + $this->applications = $this->server->applications(); + $this->databases = $this->server->databases(); + $this->previews = $this->server->previews(); + $this->services = $this->server->services()->get(); + $this->allApplicationIds = $this->applications->filter(function ($application) { + return $application->additional_servers->count() === 0; + })->pluck('id'); + $this->allApplicationsWithAdditionalServers = $this->applications->filter(function ($application) { + return $application->additional_servers->count() > 0; + }); + $this->allApplicationPreviewsIds = $this->previews->pluck('id'); + $this->allDatabaseUuids = $this->databases->pluck('uuid'); + $this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid'); + $this->services->each(function ($service) { + $service->applications()->pluck('id')->each(function ($applicationId) { + $this->allServiceApplicationIds->push($applicationId); + }); + $service->databases()->pluck('id')->each(function ($databaseId) { + $this->allServiceDatabaseIds->push($databaseId); + }); + }); + + ray('allServiceApplicationIds', ['allServiceApplicationIds' => $this->allServiceApplicationIds]); + + foreach ($this->containers as $container) { + $containerStatus = data_get($container, 'state', 'exited'); + $containerHealth = data_get($container, 'health_status', 'unhealthy'); + $containerStatus = "$containerStatus ($containerHealth)"; + $labels = collect(data_get($container, 'labels')); + $coolify_managed = $labels->has('coolify.managed'); + if ($coolify_managed) { + $name = data_get($container, 'name'); + if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) { + $this->foundLogDrainContainer = true; + } + if ($labels->has('coolify.applicationId')) { + $applicationId = $labels->get('coolify.applicationId'); + $pullRequestId = data_get($labels, 'coolify.pullRequestId', '0'); + try { + if ($pullRequestId === '0') { + if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) { + $this->foundApplicationIds->push($applicationId); + } + $this->updateApplicationStatus($applicationId, $containerStatus); + } else { + if ($this->allApplicationPreviewsIds->contains($applicationId) && $this->isRunning($containerStatus)) { + $this->foundApplicationPreviewsIds->push($applicationId); + } + $this->updateApplicationPreviewStatus($applicationId, $containerStatus); + } + } catch (\Exception $e) { + ray()->error($e); + } + } elseif ($labels->has('coolify.serviceId')) { + $serviceId = $labels->get('coolify.serviceId'); + $subType = $labels->get('coolify.service.subType'); + $subId = $labels->get('coolify.service.subId'); + if ($subType === 'application' && $this->isRunning($containerStatus)) { + $this->foundServiceApplicationIds->push($subId); + $this->updateServiceSubStatus($serviceId, $subType, $subId, $containerStatus); + } elseif ($subType === 'database' && $this->isRunning($containerStatus)) { + $this->foundServiceDatabaseIds->push($subId); + $this->updateServiceSubStatus($serviceId, $subType, $subId, $containerStatus); + } + + } else { + $uuid = $labels->get('com.docker.compose.service'); + $type = $labels->get('coolify.type'); + if ($name === 'coolify-proxy' && $this->isRunning($containerStatus)) { + $this->foundProxy = true; + } elseif ($type === 'service' && $this->isRunning($containerStatus)) { + ray("Service: $uuid, $containerStatus"); + } else { + if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) { + $this->foundDatabaseUuids->push($uuid); + if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) { + $this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true); + } else { + $this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false); + } + } + } + } + } + } + + $this->updateProxyStatus(); + + $this->updateNotFoundApplicationStatus(); + $this->updateNotFoundApplicationPreviewStatus(); + $this->updateNotFoundDatabaseStatus(); + $this->updateNotFoundServiceStatus(); + + $this->updateAdditionalServersStatus(); + + $this->checkLogDrainContainer(); + + } catch (\Exception $e) { + throw $e; + } + + } + + private function serverStatus() + { + if ($this->server->isFunctional() === false) { + throw new \Exception('Server is not ready.'); + } + if ($this->server->status() === false) { + throw new \Exception('Server is not reachable.'); + } + } + + private function updateApplicationStatus(string $applicationId, string $containerStatus) + { + $application = $this->applications->where('id', $applicationId)->first(); + if (! $application) { + return; + } + $application->status = $containerStatus; + $application->save(); + ray('Application updated', ['application_id' => $applicationId, 'status' => $containerStatus]); + } + + private function updateApplicationPreviewStatus(string $applicationId, string $containerStatus) + { + $application = $this->previews->where('id', $applicationId)->first(); + if (! $application) { + return; + } + $application->status = $containerStatus; + $application->save(); + ray('Application preview updated', ['application_id' => $applicationId, 'status' => $containerStatus]); + } + + private function updateNotFoundApplicationStatus() + { + $notFoundApplicationIds = $this->allApplicationIds->diff($this->foundApplicationIds); + if ($notFoundApplicationIds->isNotEmpty()) { + ray('Not found application ids', ['application_ids' => $notFoundApplicationIds]); + $notFoundApplicationIds->each(function ($applicationId) { + ray('Updating application status', ['application_id' => $applicationId, 'status' => 'exited']); + $application = Application::find($applicationId); + if ($application) { + $application->status = 'exited'; + $application->save(); + ray('Application status updated', ['application_id' => $applicationId, 'status' => 'exited']); + } + }); + } + } + + private function updateNotFoundApplicationPreviewStatus() + { + $notFoundApplicationPreviewsIds = $this->allApplicationPreviewsIds->diff($this->foundApplicationPreviewsIds); + if ($notFoundApplicationPreviewsIds->isNotEmpty()) { + ray('Not found application previews ids', ['application_previews_ids' => $notFoundApplicationPreviewsIds]); + $notFoundApplicationPreviewsIds->each(function ($applicationPreviewId) { + ray('Updating application preview status', ['application_preview_id' => $applicationPreviewId, 'status' => 'exited']); + $applicationPreview = ApplicationPreview::find($applicationPreviewId); + if ($applicationPreview) { + $applicationPreview->status = 'exited'; + $applicationPreview->save(); + ray('Application preview status updated', ['application_preview_id' => $applicationPreviewId, 'status' => 'exited']); + } + }); + } + } + + private function updateProxyStatus() + { + // If proxy is not found, start it + if ($this->server->isProxyShouldRun()) { + if ($this->foundProxy === false) { + try { + if (CheckProxy::run($this->server)) { + StartProxy::run($this->server, false); + } + } catch (\Throwable $e) { + } + } else { + $connectProxyToDockerNetworks = connectProxyToNetworks($this->server); + instant_remote_process($connectProxyToDockerNetworks, $this->server, false); + } + } + + } + + private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false) + { + $database = $this->databases->where('uuid', $databaseUuid)->first(); + if (! $database) { + return; + } + $database->status = $containerStatus; + $database->save(); + ray('Database status updated', ['database_uuid' => $databaseUuid, 'status' => $containerStatus]); + if ($this->isRunning($containerStatus) && $tcpProxy) { + $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { + return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running'; + })->first(); + if (! $tcpProxyContainerFound) { + ray('Starting TCP proxy for database', ['database_uuid' => $databaseUuid]); + StartDatabaseProxy::dispatch($database); + } else { + ray('TCP proxy for database found in containers', ['database_uuid' => $databaseUuid]); + } + } + } + + private function updateNotFoundDatabaseStatus() + { + $notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids); + if ($notFoundDatabaseUuids->isNotEmpty()) { + ray('Not found database uuids', ['database_uuids' => $notFoundDatabaseUuids]); + $notFoundDatabaseUuids->each(function ($databaseUuid) { + ray('Updating database status', ['database_uuid' => $databaseUuid, 'status' => 'exited']); + $database = $this->databases->where('uuid', $databaseUuid)->first(); + if ($database) { + $database->status = 'exited'; + $database->save(); + ray('Database status updated', ['database_uuid' => $databaseUuid, 'status' => 'exited']); + ray('Database is public', ['database_uuid' => $databaseUuid, 'is_public' => $database->is_public]); + if ($database->is_public) { + ray('Stopping TCP proxy for database', ['database_uuid' => $databaseUuid]); + StopDatabaseProxy::dispatch($database); + } + } + }); + } + } + + private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus) + { + $service = $this->services->where('id', $serviceId)->first(); + if (! $service) { + return; + } + if ($subType === 'application') { + $application = $service->applications()->where('id', $subId)->first(); + $application->status = $containerStatus; + $application->save(); + ray('Service application updated', ['service_id' => $serviceId, 'sub_type' => $subType, 'sub_id' => $subId, 'status' => $containerStatus]); + } elseif ($subType === 'database') { + $database = $service->databases()->where('id', $subId)->first(); + $database->status = $containerStatus; + $database->save(); + ray('Service database updated', ['service_id' => $serviceId, 'sub_type' => $subType, 'sub_id' => $subId, 'status' => $containerStatus]); + } else { + ray()->warning('Unknown sub type', ['service_id' => $serviceId, 'sub_type' => $subType, 'sub_id' => $subId, 'status' => $containerStatus]); + } + } + + private function updateNotFoundServiceStatus() + { + $notFoundServiceApplicationIds = $this->allServiceApplicationIds->diff($this->foundServiceApplicationIds); + $notFoundServiceDatabaseIds = $this->allServiceDatabaseIds->diff($this->foundServiceDatabaseIds); + if ($notFoundServiceApplicationIds->isNotEmpty()) { + ray('Not found service application ids', ['service_application_ids' => $notFoundServiceApplicationIds]); + $notFoundServiceApplicationIds->each(function ($serviceApplicationId) { + ray('Updating service application status', ['service_application_id' => $serviceApplicationId, 'status' => 'exited']); + $application = ServiceApplication::find($serviceApplicationId); + if ($application) { + $application->status = 'exited'; + $application->save(); + ray('Service application status updated', ['service_application_id' => $serviceApplicationId, 'status' => 'exited']); + } + }); + } + if ($notFoundServiceDatabaseIds->isNotEmpty()) { + ray('Not found service database ids', ['service_database_ids' => $notFoundServiceDatabaseIds]); + $notFoundServiceDatabaseIds->each(function ($serviceDatabaseId) { + ray('Updating service database status', ['service_database_id' => $serviceDatabaseId, 'status' => 'exited']); + $database = ServiceDatabase::find($serviceDatabaseId); + if ($database) { + $database->status = 'exited'; + $database->save(); + ray('Service database status updated', ['service_database_id' => $serviceDatabaseId, 'status' => 'exited']); + } + }); + } + } + + private function updateAdditionalServersStatus() + { + $this->allApplicationsWithAdditionalServers->each(function ($application) { + ray('Updating additional servers status for application', ['application_id' => $application->id]); + ComplexStatusCheck::run($application); + }); + } + + private function isRunning(string $containerStatus) + { + return str($containerStatus)->contains('running'); + } + + private function checkLogDrainContainer() + { + if ($this->server->isLogDrainEnabled() && $this->foundLogDrainContainer === false) { + InstallLogDrain::dispatch($this->server); + } + } +} diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 93d5fca70..6850ae98a 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -13,7 +13,6 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; class ScheduledTaskJob implements ShouldQueue @@ -56,24 +55,17 @@ class ScheduledTaskJob implements ShouldQueue { if ($this->resource instanceof Application) { $timezone = $this->resource->destination->server->settings->server_timezone; + return $timezone; } elseif ($this->resource instanceof Service) { $timezone = $this->resource->server->settings->server_timezone; + return $timezone; } + return 'UTC'; } - public function middleware(): array - { - return [new WithoutOverlapping($this->task->id)]; - } - - public function uniqueId(): int - { - return $this->task->id; - } - public function handle(): void { @@ -94,12 +86,12 @@ class ScheduledTaskJob implements ShouldQueue } elseif ($this->resource->type() == 'service') { $this->resource->applications()->get()->each(function ($application) { if (str(data_get($application, 'status'))->contains('running')) { - $this->containers[] = data_get($application, 'name') . '-' . data_get($this->resource, 'uuid'); + $this->containers[] = data_get($application, 'name').'-'.data_get($this->resource, 'uuid'); } }); $this->resource->databases()->get()->each(function ($database) { if (str(data_get($database, 'status'))->contains('running')) { - $this->containers[] = data_get($database, 'name') . '-' . data_get($this->resource, 'uuid'); + $this->containers[] = data_get($database, 'name').'-'.data_get($this->resource, 'uuid'); } }); } @@ -112,8 +104,8 @@ class ScheduledTaskJob implements ShouldQueue } foreach ($this->containers as $containerName) { - if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container . '-' . $this->resource->uuid)) { - $cmd = "sh -c '" . str_replace("'", "'\''", $this->task->command) . "'"; + if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { + $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; $exec = "docker exec {$containerName} {$cmd}"; $this->task_output = instant_remote_process([$exec], $this->server, true); $this->task_log->update([ diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index 540085385..39d4aa0c0 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -16,7 +16,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Arr; @@ -24,7 +23,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - public $tries = 3; + public $tries = 1; public $timeout = 60; @@ -45,16 +44,6 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public Server $server) {} - public function middleware(): array - { - return [(new WithoutOverlapping($this->server->id))]; - } - - public function uniqueId(): int - { - return $this->server->id; - } - public function handle() { try { @@ -80,7 +69,9 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue return 'No containers found.'; } GetContainersStatus::run($this->server, $this->containers, $containerReplicates); - $this->checkLogDrainContainer(); + if ($this->server->isLogDrainEnabled()) { + $this->checkLogDrainContainer(); + } } } catch (\Throwable $e) { @@ -93,7 +84,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue private function serverStatus() { - ['uptime' => $uptime] = $this->server->validateConnection(); + ['uptime' => $uptime] = $this->server->validateConnection(false); if ($uptime) { if ($this->server->unreachable_notification_sent === true) { $this->server->update(['unreachable_notification_sent' => false]); @@ -126,9 +117,6 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue private function checkLogDrainContainer() { - if (! $this->server->isLogDrainEnabled()) { - return; - } $foundLogDrainContainer = $this->containers->filter(function ($value, $key) { return data_get($value, 'Name') === '/coolify-log-drain'; })->first(); diff --git a/app/Jobs/ServerLimitCheckJob.php b/app/Jobs/ServerLimitCheckJob.php index 24292025b..1f09d5a3b 100644 --- a/app/Jobs/ServerLimitCheckJob.php +++ b/app/Jobs/ServerLimitCheckJob.php @@ -10,7 +10,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; class ServerLimitCheckJob implements ShouldBeEncrypted, ShouldQueue @@ -26,16 +25,6 @@ class ServerLimitCheckJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public Team $team) {} - public function middleware(): array - { - return [(new WithoutOverlapping($this->team->uuid))]; - } - - public function uniqueId(): int - { - return $this->team->uuid; - } - public function handle() { try { diff --git a/app/Jobs/ServerStatusJob.php b/app/Jobs/ServerStatusJob.php index ac9182eca..fcc33c859 100644 --- a/app/Jobs/ServerStatusJob.php +++ b/app/Jobs/ServerStatusJob.php @@ -8,7 +8,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; class ServerStatusJob implements ShouldBeEncrypted, ShouldQueue @@ -26,16 +25,6 @@ class ServerStatusJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public Server $server) {} - public function middleware(): array - { - return [(new WithoutOverlapping($this->server->uuid))]; - } - - public function uniqueId(): int - { - return $this->server->uuid; - } - public function handle() { if (! $this->server->isServerReady($this->tries)) { diff --git a/app/Jobs/ServerStorageCheckJob.php b/app/Jobs/ServerStorageCheckJob.php new file mode 100644 index 000000000..376cb8532 --- /dev/null +++ b/app/Jobs/ServerStorageCheckJob.php @@ -0,0 +1,59 @@ +server->isFunctional()) { + ray('Server is not ready.'); + + return 'Server is not ready.'; + } + $team = $this->server->team; + $percentage = $this->server->storageCheck(); + if ($percentage > 1) { + ray('Server storage is at '.$percentage.'%'); + } + + } catch (\Throwable $e) { + ray($e->getMessage()); + + return handleError($e); + } + + } +} diff --git a/app/Jobs/UpdateCoolifyJob.php b/app/Jobs/UpdateCoolifyJob.php index 4c65a711f..2cc705e4a 100644 --- a/app/Jobs/UpdateCoolifyJob.php +++ b/app/Jobs/UpdateCoolifyJob.php @@ -3,7 +3,6 @@ namespace App\Jobs; use App\Actions\Server\UpdateCoolify; -use App\Models\InstanceSettings; use App\Models\Server; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -23,7 +22,7 @@ class UpdateCoolifyJob implements ShouldBeEncrypted, ShouldQueue { try { CheckForUpdatesJob::dispatchSync(); - $settings = InstanceSettings::get(); + $settings = instanceSettings(); if (! $settings->new_version_available) { Log::info('No new version available. Skipping update.'); diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index af05ad767..52d4674ee 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -141,7 +141,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== if (! $this->createdServer) { return $this->dispatch('error', 'Localhost server is not found. Something went wrong during installation. Please try to reinstall or contact support.'); } - $this->serverPublicKey = $this->createdServer->privateKey->publicKey(); + $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); return $this->validateServer('localhost'); } elseif ($this->selectedServerType === 'remote') { @@ -175,7 +175,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== return; } $this->selectedExistingPrivateKey = $this->createdServer->privateKey->id; - $this->serverPublicKey = $this->createdServer->privateKey->publicKey(); + $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); $this->updateServerDetails(); $this->currentState = 'validate-server'; } @@ -231,17 +231,24 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== public function savePrivateKey() { $this->validate([ - 'privateKeyName' => 'required', - 'privateKey' => 'required', + 'privateKeyName' => 'required|string|max:255', + 'privateKeyDescription' => 'nullable|string|max:255', + 'privateKey' => 'required|string', ]); - $this->createdPrivateKey = PrivateKey::create([ - 'name' => $this->privateKeyName, - 'description' => $this->privateKeyDescription, - 'private_key' => $this->privateKey, - 'team_id' => currentTeam()->id, - ]); - $this->createdPrivateKey->save(); - $this->currentState = 'create-server'; + + try { + $privateKey = PrivateKey::createAndStore([ + 'name' => $this->privateKeyName, + 'description' => $this->privateKeyDescription, + 'private_key' => $this->privateKey, + 'team_id' => currentTeam()->id, + ]); + + $this->createdPrivateKey = $privateKey; + $this->currentState = 'create-server'; + } catch (\Exception $e) { + $this->addError('privateKey', 'Failed to save private key: '.$e->getMessage()); + } } public function saveServer() diff --git a/app/Livewire/CommandCenter/Index.php b/app/Livewire/CommandCenter/Index.php deleted file mode 100644 index 0a05e811f..000000000 --- a/app/Livewire/CommandCenter/Index.php +++ /dev/null @@ -1,21 +0,0 @@ -servers = Server::isReachable()->get(); - } - - public function render() - { - return view('livewire.command-center.index'); - } -} diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 68555d26c..d18a7689e 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -30,8 +30,7 @@ class Dashboard extends Component public function cleanup_queue() { - $this->dispatch('success', 'Cleanup started.'); - Artisan::queue('cleanup:application-deployment-queue', [ + Artisan::queue('cleanup:deployment-queue', [ '--team-id' => currentTeam()->id, ]); } diff --git a/app/Livewire/Destination/Form.php b/app/Livewire/Destination/Form.php index 7125f2120..87ae83931 100644 --- a/app/Livewire/Destination/Form.php +++ b/app/Livewire/Destination/Form.php @@ -38,7 +38,7 @@ class Form extends Component } $this->destination->delete(); - return redirect()->route('dashboard'); + return redirect()->route('destination.all'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 5650e82ba..37583a944 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -66,7 +66,7 @@ class Show extends Component return ! $alreadyAddedNetworks->contains('network', $network['Name']); }); if ($this->networks->count() === 0) { - $this->dispatch('success', 'No new networks found.'); + $this->dispatch('success', 'No new destinations found on this server.'); return; } diff --git a/app/Livewire/Help.php b/app/Livewire/Help.php index d6dc0d521..934e81661 100644 --- a/app/Livewire/Help.php +++ b/app/Livewire/Help.php @@ -47,7 +47,7 @@ class Help extends Component ] ); $mail->subject("[HELP]: {$this->subject}"); - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); $type = set_transanctional_email_settings($settings); if (! $type) { $url = 'https://app.coolify.io/api/feedback'; @@ -61,6 +61,7 @@ class Help extends Component send_user_an_email($mail, auth()->user()?->email, 'hi@coollabs.io'); } $this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.'); + $this->reset('description', 'subject'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index ec196c154..988add7c8 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -2,13 +2,28 @@ namespace App\Livewire; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class NavbarDeleteTeam extends Component { - public function delete() + public $team; + + public function mount() { + $this->team = currentTeam()->name; + } + + public function delete($password) + { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + $currentTeam = currentTeam(); $currentTeam->delete(); diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 2960ed226..53673292e 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -172,7 +172,7 @@ class Email extends Component public function copyFromInstanceSettings() { - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); if ($settings->smtp_enabled) { $team = currentTeam(); $team->update([ diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index f2968f6d9..3de895f8c 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -4,7 +4,6 @@ namespace App\Livewire\Project\Application\Deployment; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; -use Illuminate\Support\Collection; use Livewire\Component; class Show extends Component diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index d2700f444..2e327d80f 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -2,9 +2,11 @@ namespace App\Livewire\Project\Application; +use App\Actions\Application\GenerateConfig; use App\Models\Application; use Illuminate\Support\Collection; use Livewire\Component; +use Spatie\Url\Url; use Visus\Cuid2\Cuid2; class General extends Component @@ -182,9 +184,7 @@ class General extends Component $storage->save(); }); } - } - } public function loadComposeFile($isInit = false) @@ -241,16 +241,6 @@ class General extends Component } } - public function updatedApplicationFqdn() - { - $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { - return str($domain)->trim()->lower(); - }); - $this->application->fqdn = $this->application->fqdn->unique()->implode(','); - $this->resetDefaultLabels(); - } public function updatedApplicationBuildPack() { @@ -287,18 +277,22 @@ class General extends Component public function resetDefaultLabels() { - if ($this->application->settings->is_container_label_readonly_enabled) { - return; + try { + if ($this->application->settings->is_container_label_readonly_enabled) { + return; + } + $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); + $this->ports_exposes = $this->application->ports_exposes; + $this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled; + $this->application->custom_labels = base64_encode($this->customLabels); + $this->application->save(); + if ($this->application->build_pack === 'dockercompose') { + $this->loadComposeFile(); + } + $this->dispatch('configurationChanged'); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); - $this->ports_exposes = $this->application->ports_exposes; - $this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled; - $this->application->custom_labels = base64_encode($this->customLabels); - $this->application->save(); - if ($this->application->build_pack === 'dockercompose') { - $this->loadComposeFile(); - } - $this->dispatch('configurationChanged'); } public function checkFqdns($showToaster = true) @@ -320,7 +314,7 @@ class General extends Component public function set_redirect() { try { - $has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count(); + $has_www = collect($this->application->fqdns)->filter(fn($fqdn) => str($fqdn)->contains('www.'))->count(); if ($has_www === 0 && $this->application->redirect === 'www') { $this->dispatch('error', 'You want to redirect to www, but you do not have a www domain set.

Please add www to your domain list and as an A DNS record (if applicable).'); @@ -337,15 +331,18 @@ class General extends Component public function submit($showToaster = true) { try { - if ($this->application->isDirty('redirect')) { - $this->set_redirect(); - } $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + Url::fromString($domain, ['http', 'https']); return str($domain)->trim()->lower(); }); $this->application->fqdn = $this->application->fqdn->unique()->implode(','); + $this->resetDefaultLabels(); + + if ($this->application->isDirty('redirect')) { + $this->set_redirect(); + } $this->checkFqdns(); @@ -408,9 +405,25 @@ class General extends Component $this->application->save(); $showToaster && $this->dispatch('success', 'Application settings updated!'); } catch (\Throwable $e) { + $originalFqdn = $this->application->getOriginal('fqdn'); + if ($originalFqdn !== $this->application->fqdn) { + $this->application->fqdn = $originalFqdn; + } return handleError($e, $this); } finally { $this->dispatch('configurationChanged'); } } + public function downloadConfig() + { + $config = GenerateConfig::run($this->application, true); + $fileName = str($this->application->name)->slug()->append('_config.json'); + + return response()->streamDownload(function () use ($config) { + echo $config; + }, $fileName, [ + 'Content-Type' => 'application/json', + 'Content-Disposition' => 'attachment; filename=' . $fileName, + ]); + } } diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index c02949e17..1082b48cd 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -21,6 +21,8 @@ class Heading extends Component protected string $deploymentUuid; + public bool $docker_cleanup = true; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -102,7 +104,7 @@ class Heading extends Component public function stop() { - StopApplication::run($this->application); + StopApplication::run($this->application, false, $this->docker_cleanup); $this->application->status = 'exited'; $this->application->save(); if ($this->application->additional_servers->count() > 0) { @@ -135,4 +137,13 @@ class Heading extends Component 'environment_name' => $this->parameters['environment_name'], ]); } + + public function render() + { + return view('livewire.project.application.heading', [ + 'checkboxes' => [ + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + ], + ]); + } } diff --git a/app/Livewire/Project/Application/Preview/Form.php b/app/Livewire/Project/Application/Preview/Form.php index e4f100fcf..9a0b9b851 100644 --- a/app/Livewire/Project/Application/Preview/Form.php +++ b/app/Livewire/Project/Application/Preview/Form.php @@ -31,10 +31,14 @@ class Form extends Component public function generate_real_url() { if (data_get($this->application, 'fqdn')) { - $firstFqdn = str($this->application->fqdn)->before(','); - $url = Url::fromString($firstFqdn); - $host = $url->getHost(); - $this->preview_url_template = str($this->application->preview_url_template)->replace('{{domain}}', $host); + try { + $firstFqdn = str($this->application->fqdn)->before(','); + $url = Url::fromString($firstFqdn); + $host = $url->getHost(); + $this->preview_url_template = str($this->application->preview_url_template)->replace('{{domain}}', $host); + } catch (\Exception $e) { + $this->dispatch('error', 'Invalid FQDN.'); + } } } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 317a2ae51..b1ba035dc 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -5,7 +5,9 @@ namespace App\Livewire\Project\Application; use App\Actions\Docker\GetContainersStatus; use App\Models\Application; use App\Models\ApplicationPreview; +use Illuminate\Process\InvokedProcess; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Process; use Livewire\Component; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; @@ -184,17 +186,20 @@ class Previews extends Component public function stop(int $pull_request_id) { try { + $server = $this->application->destination->server; + $timeout = 300; + if ($this->application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $this->application->destination->server); + instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server); } else { - $containers = getCurrentApplicationContainerStatus($this->application->destination->server, $this->application->id, $pull_request_id); - foreach ($containers as $container) { - $name = str_replace('/', '', $container['Names']); - instant_remote_process(["docker rm -f $name"], $this->application->destination->server, throwError: false); - } + $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray(); + $this->stopContainers($containers, $server, $timeout); } - GetContainersStatus::dispatchSync($this->application->destination->server)->onQueue('high'); - $this->dispatch('reloadWindow'); + + GetContainersStatus::run($server); + $this->application->refresh(); + $this->dispatch('containerStatusUpdated'); + $this->dispatch('success', 'Preview Deployment stopped.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -203,16 +208,21 @@ class Previews extends Component public function delete(int $pull_request_id) { try { + $server = $this->application->destination->server; + $timeout = 300; + if ($this->application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $this->application->destination->server); + instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server); } else { - $containers = getCurrentApplicationContainerStatus($this->application->destination->server, $this->application->id, $pull_request_id); - foreach ($containers as $container) { - $name = str_replace('/', '', $container['Names']); - instant_remote_process(["docker rm -f $name"], $this->application->destination->server, throwError: false); - } + $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray(); + $this->stopContainers($containers, $server, $timeout); } - ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first()->delete(); + + ApplicationPreview::where('application_id', $this->application->id) + ->where('pull_request_id', $pull_request_id) + ->first() + ->delete(); + $this->application->refresh(); $this->dispatch('update_links'); $this->dispatch('success', 'Preview deleted.'); @@ -220,4 +230,49 @@ class Previews extends Component return handleError($e, $this); } } + + private function stopContainers(array $containers, $server, int $timeout) + { + $processes = []; + foreach ($containers as $container) { + $containerName = str_replace('/', '', $container['Names']); + $processes[$containerName] = $this->stopContainer($containerName, $timeout); + } + + $startTime = time(); + while (count($processes) > 0) { + $finishedProcesses = array_filter($processes, function ($process) { + return ! $process->running(); + }); + foreach (array_keys($finishedProcesses) as $containerName) { + unset($processes[$containerName]); + $this->removeContainer($containerName, $server); + } + + if (time() - $startTime >= $timeout) { + $this->forceStopRemainingContainers(array_keys($processes), $server); + break; + } + + usleep(100000); + } + } + + private function stopContainer(string $containerName, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + private function removeContainer(string $containerName, $server) + { + instant_remote_process(["docker rm -f $containerName"], $server, throwError: false); + } + + private function forceStopRemainingContainers(array $containerNames, $server) + { + foreach ($containerNames as $containerName) { + instant_remote_process(["docker kill $containerName"], $server, throwError: false); + $this->removeContainer($containerName, $server); + } + } } diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 59f2f9a39..7e2e4a12b 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -3,6 +3,8 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; use Spatie\Url\Url; @@ -12,6 +14,12 @@ class BackupEdit extends Component public $s3s; + public bool $delete_associated_backups_locally = false; + + public bool $delete_associated_backups_s3 = false; + + public bool $delete_associated_backups_sftp = false; + public ?string $status = null; public array $parameters; @@ -23,6 +31,7 @@ class BackupEdit extends Component 'backup.save_s3' => 'required|boolean', 'backup.s3_storage_id' => 'nullable|integer', 'backup.databases_to_backup' => 'nullable', + 'backup.dump_all' => 'required|boolean', ]; protected $validationAttributes = [ @@ -32,6 +41,7 @@ class BackupEdit extends Component 'backup.save_s3' => 'Save to S3', 'backup.s3_storage_id' => 'S3 Storage', 'backup.databases_to_backup' => 'Databases to Backup', + 'backup.dump_all' => 'Backup All Databases', ]; protected $messages = [ @@ -46,10 +56,24 @@ class BackupEdit extends Component } } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + try { + if ($this->delete_associated_backups_locally) { + $this->deleteAssociatedBackupsLocally(); + } + if ($this->delete_associated_backups_s3) { + $this->deleteAssociatedBackupsS3(); + } + $this->backup->delete(); + if ($this->backup->database->getMorphClass() === 'App\Models\ServiceDatabase') { $previousUrl = url()->previous(); $url = Url::fromString($previousUrl); @@ -104,4 +128,66 @@ class BackupEdit extends Component $this->dispatch('error', $e->getMessage()); } } + + public function deleteAssociatedBackupsLocally() + { + $executions = $this->backup->executions; + $backupFolder = null; + + foreach ($executions as $execution) { + if ($this->backup->database->getMorphClass() === 'App\Models\ServiceDatabase') { + $server = $this->backup->database->service->destination->server; + } else { + $server = $this->backup->database->destination->server; + } + + if (! $backupFolder) { + $backupFolder = dirname($execution->filename); + } + + delete_backup_locally($execution->filename, $server); + $execution->delete(); + } + + if ($backupFolder) { + $this->deleteEmptyBackupFolder($backupFolder, $server); + } + } + + public function deleteAssociatedBackupsS3() + { + //Add function to delete backups from S3 + } + + public function deleteAssociatedBackupsSftp() + { + //Add function to delete backups from SFTP + } + + private function deleteEmptyBackupFolder($folderPath, $server) + { + $checkEmpty = instant_remote_process(["[ -z \"$(ls -A '$folderPath')\" ] && echo 'empty' || echo 'not empty'"], $server); + + if (trim($checkEmpty) === 'empty') { + instant_remote_process(["rmdir '$folderPath'"], $server); + + $parentFolder = dirname($folderPath); + $checkParentEmpty = instant_remote_process(["[ -z \"$(ls -A '$parentFolder')\" ] && echo 'empty' || echo 'not empty'"], $server); + + if (trim($checkParentEmpty) === 'empty') { + instant_remote_process(["rmdir '$parentFolder'"], $server); + } + } + } + + public function render() + { + return view('livewire.project.database.backup-edit', [ + 'checkboxes' => [ + ['id' => 'delete_associated_backups_locally', 'label' => __('database.delete_backups_locally')], + // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected S3 Storage.'] + // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected SFTP Storage.'] + ], + ]); + } } diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 5d56ea53d..c8c33a022 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -3,18 +3,28 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; +use Livewire\Attributes\On; use Livewire\Component; class BackupExecutions extends Component { public ?ScheduledDatabaseBackup $backup = null; + public $database; + public $executions = []; + public $setDeletableBackup; + public $delete_backup_s3 = true; + + public $delete_backup_sftp = true; + public function getListeners() { - $userId = auth()->user()->id; + $userId = Auth::id(); return [ "echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions', @@ -31,19 +41,36 @@ class BackupExecutions extends Component } } - public function deleteBackup($exeuctionId) + #[On('deleteBackup')] + public function deleteBackup($executionId, $password) { - $execution = $this->backup->executions()->where('id', $exeuctionId)->first(); + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + + $execution = $this->backup->executions()->where('id', $executionId)->first(); if (is_null($execution)) { $this->dispatch('error', 'Backup execution not found.'); return; } + if ($execution->scheduledDatabaseBackup->database->getMorphClass() === 'App\Models\ServiceDatabase') { delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->service->destination->server); } else { delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->destination->server); } + + if ($this->delete_backup_s3) { + // Add logic to delete from S3 + } + + if ($this->delete_backup_sftp) { + // Add logic to delete from SFTP + } + $execution->delete(); $this->dispatch('success', 'Backup deleted.'); $this->refreshBackupExecutions(); @@ -82,16 +109,18 @@ class BackupExecutions extends Component return $server; } } + return null; } public function getServerTimezone() { $server = $this->server(); - if (!$server) { + if (! $server) { return 'UTC'; } $serverTimezone = $server->settings->server_timezone; + return $serverTimezone; } @@ -104,6 +133,17 @@ class BackupExecutions extends Component } catch (\Exception $e) { $dateObj->setTimezone(new \DateTimeZone('UTC')); } + return $dateObj->format('Y-m-d H:i:s T'); } + + public function render() + { + return view('livewire.project.database.backup-executions', [ + 'checkboxes' => [ + ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently form S3 Storage'], + ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'], + ], + ]); + } } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index a6e2a1320..7a6446815 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -56,7 +56,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -73,14 +73,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -95,7 +95,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 00e0ff09f..394ba6c9a 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -54,7 +54,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -88,14 +88,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -110,7 +110,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 6435f6781..49884ff9a 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -14,6 +14,8 @@ class Heading extends Component public array $parameters; + public $docker_cleanup = true; + public function getListeners() { $userId = auth()->user()->id; @@ -54,7 +56,7 @@ class Heading extends Component public function stop() { - StopDatabase::run($this->database); + StopDatabase::run($this->database, false, $this->docker_cleanup); $this->database->status = 'exited'; $this->database->save(); $this->check_status(); @@ -71,4 +73,13 @@ class Heading extends Component $activity = StartDatabase::run($this->database); $this->dispatch('activityMonitor', $activity->id); } + + public function render() + { + return view('livewire.project.database.heading', [ + 'checkboxes' => [ + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + ], + ]); + } } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 320feeac7..f976e1edd 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -57,7 +57,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -94,14 +94,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -116,7 +116,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 70545910c..12d4882f3 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -63,7 +63,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -100,14 +100,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -122,7 +122,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index d23b66c00..ac40e7dfa 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -61,7 +61,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -101,14 +101,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -123,7 +123,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 29a9cbae2..7d5270ddf 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -62,7 +62,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -99,14 +99,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -121,7 +121,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index fd2f9834f..72fd95de8 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -57,7 +57,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -88,14 +88,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -110,7 +110,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/ScheduledBackups.php b/app/Livewire/Project/Database/ScheduledBackups.php index beb5a9c39..8021e25d3 100644 --- a/app/Livewire/Project/Database/ScheduledBackups.php +++ b/app/Livewire/Project/Database/ScheduledBackups.php @@ -26,7 +26,7 @@ class ScheduledBackups extends Component public function mount(): void { if ($this->selectedBackupId) { - $this->setSelectedBackup($this->selectedBackupId); + $this->setSelectedBackup($this->selectedBackupId, true); } $this->parameters = get_route_parameters(); if ($this->database->getMorphClass() === 'App\Models\ServiceDatabase') { @@ -37,10 +37,13 @@ class ScheduledBackups extends Component $this->s3s = currentTeam()->s3s; } - public function setSelectedBackup($backupId) + public function setSelectedBackup($backupId, $force = false) { + if ($this->selectedBackupId === $backupId && ! $force) { + return; + } $this->selectedBackupId = $backupId; - $this->selectedBackup = $this->database->scheduledBackups->find($this->selectedBackupId); + $this->selectedBackup = $this->database->scheduledBackups->find($backupId); if (is_null($this->selectedBackup)) { $this->selectedBackupId = null; } diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php index 22478916f..e01741770 100644 --- a/app/Livewire/Project/DeleteEnvironment.php +++ b/app/Livewire/Project/DeleteEnvironment.php @@ -13,9 +13,12 @@ class DeleteEnvironment extends Component public bool $disabled = false; + public string $environmentName = ''; + public function mount() { $this->parameters = get_route_parameters(); + $this->environmentName = Environment::findOrFail($this->environment_id)->name; } public function delete() diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php index 499b86e3e..360fad10a 100644 --- a/app/Livewire/Project/DeleteProject.php +++ b/app/Livewire/Project/DeleteProject.php @@ -13,9 +13,12 @@ class DeleteProject extends Component public bool $disabled = false; + public string $projectName = ''; + public function mount() { $this->parameters = get_route_parameters(); + $this->projectName = Project::findOrFail($this->project_id)->name; } public function delete() diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 0e4f15a5c..f8eb838be 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -18,7 +18,11 @@ class Index extends Component public function mount() { $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); - $this->projects = Project::ownedByCurrentTeam()->get(); + $this->projects = Project::ownedByCurrentTeam()->get()->map(function ($project) { + $project->settingsRoute = route('project.edit', ['project_uuid' => $project->uuid]); + + return $project; + }); $this->servers = Server::ownedByCurrentTeam()->count(); } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index b5c5cb1db..971d4700b 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -31,10 +31,12 @@ class PublicGitRepository extends Component public bool $isStatic = false; + public bool $checkCoolifyConfig = true; + public ?string $publish_directory = null; // In case of docker compose - public ?string $base_directory = null; + public string $base_directory = '/'; public ?string $docker_compose_location = '/docker-compose.yaml'; // End of docker compose @@ -97,6 +99,7 @@ class PublicGitRepository extends Component $this->base_directory = '/'.$this->base_directory; } } + } public function updatedDockerComposeLocation() @@ -275,6 +278,7 @@ class PublicGitRepository extends Component 'destination_id' => $destination->id, 'destination_type' => $destination_class, 'build_pack' => $this->build_pack, + 'base_directory' => $this->base_directory, ]; } else { $application_init = [ @@ -289,6 +293,7 @@ class PublicGitRepository extends Component 'source_id' => $this->git_source->id, 'source_type' => $this->git_source->getMorphClass(), 'build_pack' => $this->build_pack, + 'base_directory' => $this->base_directory, ]; } @@ -303,11 +308,15 @@ class PublicGitRepository extends Component $application->settings->is_static = $this->isStatic; $application->settings->save(); - $fqdn = generateFqdn($destination->server, $application->uuid); $application->fqdn = $fqdn; $application->save(); - + if ($this->checkCoolifyConfig) { + // $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id); + // if ($config) { + // $application->setConfig($config); + // } + } return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_name' => $environment->name, diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 3c5f3901b..7f8247597 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -49,11 +49,8 @@ class Select extends Component public ?string $existingPostgresqlUrl = null; - public ?string $search = null; - protected $queryString = [ 'server_id', - 'search', ]; public function mount() @@ -90,40 +87,119 @@ class Select extends Component // } // } - public function updatedSearch() + public function loadServices() { - $this->loadServices(); - } + $services = get_service_templates(true); + $services = collect($services)->map(function ($service, $key) { + return [ + 'name' => str($key)->headline(), + 'logo' => asset(data_get($service, 'logo', 'svgs/coolify.png')), + ] + (array) $service; + })->all(); + $gitBasedApplications = [ + [ + 'id' => 'public', + 'name' => 'Public Repository', + 'description' => 'You can deploy any kind of public repositories from the supported git providers.', + 'logo' => asset('svgs/git.svg'), + ], + [ + 'id' => 'private-gh-app', + 'name' => 'Private Repository (with GitHub App)', + 'description' => 'You can deploy public & private repositories through your GitHub Apps.', + 'logo' => asset('svgs/github.svg'), + ], + [ + 'id' => 'private-deploy-key', + 'name' => 'Private Repository (with Deploy Key)', + 'description' => 'You can deploy private repositories with a deploy key.', + 'logo' => asset('svgs/git.svg'), + ], + ]; + $dockerBasedApplications = [ + [ + 'id' => 'dockerfile', + 'name' => 'Dockerfile', + 'description' => 'You can deploy a simple Dockerfile, without Git.', + 'logo' => asset('svgs/docker.svg'), + ], + [ + 'id' => 'docker-compose-empty', + 'name' => 'Docker Compose Empty', + 'description' => 'You can deploy complex application easily with Docker Compose, without Git.', + 'logo' => asset('svgs/docker.svg'), + ], + [ + 'id' => 'docker-image', + 'name' => 'Docker Image', + 'description' => 'You can deploy an existing Docker Image from any Registry, without Git.', + 'logo' => asset('svgs/docker.svg'), + ], + ]; + $databases = [ + [ + 'id' => 'postgresql', + 'name' => 'PostgreSQL', + 'description' => 'PostgreSQL is an object-relational database known for its robustness, advanced features, and strong standards compliance.', + 'logo' => ' +', + ], + [ + 'id' => 'mysql', + 'name' => 'MySQL', + 'description' => 'MySQL is an open-source relational database management system. ', + 'logo' => ' + + + +', - public function loadServices(bool $force = false) - { - try { - $this->loadingServices = true; - if (count($this->allServices) > 0 && ! $force) { - if (! $this->search) { - $this->services = $this->allServices; + ], + [ + 'id' => 'mariadb', + 'name' => 'MariaDB', + 'description' => 'MariaDB is a community-developed, commercially supported fork of the MySQL relational database management system, intended to remain free and open-source software under the GNU General Public License.', + 'logo' => '', + ], + [ + 'id' => 'redis', + 'name' => 'Redis', + 'description' => 'Redis is a source-available, in-memory storage, used as a distributed, in-memory key–value database, cache and message broker, with optional durability.', + 'logo' => '', + ], + [ + 'id' => 'keydb', + 'name' => 'KeyDB', + 'description' => 'KeyDB is a database that offers high performance, low latency, and scalability for various data structures and workloads.', + 'logo' => '
', + ], + [ + 'id' => 'dragonfly', + 'name' => 'Dragonfly', + 'description' => 'Dragonfly DB is a drop-in Redis replacement that delivers 25x more throughput and 12x faster snapshotting than Redis.', + 'logo' => '
', + ], + [ + 'id' => 'mongodb', + 'name' => 'MongoDB', + 'description' => 'MongoDB is a source-available, cross-platform, document-oriented database program.', + 'logo' => '', + ], + [ + 'id' => 'clickhouse', + 'name' => 'ClickHouse', + 'description' => 'ClickHouse is a column-oriented database that supports real-time analytics, business intelligence, observability, ML and GenAI, and more.', + 'logo' => '
', + ], - return; - } - $this->services = $this->allServices->filter(function ($service, $key) { - $tags = collect(data_get($service, 'tags', [])); + ]; - return str_contains(strtolower($key), strtolower($this->search)) || $tags->contains(function ($tag) { - return str_contains(strtolower($tag), strtolower($this->search)); - }); - }); - } else { - $this->search = null; - $this->allServices = get_service_templates($force); - $this->services = $this->allServices->filter(function ($service, $key) { - return str_contains(strtolower($key), strtolower($this->search)); - }); - } - } catch (\Throwable $e) { - return handleError($e, $this); - } finally { - $this->loadingServices = false; - } + return [ + 'services' => $services, + 'gitBasedApplications' => $gitBasedApplications, + 'dockerBasedApplications' => $dockerBasedApplications, + 'databases' => $databases, + ]; } public function instantSave() @@ -141,6 +217,7 @@ class Select extends Component public function setType(string $type) { + $type = str($type)->lower()->slug()->value(); if ($this->loading) { return; } diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index 71ce2c356..283496887 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -32,8 +32,11 @@ class Index extends Component public $services = []; + public array $parameters; + public function mount() { + $this->parameters = get_route_parameters(); $project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first(); if (! $project) { return redirect()->route('dashboard'); @@ -44,7 +47,6 @@ class Index extends Component } $this->project = $project; $this->environment = $environment; - $this->applications = $this->environment->applications->load(['tags']); $this->applications = $this->applications->map(function ($application) { if (data_get($application, 'environment.project.uuid')) { diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index fa44fdfbf..a2e48fee7 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -52,7 +52,7 @@ class Configuration extends Component $application = $this->service->applications->find($id); if ($application) { $application->restart(); - $this->dispatch('success', 'Application restarted successfully.'); + $this->dispatch('success', 'Service application restarted successfully.'); } } catch (\Exception $e) { return handleError($e, $this); @@ -65,7 +65,7 @@ class Configuration extends Component $database = $this->service->databases->find($id); if ($database) { $database->restart(); - $this->dispatch('success', 'Database restarted successfully.'); + $this->dispatch('success', 'Service database restarted successfully.'); } } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index 70e8006c7..4138f720e 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -4,6 +4,7 @@ namespace App\Livewire\Project\Service; use App\Models\ServiceApplication; use Livewire\Component; +use Spatie\Url\Url; class EditDomain extends Component { @@ -20,21 +21,16 @@ class EditDomain extends Component { $this->application = ServiceApplication::find($this->applicationId); } - - public function updatedApplicationFqdn() - { - $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { - return str($domain)->trim()->lower(); - }); - $this->application->fqdn = $this->application->fqdn->unique()->implode(','); - $this->application->save(); - } - public function submit() { try { + $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); + $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); + $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + Url::fromString($domain, ['http', 'https']); + return str($domain)->trim()->lower(); + }); + $this->application->fqdn = $this->application->fqdn->unique()->implode(','); check_domain_usage(resource: $this->application); $this->validate(); $this->application->save(); @@ -44,12 +40,15 @@ class EditDomain extends Component } else { $this->dispatch('success', 'Service saved.'); } - } catch (\Throwable $e) { - return handleError($e, $this); - } finally { $this->application->service->parse(); $this->dispatch('refresh'); $this->dispatch('configurationChanged'); + } catch (\Throwable $e) { + $originalFqdn = $this->application->getOriginal('fqdn'); + if ($originalFqdn !== $this->application->fqdn) { + $this->application->fqdn = $originalFqdn; + } + return handleError($e, $this); } } diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 6cd54883e..215019112 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -14,6 +14,8 @@ use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class FileStorage extends Component @@ -83,8 +85,14 @@ class FileStorage extends Component } } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + try { $message = 'File deleted.'; if ($this->fileStorage->is_directory) { @@ -129,6 +137,13 @@ class FileStorage extends Component public function render() { - return view('livewire.project.service.file-storage'); + return view('livewire.project.service.file-storage', [ + 'directoryDeletionCheckboxes' => [ + ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permantely deleted form the server.'], + ], + 'fileDeletionCheckboxes' => [ + ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'], + ], + ]); } } diff --git a/app/Livewire/Project/Service/Navbar.php b/app/Livewire/Project/Service/Navbar.php index 674182df5..fa76ee26f 100644 --- a/app/Livewire/Project/Service/Navbar.php +++ b/app/Livewire/Project/Service/Navbar.php @@ -20,6 +20,10 @@ class Navbar extends Component public $isDeploymentProgress = false; + public $docker_cleanup = true; + + public $title = 'Configuration'; + public function mount() { if (str($this->service->status())->contains('running') && is_null($this->service->config_hash)) { @@ -35,12 +39,13 @@ class Navbar extends Component return [ "echo-private:user.{$userId},ServiceStatusChanged" => 'serviceStarted', + "envsUpdated" => '$refresh', ]; } public function serviceStarted() { - $this->dispatch('success', 'Service status changed.'); + // $this->dispatch('success', 'Service status changed.'); if (is_null($this->service->config_hash) || $this->service->isConfigurationChanged()) { $this->service->isConfigurationChanged(true); $this->dispatch('configurationChanged'); @@ -60,11 +65,6 @@ class Navbar extends Component $this->dispatch('success', 'Service status updated.'); } - public function render() - { - return view('livewire.project.service.navbar'); - } - public function checkDeployments() { try { @@ -95,14 +95,9 @@ class Navbar extends Component $this->dispatch('activityMonitor', $activity->id); } - public function stop(bool $forceCleanup = false) + public function stop() { - StopService::run($this->service); - if ($forceCleanup) { - $this->dispatch('success', 'Containers cleaned up.'); - } else { - $this->dispatch('success', 'Service stopped.'); - } + StopService::run($this->service, false, $this->docker_cleanup); ServiceStatusChanged::dispatch(); } @@ -114,11 +109,35 @@ class Navbar extends Component return; } - PullImage::run($this->service); - StopService::run($this->service); + StopService::run(service: $this->service, dockerCleanup: false); $this->service->parse(); $this->dispatch('imagePulled'); $activity = StartService::run($this->service); $this->dispatch('activityMonitor', $activity->id); } + + public function pullAndRestartEvent() + { + $this->checkDeployments(); + if ($this->isDeploymentProgress) { + $this->dispatch('error', 'There is a deployment in progress.'); + + return; + } + PullImage::run($this->service); + StopService::run(service: $this->service, dockerCleanup: false); + $this->service->parse(); + $this->dispatch('imagePulled'); + $activity = StartService::run($this->service); + $this->dispatch('activityMonitor', $activity->id); + } + + public function render() + { + return view('livewire.project.service.navbar', [ + 'checkboxes' => [ + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + ], + ]); + } } diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index e7d00c3dd..ba37313fd 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -3,7 +3,10 @@ namespace App\Livewire\Project\Service; use App\Models\ServiceApplication; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; +use Spatie\Url\Url; class ServiceApplicationView extends Component { @@ -11,6 +14,10 @@ class ServiceApplicationView extends Component public $parameters; + public $docker_cleanup = true; + + public $delete_volumes = true; + protected $rules = [ 'application.human_name' => 'nullable', 'application.description' => 'nullable', @@ -23,20 +30,9 @@ class ServiceApplicationView extends Component 'application.is_stripprefix_enabled' => 'nullable|boolean', ]; - public function render() - { - return view('livewire.project.service.service-application-view'); - } - public function updatedApplicationFqdn() { - $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); - $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { - return str($domain)->trim()->lower(); - }); - $this->application->fqdn = $this->application->fqdn->unique()->implode(','); - $this->application->save(); + } public function instantSave() @@ -56,8 +52,14 @@ class ServiceApplicationView extends Component $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + try { $this->application->delete(); $this->dispatch('success', 'Application deleted.'); @@ -76,6 +78,14 @@ class ServiceApplicationView extends Component public function submit() { try { + $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); + $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); + $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + Url::fromString($domain, ['http', 'https']); + return str($domain)->trim()->lower(); + }); + $this->application->fqdn = $this->application->fqdn->unique()->implode(','); + check_domain_usage(resource: $this->application); $this->validate(); $this->application->save(); @@ -85,10 +95,26 @@ class ServiceApplicationView extends Component } else { $this->dispatch('success', 'Service saved.'); } - } catch (\Throwable $e) { - return handleError($e, $this); - } finally { $this->dispatch('generateDockerCompose'); + } catch (\Throwable $e) { + $originalFqdn = $this->application->getOriginal('fqdn'); + if ($originalFqdn !== $this->application->fqdn) { + $this->application->fqdn = $originalFqdn; + } + return handleError($e, $this); } } + + public function render() + { + return view('livewire.project.service.service-application-view', [ + 'checkboxes' => [ + ['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')], + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + // ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'], + // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'], + // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.'] + ], + ]); + } } diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index 04bb136db..2c751aa92 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -33,7 +33,8 @@ class StackForm extends Component $key = data_get($field, 'key'); $value = data_get($field, 'value'); $rules = data_get($field, 'rules', 'nullable'); - $isPassword = data_get($field, 'isPassword'); + $isPassword = data_get($field, 'isPassword', false); + $customHelper = data_get($field, 'customHelper', false); $this->fields->put($key, [ 'serviceName' => $serviceName, 'key' => $key, @@ -41,13 +42,22 @@ class StackForm extends Component 'value' => $value, 'isPassword' => $isPassword, 'rules' => $rules, + 'customHelper' => $customHelper, ]); $this->rules["fields.$key.value"] = $rules; $this->validationAttributes["fields.$key.value"] = $fieldKey; } } - $this->fields = $this->fields->sortBy('name'); + $this->fields = $this->fields->groupBy('serviceName')->map(function ($group) { + return $group->sortBy(function ($field) { + return data_get($field, 'isPassword') ? 1 : 0; + })->mapWithKeys(function ($field) { + return [$field['key'] => $field]; + }); + })->flatMap(function ($group) { + return $group; + }); } public function saveCompose($raw) diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 5f0178be4..c05260899 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -3,6 +3,11 @@ namespace App\Livewire\Project\Shared; use App\Jobs\DeleteResourceJob; +use App\Models\Service; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -10,6 +15,8 @@ class Danger extends Component { public $resource; + public $resourceName; + public $projectUuid; public $environmentName; @@ -18,22 +25,95 @@ class Danger extends Component public bool $delete_volumes = true; + public bool $docker_cleanup = true; + + public bool $delete_connected_networks = true; + public ?string $modalId = null; + public string $resourceDomain = ''; + public function mount() { - $this->modalId = new Cuid2; $parameters = get_route_parameters(); + $this->modalId = new Cuid2; $this->projectUuid = data_get($parameters, 'project_uuid'); $this->environmentName = data_get($parameters, 'environment_name'); + + if ($this->resource === null) { + if (isset($parameters['service_uuid'])) { + $this->resource = Service::where('uuid', $parameters['service_uuid'])->first(); + } elseif (isset($parameters['stack_service_uuid'])) { + $this->resource = ServiceApplication::where('uuid', $parameters['stack_service_uuid'])->first() + ?? ServiceDatabase::where('uuid', $parameters['stack_service_uuid'])->first(); + } + } + + if ($this->resource === null) { + $this->resourceName = 'Unknown Resource'; + + return; + } + + if (! method_exists($this->resource, 'type')) { + $this->resourceName = 'Unknown Resource'; + + return; + } + + switch ($this->resource->type()) { + case 'application': + $this->resourceName = $this->resource->name ?? 'Application'; + break; + case 'standalone-postgresql': + case 'standalone-redis': + case 'standalone-mongodb': + case 'standalone-mysql': + case 'standalone-mariadb': + case 'standalone-keydb': + case 'standalone-dragonfly': + case 'standalone-clickhouse': + $this->resourceName = $this->resource->name ?? 'Database'; + break; + case 'service': + $this->resourceName = $this->resource->name ?? 'Service'; + break; + case 'service-application': + $this->resourceName = $this->resource->name ?? 'Service Application'; + break; + case 'service-database': + $this->resourceName = $this->resource->name ?? 'Service Database'; + break; + default: + $this->resourceName = 'Unknown Resource'; + } } - public function delete() + public function delete($password) { + if (isProduction()) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + } + + if (! $this->resource) { + $this->addError('resource', 'Resource not found.'); + + return; + } + try { - // $this->authorize('delete', $this->resource); $this->resource->delete(); - DeleteResourceJob::dispatch($this->resource, $this->delete_configurations, $this->delete_volumes); + DeleteResourceJob::dispatch( + $this->resource, + $this->delete_configurations, + $this->delete_volumes, + $this->docker_cleanup, + $this->delete_connected_networks + ); return redirect()->route('project.resource.index', [ 'project_uuid' => $this->projectUuid, @@ -43,4 +123,19 @@ class Danger extends Component return handleError($e, $this); } } + + public function render() + { + return view('livewire.project.shared.danger', [ + 'checkboxes' => [ + ['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')], + ['id' => 'delete_connected_networks', 'label' => __('resource.delete_connected_networks')], + ['id' => 'delete_configurations', 'label' => __('resource.delete_configurations')], + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + // ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'], + // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'], + // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.'] + ], + ]); + } } diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index a2c018beb..7fb5c45db 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -8,6 +8,8 @@ use App\Events\ApplicationStatusChanged; use App\Jobs\ContainerStatusJob; use App\Models\Server; use App\Models\StandaloneDocker; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -115,8 +117,14 @@ class Destination extends Component ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); } - public function removeServer(int $network_id, int $server_id) + public function removeServer(int $network_id, int $server_id, $password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) { $this->dispatch('error', 'You cannot remove this destination server.', 'You are trying to remove the main server.'); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index a859c90b0..0dbf0f957 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -48,14 +48,6 @@ class Add extends Component public function submit() { $this->validate(); - // if (str($this->value)->startsWith('{{') && str($this->value)->endsWith('}}')) { - // $type = str($this->value)->after('{{')->before('.')->value; - // if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) { - // $this->dispatch('error', 'Invalid shared variable type.', 'Valid types are: team, project, environment.'); - - // return; - // } - // } $this->dispatch('saveKey', [ 'key' => $this->key, 'value' => $this->value, diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 055788b57..5a711259b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -53,30 +53,16 @@ class All extends Component public function sortEnvironmentVariables() { - if ($this->resource->type() === 'application') { - $this->resource->load(['environment_variables', 'environment_variables_preview']); - } else { - $this->resource->load(['environment_variables']); + if (! data_get($this->resource, 'settings.is_env_sorting_enabled')) { + if ($this->resource->environment_variables) { + $this->resource->environment_variables = $this->resource->environment_variables->sortBy('order')->values(); + } + + if ($this->resource->environment_variables_preview) { + $this->resource->environment_variables_preview = $this->resource->environment_variables_preview->sortBy('order')->values(); + } } - $sortBy = data_get($this->resource, 'settings.is_env_sorting_enabled') ? 'key' : 'order'; - - $sortFunction = function ($variables) use ($sortBy) { - if (! $variables) { - return $variables; - } - if ($sortBy === 'key') { - return $variables->sortBy(function ($item) { - return strtolower($item->key); - }, SORT_NATURAL | SORT_FLAG_CASE)->values(); - } else { - return $variables->sortBy('order')->values(); - } - }; - - $this->resource->environment_variables = $sortFunction($this->resource->environment_variables); - $this->resource->environment_variables_preview = $sortFunction($this->resource->environment_variables_preview); - $this->getDevView(); } @@ -121,6 +107,8 @@ class All extends Component $this->sortEnvironmentVariables(); } catch (\Throwable $e) { return handleError($e, $this); + } finally { + $this->refreshEnvs(); } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 463ceecad..0538a6bdb 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -37,6 +37,7 @@ class Show extends Component 'env.is_literal' => 'required|boolean', 'env.is_shown_once' => 'required|boolean', 'env.real_value' => 'nullable', + 'env.is_required' => 'required|boolean', ]; protected $validationAttributes = [ @@ -46,6 +47,7 @@ class Show extends Component 'env.is_multiline' => 'Multiline', 'env.is_literal' => 'Literal', 'env.is_shown_once' => 'Shown Once', + 'env.is_required' => 'Required', ]; public function refresh() @@ -109,14 +111,14 @@ class Show extends Component } else { $this->validate(); } - // if (str($this->env->value)->startsWith('{{') && str($this->env->value)->endsWith('}}')) { - // $type = str($this->env->value)->after('{{')->before('.')->value; - // if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) { - // $this->dispatch('error', 'Invalid shared variable type.', 'Valid types are: team, project, environment.'); - // return; - // } - // } + if ($this->env->is_required && str($this->env->real_value)->isEmpty()) { + $oldValue = $this->env->getOriginal('value'); + $this->env->value = $oldValue; + $this->dispatch('error', 'Required environment variable cannot be empty.'); + + return; + } $this->serialize(); $this->env->save(); $this->dispatch('success', 'Environment variable updated.'); diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 343915d9c..90419caed 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -2,18 +2,18 @@ namespace App\Livewire\Project\Shared; -use App\Actions\Server\RunCommand; use App\Models\Application; use App\Models\Server; use App\Models\Service; use Illuminate\Support\Collection; +use Livewire\Attributes\On; use Livewire\Component; class ExecuteContainerCommand extends Component { - public string $command; + public $selected_container = 'default'; - public string $container; + public $container; public Collection $containers; @@ -23,8 +23,6 @@ class ExecuteContainerCommand extends Component public string $type; - public string $workDir = ''; - public Server $server; public Collection $servers; @@ -33,11 +31,13 @@ class ExecuteContainerCommand extends Component 'server' => 'required', 'container' => 'required', 'command' => 'required', - 'workDir' => 'nullable', ]; public function mount() { + if (! auth()->user()->isAdmin()) { + abort(403); + } $this->parameters = get_route_parameters(); $this->containers = collect(); $this->servers = collect(); @@ -62,24 +62,13 @@ class ExecuteContainerCommand extends Component if ($this->resource->destination->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->destination->server); } - $this->container = $this->resource->uuid; - $this->containers->push($this->container); } elseif (data_get($this->parameters, 'service_uuid')) { $this->type = 'service'; $this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail(); - $this->resource->applications()->get()->each(function ($application) { - $this->containers->push(data_get($application, 'name').'-'.data_get($this->resource, 'uuid')); - }); - $this->resource->databases()->get()->each(function ($database) { - $this->containers->push(data_get($database, 'name').'-'.data_get($this->resource, 'uuid')); - }); if ($this->resource->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->server); } } - if ($this->containers->count() > 0) { - $this->container = $this->containers->first(); - } } public function loadContainers() @@ -96,50 +85,78 @@ class ExecuteContainerCommand extends Component $containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true); } foreach ($containers as $container) { - $payload = [ - 'server' => $server, - 'container' => $container, - ]; - $this->containers = $this->containers->push($payload); + // if container state is running + if (data_get($container, 'State') === 'running') { + $payload = [ + 'server' => $server, + 'container' => $container, + ]; + $this->containers = $this->containers->push($payload); + } } + } elseif (data_get($this->parameters, 'database_uuid')) { + if ($this->resource->isRunning()) { + $this->containers = $this->containers->push([ + 'server' => $server, + 'container' => [ + 'Names' => $this->resource->uuid, + ], + ]); + } + } elseif (data_get($this->parameters, 'service_uuid')) { + $this->resource->applications()->get()->each(function ($application) { + if ($application->isRunning()) { + $this->containers->push([ + 'server' => $this->resource->server, + 'container' => [ + 'Names' => data_get($application, 'name').'-'.data_get($this->resource, 'uuid'), + ], + ]); + } + }); + $this->resource->databases()->get()->each(function ($database) { + if ($database->isRunning()) { + $this->containers->push([ + 'server' => $this->resource->server, + 'container' => [ + 'Names' => data_get($database, 'name').'-'.data_get($this->resource, 'uuid'), + ], + ]); + } + }); } + } if ($this->containers->count() > 0) { - if (data_get($this->parameters, 'application_uuid')) { - $this->container = data_get($this->containers->first(), 'container.Names'); - } elseif (data_get($this->parameters, 'database_uuid')) { - $this->container = $this->containers->first(); - } elseif (data_get($this->parameters, 'service_uuid')) { - $this->container = $this->containers->first(); - } + $this->container = $this->containers->first(); } } - public function runCommand() + #[On('connectToContainer')] + public function connectToContainer() { + if ($this->selected_container === 'default') { + $this->dispatch('error', 'Please select a container.'); + + return; + } try { - if (data_get($this->parameters, 'application_uuid')) { - $container = $this->containers->where('container.Names', $this->container)->first(); - $container_name = data_get($container, 'container.Names'); - if (is_null($container)) { - throw new \RuntimeException('Container not found.'); - } - $server = data_get($container, 'server'); - } else { - $container_name = $this->container; - $server = $this->servers->first(); + $container = collect($this->containers)->firstWhere('container.Names', $this->selected_container); + if (is_null($container)) { + throw new \RuntimeException('Container not found.'); } + $server = data_get($this->container, 'server'); + if ($server->isForceDisabled()) { throw new \RuntimeException('Server is disabled.'); } - $cmd = "sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; ".str_replace("'", "'\''", $this->command)."'"; - if (! empty($this->workDir)) { - $exec = "docker exec -w {$this->workDir} {$container_name} {$cmd}"; - } else { - $exec = "docker exec {$container_name} {$cmd}"; - } - $activity = RunCommand::run(server: $server, command: $exec); - $this->dispatch('activityMonitor', $activity->id); + $this->dispatch( + 'send-terminal-command', + isset($container), + data_get($container, 'container.Names'), + data_get($container, 'server.uuid') + ); + } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index deccc875c..0e140b8c1 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared; +use App\Helpers\SshMultiplexingHelper; use App\Models\Application; use App\Models\Server; use App\Models\Service; @@ -108,14 +109,14 @@ class GetLogs extends Component $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = generateSshCommand($this->server, $command); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = generateSshCommand($this->server, $command); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } else { if ($this->server->isSwarm()) { @@ -124,14 +125,14 @@ class GetLogs extends Component $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = generateSshCommand($this->server, $command); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = generateSshCommand($this->server, $command); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } if ($refresh) { diff --git a/app/Livewire/Project/Shared/Metrics.php b/app/Livewire/Project/Shared/Metrics.php index d9d7dd3ef..fdc35fc0f 100644 --- a/app/Livewire/Project/Shared/Metrics.php +++ b/app/Livewire/Project/Shared/Metrics.php @@ -31,13 +31,8 @@ class Metrics extends Component public function loadData() { try { - $metrics = $this->resource->getMetrics($this->interval); - $cpuMetrics = collect($metrics)->map(function ($metric) { - return [$metric[0], $metric[1]]; - }); - $memoryMetrics = collect($metrics)->map(function ($metric) { - return [$metric[0], $metric[2]]; - }); + $cpuMetrics = $this->resource->getCpuMetrics($this->interval); + $memoryMetrics = $this->resource->getMemoryMetrics($this->interval); $this->dispatch("refreshChartData-{$this->chartId}-cpu", [ 'seriesData' => $cpuMetrics, ]); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php index 5bd6b4b9b..017cc9fd7 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php @@ -7,7 +7,9 @@ use Livewire\Component; class Executions extends Component { public $executions = []; + public $selectedKey; + public $task; public function getListeners() @@ -29,7 +31,7 @@ class Executions extends Component public function server() { - if (!$this->task) { + if (! $this->task) { return null; } @@ -42,16 +44,18 @@ class Executions extends Component return $this->task->service->destination->server; } } + return null; } public function getServerTimezone() { $server = $this->server(); - if (!$server) { + if (! $server) { return 'UTC'; } $serverTimezone = $server->settings->server_timezone; + return $serverTimezone; } @@ -64,6 +68,7 @@ class Executions extends Component } catch (\Exception $e) { $dateObj->setTimezone(new \DateTimeZone('UTC')); } + return $dateObj->format('Y-m-d H:i:s T'); } } diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 8be4ff643..37f50dd32 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -20,6 +20,8 @@ class Show extends Component public string $type; + public string $scheduledTaskName; + protected $rules = [ 'task.enabled' => 'required|boolean', 'task.name' => 'required|string', @@ -49,6 +51,7 @@ class Show extends Component $this->modalId = new Cuid2; $this->task = ModelsScheduledTask::where('uuid', request()->route('task_uuid'))->first(); + $this->scheduledTaskName = $this->task->name; } public function instantSave() @@ -75,9 +78,9 @@ class Show extends Component $this->task->delete(); if ($this->type == 'application') { - return redirect()->route('project.application.configuration', $this->parameters); + return redirect()->route('project.application.configuration', $this->parameters, $this->scheduledTaskName); } else { - return redirect()->route('project.service.configuration', $this->parameters); + return redirect()->route('project.service.configuration', $this->parameters, $this->scheduledTaskName); } } catch (\Exception $e) { return handleError($e); diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 08f51ce08..e4b5c9b89 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -3,6 +3,8 @@ namespace App\Livewire\Project\Shared\Storages; use App\Models\LocalPersistentVolume; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class Show extends Component @@ -36,8 +38,14 @@ class Show extends Component $this->dispatch('success', 'Storage updated successfully'); } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + $this->storage->delete(); $this->dispatch('refreshStorages'); } diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php new file mode 100644 index 000000000..916db650f --- /dev/null +++ b/app/Livewire/Project/Shared/Terminal.php @@ -0,0 +1,58 @@ +user()->currentTeam()->id; + + return [ + "echo-private:team.{$teamId},ApplicationStatusChanged" => 'closeTerminal', + ]; + } + + public function closeTerminal() + { + $this->dispatch('reloadWindow'); + } + + #[On('send-terminal-command')] + public function sendTerminalCommand($isContainer, $identifier, $serverUuid) + { + + $server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->firstOrFail(); + + if ($isContainer) { + $status = getContainerStatus($server, $identifier); + if ($status !== 'running') { + return; + } + $command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$identifier} sh -c 'PATH=\$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && if [ -f ~/.profile ]; then . ~/.profile; fi && if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); + } else { + $command = SshMultiplexingHelper::generateSshCommand($server, 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && if [ -f ~/.profile ]; then . ~/.profile; fi && if [ -n "$SHELL" ]; then exec $SHELL; else sh; fi'); + } + + // ssh command is sent back to frontend then to websocket + // this is done because the websocket connection is not available here + // a better solution would be to remove websocket on NodeJS and work with something like + // 1. Laravel Pusher/Echo connection (not possible without a sdk) + // 2. Ratchet / Revolt / ReactPHP / Event Loop (possible but hard to implement and huge dependencies) + // 3. Just found out about this https://github.com/sirn-se/websocket-php, perhaps it can be used + // 4. Follow-up discussions here: + // - https://github.com/coollabsio/coolify/issues/2298 + // - https://github.com/coollabsio/coolify/discussions/3362 + $this->dispatch('send-back-command', $command); + } + + public function render() + { + return view('livewire.project.shared.terminal'); + } +} diff --git a/app/Livewire/Project/Shared/UploadConfig.php b/app/Livewire/Project/Shared/UploadConfig.php new file mode 100644 index 000000000..dea842651 --- /dev/null +++ b/app/Livewire/Project/Shared/UploadConfig.php @@ -0,0 +1,41 @@ +config = '{ + "build_pack": "nixpacks", + "base_directory": "/nodejs", + "publish_directory": "/", + "ports_exposes": "3000", + "settings": { + "is_static": false + } +}'; + } + } + public function uploadConfig() + { + try { + $application = Application::findOrFail($this->applicationId); + $application->setConfig($this->config); + $this->dispatch('success', 'Application settings updated'); + } catch (\Exception $e) { + $this->dispatch('error', $e->getMessage()); + return; + } + + } + public function render() + { + return view('livewire.project.shared.upload-config'); + } +} diff --git a/app/Livewire/RunCommand.php b/app/Livewire/RunCommand.php deleted file mode 100644 index c2d3adeea..000000000 --- a/app/Livewire/RunCommand.php +++ /dev/null @@ -1,43 +0,0 @@ - 'required', - 'command' => 'required', - ]; - - protected $validationAttributes = [ - 'server' => 'server', - 'command' => 'command', - ]; - - public function mount($servers) - { - $this->servers = $servers; - $this->server = $servers[0]->uuid; - } - - public function runCommand() - { - $this->validate(); - try { - $activity = ServerRunCommand::run(server: Server::where('uuid', $this->server)->first(), command: $this->command); - $this->dispatch('activityMonitor', $activity->id); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } -} diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index ff8679d21..fe68a8ba5 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -2,6 +2,7 @@ namespace App\Livewire\Security; +use App\Models\InstanceSettings; use Livewire\Component; class ApiTokens extends Component @@ -14,8 +15,12 @@ class ApiTokens extends Component public bool $readOnly = true; + public bool $rootAccess = false; + public array $permissions = ['read-only']; + public $isApiEnabled; + public function render() { return view('livewire.security.api-tokens'); @@ -23,6 +28,7 @@ class ApiTokens extends Component public function mount() { + $this->isApiEnabled = InstanceSettings::get()->is_api_enabled; $this->tokens = auth()->user()->tokens->sortByDesc('created_at'); } @@ -31,12 +37,11 @@ class ApiTokens extends Component if ($this->viewSensitiveData) { $this->permissions[] = 'view:sensitive'; $this->permissions = array_diff($this->permissions, ['*']); + $this->rootAccess = false; } else { $this->permissions = array_diff($this->permissions, ['view:sensitive']); } - if (count($this->permissions) == 0) { - $this->permissions = ['*']; - } + $this->makeSureOneIsSelected(); } public function updatedReadOnly() @@ -44,11 +49,30 @@ class ApiTokens extends Component if ($this->readOnly) { $this->permissions[] = 'read-only'; $this->permissions = array_diff($this->permissions, ['*']); + $this->rootAccess = false; } else { $this->permissions = array_diff($this->permissions, ['read-only']); } - if (count($this->permissions) == 0) { + $this->makeSureOneIsSelected(); + } + + public function updatedRootAccess() + { + if ($this->rootAccess) { $this->permissions = ['*']; + $this->readOnly = false; + $this->viewSensitiveData = false; + } else { + $this->readOnly = true; + $this->permissions = ['read-only']; + } + } + + public function makeSureOneIsSelected() + { + if (count($this->permissions) == 0) { + $this->permissions = ['read-only']; + $this->readOnly = true; } } @@ -58,12 +82,6 @@ class ApiTokens extends Component $this->validate([ 'description' => 'required|min:3|max:255', ]); - // if ($this->viewSensitiveData) { - // $this->permissions[] = 'view:sensitive'; - // } - // if ($this->readOnly) { - // $this->permissions[] = 'read-only'; - // } $token = auth()->user()->createToken($this->description, $this->permissions); $this->tokens = auth()->user()->tokens; session()->flash('token', $token->plainTextToken); diff --git a/app/Livewire/Security/PrivateKey/Create.php b/app/Livewire/Security/PrivateKey/Create.php index 32a67bbea..319cec192 100644 --- a/app/Livewire/Security/PrivateKey/Create.php +++ b/app/Livewire/Security/PrivateKey/Create.php @@ -3,17 +3,13 @@ namespace App\Livewire\Security\PrivateKey; use App\Models\PrivateKey; -use DanHarrin\LivewireRateLimiting\WithRateLimiting; use Livewire\Component; -use phpseclib3\Crypt\PublicKeyLoader; class Create extends Component { - use WithRateLimiting; + public string $name = ''; - public string $name; - - public string $value; + public string $value = ''; public ?string $from = null; @@ -26,72 +22,69 @@ class Create extends Component 'value' => 'required|string', ]; - protected $validationAttributes = [ - 'name' => 'name', - 'value' => 'private Key', - ]; - public function generateNewRSAKey() { - try { - $this->rateLimit(10); - $this->name = generate_random_name(); - $this->description = 'Created by Coolify'; - ['private' => $this->value, 'public' => $this->publicKey] = generateSSHKey(); - } catch (\Throwable $e) { - return handleError($e, $this); - } + $this->generateNewKey('rsa'); } public function generateNewEDKey() { - try { - $this->rateLimit(10); - $this->name = generate_random_name(); - $this->description = 'Created by Coolify'; - ['private' => $this->value, 'public' => $this->publicKey] = generateSSHKey('ed25519'); - } catch (\Throwable $e) { - return handleError($e, $this); - } + $this->generateNewKey('ed25519'); } - public function updated($updateProperty) + private function generateNewKey($type) { - if ($updateProperty === 'value') { - try { - $this->publicKey = PublicKeyLoader::load($this->$updateProperty)->getPublicKey()->toString('OpenSSH', ['comment' => '']); - } catch (\Throwable $e) { - if ($this->$updateProperty === '') { - $this->publicKey = ''; - } else { - $this->publicKey = 'Invalid private key'; - } - } + $keyData = PrivateKey::generateNewKeyPair($type); + $this->setKeyData($keyData); + } + + public function updated($property) + { + if ($property === 'value') { + $this->validatePrivateKey(); } - $this->validateOnly($updateProperty); } public function createPrivateKey() { $this->validate(); + try { - $this->value = trim($this->value); - if (! str_ends_with($this->value, "\n")) { - $this->value .= "\n"; - } - $private_key = PrivateKey::create([ + $privateKey = PrivateKey::createAndStore([ 'name' => $this->name, 'description' => $this->description, - 'private_key' => $this->value, + 'private_key' => trim($this->value)."\n", 'team_id' => currentTeam()->id, ]); - if ($this->from === 'server') { - return redirect()->route('dashboard'); - } - return redirect()->route('security.private-key.show', ['private_key_uuid' => $private_key->uuid]); + return $this->redirectAfterCreation($privateKey); } catch (\Throwable $e) { return handleError($e, $this); } } + + private function setKeyData(array $keyData) + { + $this->name = $keyData['name']; + $this->description = $keyData['description']; + $this->value = $keyData['private_key']; + $this->publicKey = $keyData['public_key']; + } + + private function validatePrivateKey() + { + $validationResult = PrivateKey::validateAndExtractPublicKey($this->value); + $this->publicKey = $validationResult['publicKey']; + + if (! $validationResult['isValid']) { + $this->addError('value', 'Invalid private key'); + } + } + + private function redirectAfterCreation(PrivateKey $privateKey) + { + return $this->from === 'server' + ? redirect()->route('dashboard') + : redirect()->route('security.private-key.show', ['private_key_uuid' => $privateKey->uuid]); + } } diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php new file mode 100644 index 000000000..76441a67e --- /dev/null +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -0,0 +1,24 @@ +get(); + + return view('livewire.security.private-key.index', [ + 'privateKeys' => $privateKeys, + ])->layout('components.layout'); + } + + public function cleanupUnusedKeys() + { + PrivateKey::cleanupUnusedKeys(); + $this->dispatch('success', 'Unused keys have been cleaned up.'); + } +} diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index d86bd5d1e..249c84f14 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -29,25 +29,27 @@ class Show extends Component try { $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related'])->whereUuid(request()->private_key_uuid)->firstOrFail(); } catch (\Throwable $e) { - return handleError($e, $this); + abort(404); } } public function loadPublicKey() { - $this->public_key = $this->private_key->publicKey(); + $this->public_key = $this->private_key->getPublicKey(); + if ($this->public_key === 'Error loading private key') { + $this->dispatch('error', 'Failed to load public key. The private key may be invalid.'); + } } public function delete() { try { - if ($this->private_key->isEmpty()) { - $this->private_key->delete(); - currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); + $this->private_key->safeDelete(); + currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); - return redirect()->route('security.private-key.index'); - } - $this->dispatch('error', 'This private key is in use and cannot be deleted. Please delete all servers, applications, and GitHub/GitLab apps that use this private key before deleting it.'); + return redirect()->route('security.private-key.index'); + } catch (\Exception $e) { + $this->dispatch('error', $e->getMessage()); } catch (\Throwable $e) { return handleError($e, $this); } @@ -56,8 +58,9 @@ class Show extends Component public function changePrivateKey() { try { - $this->private_key->private_key = formatPrivateKey($this->private_key->private_key); - $this->private_key->save(); + $this->private_key->updatePrivateKey([ + 'private_key' => formatPrivateKey($this->private_key->private_key), + ]); refresh_server_connection($this->private_key); $this->dispatch('success', 'Private key updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php new file mode 100644 index 000000000..b8003803a --- /dev/null +++ b/app/Livewire/Server/Advanced.php @@ -0,0 +1,77 @@ + 'required|integer|min:1', + 'server.settings.dynamic_timeout' => 'required|integer|min:1', + 'server.settings.force_docker_cleanup' => 'required|boolean', + 'server.settings.docker_cleanup_frequency' => 'required_if:server.settings.force_docker_cleanup,true|string', + 'server.settings.docker_cleanup_threshold' => 'required_if:server.settings.force_docker_cleanup,false|integer|min:1|max:100', + 'server.settings.delete_unused_volumes' => 'boolean', + 'server.settings.delete_unused_networks' => 'boolean', + ]; + + protected $validationAttributes = [ + + 'server.settings.concurrent_builds' => 'Concurrent Builds', + 'server.settings.dynamic_timeout' => 'Dynamic Timeout', + 'server.settings.force_docker_cleanup' => 'Force Docker Cleanup', + 'server.settings.docker_cleanup_frequency' => 'Docker Cleanup Frequency', + 'server.settings.docker_cleanup_threshold' => 'Docker Cleanup Threshold', + 'server.settings.delete_unused_volumes' => 'Delete Unused Volumes', + 'server.settings.delete_unused_networks' => 'Delete Unused Networks', + ]; + + public function instantSave() + { + try { + $this->validate(); + $this->server->settings->save(); + $this->dispatch('success', 'Server updated.'); + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + $this->server->settings->refresh(); + + return handleError($e, $this); + } + } + + public function manualCleanup() + { + try { + DockerCleanupJob::dispatch($this->server, true); + $this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function submit() + { + try { + $frequency = $this->server->settings->docker_cleanup_frequency; + if (empty($frequency) || ! validate_cron_expression($frequency)) { + $this->server->settings->docker_cleanup_frequency = '*/10 * * * *'; + throw new \Exception('Invalid Cron / Human expression for Docker Cleanup Frequency. Resetting to default 10 minutes.'); + } + $this->server->settings->save(); + $this->dispatch('success', 'Server updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.advanced'); + } +} diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index 0921c7fa4..09b31c0b0 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -34,12 +34,12 @@ class Charts extends Component try { $cpuMetrics = $this->server->getCpuMetrics($this->interval); $memoryMetrics = $this->server->getMemoryMetrics($this->interval); - $cpuMetrics = collect($cpuMetrics)->map(function ($metric) { - return [$metric[0], $metric[1]]; - }); - $memoryMetrics = collect($memoryMetrics)->map(function ($metric) { - return [$metric[0], $metric[1]]; - }); + // $cpuMetrics = collect($cpuMetrics)->map(function ($metric) { + // return [$metric[0], $metric[1]]; + // }); + // $memoryMetrics = collect($memoryMetrics)->map(function ($metric) { + // return [$metric[0], $metric[1]]; + // }); $this->dispatch("refreshChartData-{$this->chartId}-cpu", [ 'seriesData' => $cpuMetrics, ]); diff --git a/app/Livewire/Server/CloudflareTunnels.php b/app/Livewire/Server/CloudflareTunnels.php new file mode 100644 index 000000000..5b0f43329 --- /dev/null +++ b/app/Livewire/Server/CloudflareTunnels.php @@ -0,0 +1,45 @@ + 'required|boolean', + ]; + + protected $validationAttributes = [ + 'server.settings.is_cloudflare_tunnel' => 'Cloudflare Tunnel', + ]; + + public function instantSave() + { + try { + $this->validate(); + $this->server->settings->save(); + $this->dispatch('success', 'Server updated.'); + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + + public function manualCloudflareConfig() + { + $this->server->settings->is_cloudflare_tunnel = true; + $this->server->settings->save(); + $this->server->refresh(); + $this->dispatch('success', 'Cloudflare Tunnels enabled.'); + } + + public function render() + { + return view('livewire.server.cloudflare-tunnels'); + } +} diff --git a/app/Livewire/Server/ConfigureCloudflareTunnels.php b/app/Livewire/Server/ConfigureCloudflareTunnels.php index f7306a5b5..f58d7b6be 100644 --- a/app/Livewire/Server/ConfigureCloudflareTunnels.php +++ b/app/Livewire/Server/ConfigureCloudflareTunnels.php @@ -30,14 +30,18 @@ class ConfigureCloudflareTunnels extends Component public function submit() { try { + if (str($this->ssh_domain)->contains('https://')) { + $this->ssh_domain = str($this->ssh_domain)->replace('https://', '')->replace('http://', '')->trim(); + // remove / from the end + $this->ssh_domain = str($this->ssh_domain)->replace('/', ''); + } $server = Server::ownedByCurrentTeam()->where('id', $this->server_id)->firstOrFail(); - ConfigureCloudflared::run($server, $this->cloudflare_token); + ConfigureCloudflared::dispatch($server, $this->cloudflare_token); $server->settings->is_cloudflare_tunnel = true; $server->ip = $this->ssh_domain; $server->save(); $server->settings->save(); - $this->dispatch('success', 'Cloudflare Tunnels configured successfully.'); - $this->dispatch('refreshServerShow'); + $this->dispatch('warning', 'Cloudflare Tunnels configuration started.'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index 3beec0c91..0c1fa2745 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -2,7 +2,10 @@ namespace App\Livewire\Server; +use App\Actions\Server\DeleteServer; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class Delete extends Component @@ -11,8 +14,13 @@ class Delete extends Component public $server; - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } try { $this->authorize('delete', $this->server); if ($this->server->hasDefinedResources()) { @@ -21,7 +29,7 @@ class Delete extends Component return; } $this->server->delete(); - + DeleteServer::dispatch($this->server); return redirect()->route('server.index'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/Form.php b/app/Livewire/Server/Form.php index 3b3747a81..a2f04074a 100644 --- a/app/Livewire/Server/Form.php +++ b/app/Livewire/Server/Form.php @@ -4,7 +4,6 @@ namespace App\Livewire\Server; use App\Actions\Server\StartSentinel; use App\Actions\Server\StopSentinel; -use App\Jobs\PullSentinelImageJob; use App\Models\Server; use Livewire\Component; @@ -24,11 +23,20 @@ class Form extends Component public $timezones; - protected $listeners = [ - 'serverInstalled', - 'refreshServerShow' => 'serverInstalled', - 'revalidate' => '$refresh', - ]; + public $delete_unused_volumes = false; + + public $delete_unused_networks = false; + + public function getListeners() + { + $teamId = auth()->user()->currentTeam()->id; + + return [ + "echo-private:team.{$teamId},CloudflareTunnelConfigured" => 'cloudflareTunnelConfigured', + 'refreshServerShow' => 'serverInstalled', + 'revalidate' => '$refresh', + ]; + } protected $rules = [ 'server.name' => 'required', @@ -36,23 +44,19 @@ class Form extends Component 'server.ip' => 'required', 'server.user' => 'required', 'server.port' => 'required', - 'server.settings.is_cloudflare_tunnel' => 'required|boolean', + 'wildcard_domain' => 'nullable|url', 'server.settings.is_reachable' => 'required', 'server.settings.is_swarm_manager' => 'required|boolean', 'server.settings.is_swarm_worker' => 'required|boolean', 'server.settings.is_build_server' => 'required|boolean', - 'server.settings.concurrent_builds' => 'required|integer|min:1', - 'server.settings.dynamic_timeout' => 'required|integer|min:1', 'server.settings.is_metrics_enabled' => 'required|boolean', - 'server.settings.metrics_token' => 'required', - 'server.settings.metrics_refresh_rate_seconds' => 'required|integer|min:1', - 'server.settings.metrics_history_days' => 'required|integer|min:1', - 'wildcard_domain' => 'nullable|url', - 'server.settings.is_server_api_enabled' => 'required|boolean', + 'server.settings.sentinel_token' => 'required', + 'server.settings.sentinel_metrics_refresh_rate_seconds' => 'required|integer|min:1', + 'server.settings.sentinel_metrics_history_days' => 'required|integer|min:1', + 'server.settings.sentinel_push_interval_seconds' => 'required|integer|min:10', + 'server.settings.sentinel_custom_url' => 'nullable|url', + 'server.settings.is_sentinel_enabled' => 'required|boolean', 'server.settings.server_timezone' => 'required|string|timezone', - 'server.settings.force_docker_cleanup' => 'required|boolean', - 'server.settings.docker_cleanup_frequency' => 'required_if:server.settings.force_docker_cleanup,true|string', - 'server.settings.docker_cleanup_threshold' => 'required_if:server.settings.force_docker_cleanup,false|integer|min:1|max:100', ]; protected $validationAttributes = [ @@ -61,18 +65,17 @@ class Form extends Component 'server.ip' => 'IP address/Domain', 'server.user' => 'User', 'server.port' => 'Port', - 'server.settings.is_cloudflare_tunnel' => 'Cloudflare Tunnel', 'server.settings.is_reachable' => 'Is reachable', 'server.settings.is_swarm_manager' => 'Swarm Manager', 'server.settings.is_swarm_worker' => 'Swarm Worker', 'server.settings.is_build_server' => 'Build Server', - 'server.settings.concurrent_builds' => 'Concurrent Builds', - 'server.settings.dynamic_timeout' => 'Dynamic Timeout', 'server.settings.is_metrics_enabled' => 'Metrics', - 'server.settings.metrics_token' => 'Metrics Token', - 'server.settings.metrics_refresh_rate_seconds' => 'Metrics Interval', - 'server.settings.metrics_history_days' => 'Metrics History', - 'server.settings.is_server_api_enabled' => 'Server API', + 'server.settings.sentinel_token' => 'Metrics Token', + 'server.settings.sentinel_metrics_refresh_rate_seconds' => 'Metrics Interval', + 'server.settings.sentinel_metrics_history_days' => 'Metrics History', + 'server.settings.sentinel_push_interval_seconds' => 'Push Interval', + 'server.settings.is_sentinel_enabled' => 'Server API', + 'server.settings.sentinel_custom_url' => 'Coolify URL', 'server.settings.server_timezone' => 'Server Timezone', ]; @@ -81,8 +84,24 @@ class Form extends Component $this->server = $server; $this->timezones = collect(timezone_identifiers_list())->sort()->values()->toArray(); $this->wildcard_domain = $this->server->settings->wildcard_domain; - $this->server->settings->docker_cleanup_threshold = $this->server->settings->docker_cleanup_threshold; - $this->server->settings->docker_cleanup_frequency = $this->server->settings->docker_cleanup_frequency; + } + + public function checkSyncStatus() + { + $this->server->refresh(); + $this->server->settings->refresh(); + } + + public function regenerateSentinelToken() + { + try { + $this->server->settings->generateSentinelToken(); + $this->server->settings->refresh(); + $this->restartSentinel(notification: false); + $this->dispatch('success', 'Token regenerated & Sentinel restarted.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function updated($field) @@ -96,6 +115,12 @@ class Form extends Component } } + public function cloudflareTunnelConfigured() + { + $this->serverInstalled(); + $this->dispatch('success', 'Cloudflare Tunnels configured successfully.'); + } + public function serverInstalled() { $this->server->refresh(); @@ -109,53 +134,63 @@ class Form extends Component $this->dispatch('proxyStatusUpdated'); } - public function checkPortForServerApi() + public function updatedServerSettingsIsSentinelEnabled($value) { - try { - if ($this->server->settings->is_server_api_enabled === true) { - $this->server->checkServerApi(); - $this->dispatch('success', 'Server API is reachable.'); + $this->validate(); + $this->validate([ + 'server.settings.sentinel_custom_url' => 'required|url', + ]); + if ($value === false) { + StopSentinel::dispatch($this->server); + $this->server->settings->is_metrics_enabled = false; + $this->server->settings->save(); + $this->server->sentinelHeartbeat(isReset: true); + } else { + try { + StartSentinel::run($this->server); + } catch (\Throwable $e) { + return handleError($e, $this); } - } catch (\Throwable $e) { - return handleError($e, $this); } } + public function updatedServerSettingsIsMetricsEnabled() + { + $this->restartSentinel(); + } + public function instantSave() { try { + $this->validate(); refresh_server_connection($this->server->privateKey); $this->validateServer(false); + $this->server->settings->save(); $this->server->save(); $this->dispatch('success', 'Server updated.'); $this->dispatch('refreshServerShow'); - if ($this->server->isSentinelEnabled()) { - PullSentinelImageJob::dispatchSync($this->server); - ray('Sentinel is enabled'); - if ($this->server->settings->isDirty('is_metrics_enabled')) { - $this->dispatch('reloadWindow'); - } - if ($this->server->settings->isDirty('is_server_api_enabled') && $this->server->settings->is_server_api_enabled === true) { - ray('Starting sentinel'); - } - } else { - ray('Sentinel is not enabled'); - StopSentinel::dispatch($this->server); - } - // $this->checkPortForServerApi(); + $this->server->settings->save(); } catch (\Throwable $e) { + $this->server->settings->refresh(); + return handleError($e, $this); } } - public function restartSentinel() + public function restartSentinel($notification = true) { try { + $this->validate(); + $this->validate([ + 'server.settings.sentinel_custom_url' => 'required|url', + ]); $version = get_latest_sentinel_version(); StartSentinel::run($this->server, $version, true); - $this->dispatch('success', 'Sentinel restarted.'); + if ($notification) { + $this->dispatch('success', 'Sentinel started.'); + } } catch (\Throwable $e) { return handleError($e, $this); } @@ -212,30 +247,22 @@ class Form extends Component } refresh_server_connection($this->server->privateKey); $this->server->settings->wildcard_domain = $this->wildcard_domain; - if ($this->server->settings->force_docker_cleanup) { - $this->server->settings->docker_cleanup_frequency = $this->server->settings->docker_cleanup_frequency; - } else { - $this->server->settings->docker_cleanup_threshold = $this->server->settings->docker_cleanup_threshold; - } + // if ($this->server->settings->force_docker_cleanup) { + // $this->server->settings->docker_cleanup_frequency = $this->server->settings->docker_cleanup_frequency; + // } else { + // $this->server->settings->docker_cleanup_threshold = $this->server->settings->docker_cleanup_threshold; + // } $currentTimezone = $this->server->settings->getOriginal('server_timezone'); $newTimezone = $this->server->settings->server_timezone; if ($currentTimezone !== $newTimezone || $currentTimezone === '') { $this->server->settings->server_timezone = $newTimezone; - $this->server->settings->save(); } - $this->server->settings->save(); $this->server->save(); + $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { return handleError($e, $this); } } - - public function updatedServerSettingsServerTimezone($value) - { - $this->server->settings->server_timezone = $value; - $this->server->settings->save(); - $this->dispatch('success', 'Server timezone updated.'); - } } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 123b29d70..55d0c4966 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -39,6 +39,7 @@ class Proxy extends Component { $this->server->proxy = null; $this->server->save(); + $this->dispatch('proxyChanged'); } public function selectProxy($proxy_type) @@ -47,7 +48,7 @@ class Proxy extends Component $this->server->proxy->set('type', $proxy_type); $this->server->save(); $this->selectedProxy = $this->server->proxy->type; - if ($this->selectedProxy !== 'NONE') { + if ($this->server->proxySet()) { StartProxy::run($this->server, false); } $this->dispatch('proxyStatusUpdated'); diff --git a/app/Livewire/Server/Proxy/Deploy.php b/app/Livewire/Server/Proxy/Deploy.php index 2279951ee..eaa312663 100644 --- a/app/Livewire/Server/Proxy/Deploy.php +++ b/app/Livewire/Server/Proxy/Deploy.php @@ -6,6 +6,8 @@ use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; use App\Events\ProxyStatusChanged; use App\Models\Server; +use Illuminate\Process\InvokedProcess; +use Illuminate\Support\Facades\Process; use Livewire\Component; class Deploy extends Component @@ -29,6 +31,7 @@ class Deploy extends Component 'serverRefresh' => 'proxyStatusUpdated', 'checkProxy', 'startProxy', + 'proxyChanged' => 'proxyStatusUpdated', ]; } @@ -94,21 +97,43 @@ class Deploy extends Component public function stop(bool $forceStop = true) { try { - if ($this->server->isSwarm()) { - instant_remote_process([ - 'docker service rm coolify-proxy_traefik', - ], $this->server); - } else { - instant_remote_process([ - 'docker rm -f coolify-proxy', - ], $this->server); + $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; + $timeout = 30; + + $process = $this->stopContainer($containerName, $timeout); + + $startTime = time(); + while ($process->running()) { + if (time() - $startTime >= $timeout) { + $this->forceStopContainer($containerName); + break; + } + usleep(100000); } - $this->server->proxy->status = 'exited'; - $this->server->proxy->force_stop = $forceStop; - $this->server->save(); - $this->dispatch('proxyStatusUpdated'); + + $this->removeContainer($containerName); } catch (\Throwable $e) { return handleError($e, $this); + } finally { + $this->server->proxy->force_stop = $forceStop; + $this->server->proxy->status = 'exited'; + $this->server->save(); + $this->dispatch('proxyStatusUpdated'); } } + + private function stopContainer(string $containerName, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + private function forceStopContainer(string $containerName) + { + instant_remote_process(["docker kill $containerName"], $this->server, throwError: false); + } + + private function removeContainer(string $containerName) + { + instant_remote_process(["docker rm -f $containerName"], $this->server, throwError: false); + } } diff --git a/app/Livewire/Server/Proxy/Modal.php b/app/Livewire/Server/Proxy/Modal.php deleted file mode 100644 index 5679944d0..000000000 --- a/app/Livewire/Server/Proxy/Modal.php +++ /dev/null @@ -1,16 +0,0 @@ -dispatch('proxyStatusUpdated'); - } -} diff --git a/app/Livewire/Server/Proxy/Show.php b/app/Livewire/Server/Proxy/Show.php index cef909a45..d70e44e55 100644 --- a/app/Livewire/Server/Proxy/Show.php +++ b/app/Livewire/Server/Proxy/Show.php @@ -11,7 +11,7 @@ class Show extends Component public $parameters = []; - protected $listeners = ['proxyStatusUpdated']; + protected $listeners = ['proxyStatusUpdated', 'proxyChanged' => 'proxyStatusUpdated']; public function proxyStatusUpdated() { diff --git a/app/Livewire/Server/Proxy/Status.php b/app/Livewire/Server/Proxy/Status.php index d23d7fc20..f4f18381f 100644 --- a/app/Livewire/Server/Proxy/Status.php +++ b/app/Livewire/Server/Proxy/Status.php @@ -4,7 +4,7 @@ namespace App\Livewire\Server\Proxy; use App\Actions\Docker\GetContainersStatus; use App\Actions\Proxy\CheckProxy; -use App\Jobs\ContainerStatusJob; +use App\Actions\Proxy\StartProxy; use App\Models\Server; use Livewire\Component; @@ -44,11 +44,18 @@ class Status extends Component } $this->numberOfPolls++; } - CheckProxy::run($this->server, true); + $shouldStart = CheckProxy::run($this->server, true); + if ($shouldStart) { + StartProxy::run($this->server, false); + } $this->dispatch('proxyStatusUpdated'); if ($this->server->proxy->status === 'running') { $this->polling = false; $notification && $this->dispatch('success', 'Proxy is running.'); + } elseif ($this->server->proxy->status === 'exited' and ! $this->server->proxy->force_stop) { + $notification && $this->dispatch('error', 'Proxy has exited.'); + } elseif ($this->server->proxy->force_stop) { + $notification && $this->dispatch('error', 'Proxy is stopped manually.'); } else { $notification && $this->dispatch('error', 'Proxy is not running.'); } diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index 800344ac3..f549b43cb 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -15,7 +15,9 @@ class Resources extends Component public $parameters = []; - public Collection $unmanagedContainers; + public Collection $containers; + + public $activeTab = 'managed'; public function getListeners() { @@ -50,14 +52,29 @@ class Resources extends Component public function refreshStatus() { $this->server->refresh(); - $this->loadUnmanagedContainers(); + if ($this->activeTab === 'managed') { + $this->loadManagedContainers(); + } else { + $this->loadUnmanagedContainers(); + } $this->dispatch('success', 'Resource statuses refreshed.'); } + public function loadManagedContainers() + { + try { + $this->activeTab = 'managed'; + $this->containers = $this->server->refresh()->definedResources(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function loadUnmanagedContainers() { + $this->activeTab = 'unmanaged'; try { - $this->unmanagedContainers = $this->server->loadUnmanagedContainers(); + $this->containers = $this->server->loadUnmanagedContainers(); } catch (\Throwable $e) { return handleError($e, $this); } @@ -65,13 +82,14 @@ class Resources extends Component public function mount() { - $this->unmanagedContainers = collect(); + $this->containers = collect(); $this->parameters = get_route_parameters(); try { $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->first(); if (is_null($this->server)) { return redirect()->route('server.index'); } + $this->loadManagedContainers(); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/ShowPrivateKey.php b/app/Livewire/Server/ShowPrivateKey.php index 578a08967..1be22882d 100644 --- a/app/Livewire/Server/ShowPrivateKey.php +++ b/app/Livewire/Server/ShowPrivateKey.php @@ -13,25 +13,29 @@ class ShowPrivateKey extends Component public $parameters; - public function setPrivateKey($newPrivateKeyId) + public function mount() { - try { - $oldPrivateKeyId = $this->server->private_key_id; - refresh_server_connection($this->server->privateKey); - $this->server->update([ - 'private_key_id' => $newPrivateKeyId, - ]); - $this->server->refresh(); - refresh_server_connection($this->server->privateKey); - $this->checkConnection(); - } catch (\Throwable $e) { - $this->server->update([ - 'private_key_id' => $oldPrivateKeyId, - ]); - $this->server->refresh(); - refresh_server_connection($this->server->privateKey); + $this->parameters = get_route_parameters(); + } - return handleError($e, $this); + public function setPrivateKey($privateKeyId) + { + $originalPrivateKeyId = $this->server->getOriginal('private_key_id'); + try { + $this->server->update(['private_key_id' => $privateKeyId]); + ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); + if ($uptime) { + $this->dispatch('success', 'Private key updated successfully.'); + } else { + throw new \Exception('Server is not reachable.

Check this documentation for further help.

Error: '.$error); + } + } catch (\Exception $e) { + $this->server->update(['private_key_id' => $originalPrivateKeyId]); + $this->server->validateConnection(); + $this->dispatch('error', 'Failed to update private key: '.$e->getMessage()); + } finally { + $this->dispatch('refreshServerShow'); + $this->server->refresh(); } } @@ -42,18 +46,16 @@ class ShowPrivateKey extends Component if ($uptime) { $this->dispatch('success', 'Server is reachable.'); } else { - ray($error); - $this->dispatch('error', 'Server is not reachable.
Please validate your configuration and connection.

Check this documentation for further help.'); - + $this->dispatch('error', 'Server is not reachable.

Check this documentation for further help.

Error: '.$error); return; } } catch (\Throwable $e) { return handleError($e, $this); + } finally { + $this->dispatch('refreshServerShow'); + $this->server->refresh(); } } - public function mount() - { - $this->parameters = get_route_parameters(); - } + } diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index c52970258..eb492e691 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -28,6 +28,7 @@ class Index extends Component protected string $dynamic_config_path = '/data/coolify/proxy/dynamic'; protected Server $server; + public $timezones; protected $rules = [ 'settings.fqdn' => 'nullable', @@ -53,14 +54,14 @@ class Index extends Component 'settings.is_auto_update_enabled' => 'Auto Update Enabled', 'auto_update_frequency' => 'Auto Update Frequency', 'update_check_frequency' => 'Update Check Frequency', + 'settings.instance_timezone' => 'Instance Timezone', ]; - public $timezones; public function mount() { if (isInstanceAdmin()) { - $this->settings = InstanceSettings::get(); + $this->settings = instanceSettings(); $this->do_not_track = $this->settings->do_not_track; $this->is_auto_update_enabled = $this->settings->is_auto_update_enabled; $this->is_registration_enabled = $this->settings->is_registration_enabled; @@ -162,7 +163,7 @@ class Index extends Component { CheckForUpdatesJob::dispatchSync(); $this->dispatch('updateAvailable'); - $settings = InstanceSettings::get(); + $settings = instanceSettings(); if ($settings->new_version_available) { $this->dispatch('success', 'New version available!'); } else { @@ -170,12 +171,6 @@ class Index extends Component } } - public function updatedSettingsInstanceTimezone($value) - { - $this->settings->instance_timezone = $value; - $this->settings->save(); - $this->dispatch('success', 'Instance timezone updated.'); - } public function render() { diff --git a/app/Livewire/Settings/License.php b/app/Livewire/Settings/License.php index f9402fd7b..ca0c9c1ae 100644 --- a/app/Livewire/Settings/License.php +++ b/app/Livewire/Settings/License.php @@ -29,7 +29,7 @@ class License extends Component abort(404); } $this->instance_id = config('app.id'); - $this->settings = \App\Models\InstanceSettings::get(); + $this->settings = instanceSettings(); } public function render() diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index 99b8f8d49..9240aa96d 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -42,7 +42,7 @@ class SettingsBackup extends Component public function mount() { if (isInstanceAdmin()) { - $settings = InstanceSettings::get(); + $settings = instanceSettings(); $this->database = StandalonePostgresql::whereName('coolify-db')->first(); $s3s = S3Storage::whereTeamId(0)->get() ?? []; if ($this->database) { diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 3eb8ea646..4515df9a7 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -43,7 +43,7 @@ class SettingsEmail extends Component public function mount() { if (isInstanceAdmin()) { - $this->settings = InstanceSettings::get(); + $this->settings = instanceSettings(); $this->emails = auth()->user()->email; } else { return redirect()->route('dashboard'); diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 75d7fd04a..193b650ff 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -99,7 +99,7 @@ class Change extends Component return redirect()->route('source.all'); } $this->applications = $this->github_app->applications; - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->name = str($this->github_app->name)->kebab(); diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index f85e8646e..103c5c9fb 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -23,7 +23,7 @@ class Create extends Component public function mount() { - $this->name = generate_random_name(); + $this->name = substr(generate_random_name(), 0, 34); // GitHub Apps names can only be 34 characters long } public function createGitHubApp() diff --git a/app/Livewire/Storage/Create.php b/app/Livewire/Storage/Create.php index a05834ecc..c5250e1e3 100644 --- a/app/Livewire/Storage/Create.php +++ b/app/Livewire/Storage/Create.php @@ -43,15 +43,17 @@ class Create extends Component 'endpoint' => 'Endpoint', ]; - public function mount() + public function updatedEndpoint($value) { - if (isDev()) { - $this->name = 'Local MinIO'; - $this->description = 'Local MinIO'; - $this->key = 'minioadmin'; - $this->secret = 'minioadmin'; - $this->bucket = 'local'; - $this->endpoint = 'http://coolify-minio:9000'; + if (! str($value)->startsWith('https://') && ! str($value)->startsWith('http://')) { + $this->endpoint = 'https://'.$value; + $value = $this->endpoint; + } + + if (str($value)->contains('your-objectstorage.com') && ! isset($this->bucket)) { + $this->bucket = str($value)->after('//')->before('.'); + } elseif (str($value)->contains('your-objectstorage.com')) { + $this->bucket = $this->bucket ?: str($value)->after('//')->before('.'); } } diff --git a/app/Livewire/Subscription/Index.php b/app/Livewire/Subscription/Index.php index c278bf58e..df450cf7e 100644 --- a/app/Livewire/Subscription/Index.php +++ b/app/Livewire/Subscription/Index.php @@ -23,7 +23,7 @@ class Index extends Component if (data_get(currentTeam(), 'subscription') && isSubscriptionActive()) { return redirect()->route('subscription.show'); } - $this->settings = \App\Models\InstanceSettings::get(); + $this->settings = instanceSettings(); $this->alreadySubscribed = currentTeam()->subscription()->exists(); } diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index 97d4fcdbf..3026cb297 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -4,6 +4,8 @@ namespace App\Livewire\Team; use App\Models\Team; use App\Models\User; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class AdminView extends Component @@ -73,8 +75,13 @@ class AdminView extends Component $team->delete(); } - public function delete($id) + public function delete($id, $password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } if (! auth()->user()->isInstanceAdmin()) { return $this->dispatch('error', 'You are not authorized to delete users'); } diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php new file mode 100644 index 000000000..945b25714 --- /dev/null +++ b/app/Livewire/Terminal/Index.php @@ -0,0 +1,76 @@ +user()->isAdmin()) { + abort(403); + } + $this->servers = Server::isReachable()->get(); + $this->containers = $this->getAllActiveContainers(); + } + + private function getAllActiveContainers() + { + return collect($this->servers)->flatMap(function ($server) { + if (! $server->isFunctional()) { + return []; + } + + return $server->loadAllContainers()->map(function ($container) use ($server) { + $state = data_get_str($container, 'State')->lower(); + if ($state->contains('running')) { + return [ + 'name' => data_get($container, 'Names'), + 'connection_name' => data_get($container, 'Names'), + 'uuid' => data_get($container, 'Names'), + 'status' => data_get_str($container, 'State')->lower(), + 'server' => $server, + 'server_uuid' => $server->uuid, + ]; + } + + return null; + })->filter(); + }); + } + + public function updatedSelectedUuid() + { + $this->connectToContainer(); + } + + #[On('connectToContainer')] + public function connectToContainer() + { + if ($this->selected_uuid === 'default') { + $this->dispatch('error', 'Please select a server or a container.'); + + return; + } + $container = collect($this->containers)->firstWhere('uuid', $this->selected_uuid); + $this->dispatch('send-terminal-command', + isset($container), + $container['connection_name'] ?? $this->selected_uuid, + $container['server_uuid'] ?? $this->selected_uuid + ); + } + + public function render() + { + return view('livewire.terminal.index'); + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index d0cc34a06..846d7df4c 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -6,7 +6,10 @@ use App\Enums\ApplicationDeploymentStatus; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Process\InvokedProcess; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use OpenApi\Attributes as OA; use RuntimeException; @@ -102,7 +105,7 @@ class Application extends BaseModel { use SoftDeletes; - private static $parserVersion = '3'; + private static $parserVersion = '4'; protected $guarded = []; @@ -141,6 +144,9 @@ class Application extends BaseModel } $application->tags()->detach(); $application->previews()->delete(); + foreach ($application->deployment_queue as $deployment) { + $deployment->delete(); + } }); } @@ -149,12 +155,64 @@ class Application extends BaseModel return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); } + public function getContainersToStop(bool $previewDeployments = false): array + { + $containers = $previewDeployments + ? getCurrentApplicationContainerStatus($this->destination->server, $this->id, includePullrequests: true) + : getCurrentApplicationContainerStatus($this->destination->server, $this->id, 0); + + return $containers->pluck('Names')->toArray(); + } + + public function stopContainers(array $containerNames, $server, int $timeout = 600) + { + $processes = []; + foreach ($containerNames as $containerName) { + $processes[$containerName] = $this->stopContainer($containerName, $server, $timeout); + } + + $startTime = time(); + while (count($processes) > 0) { + $finishedProcesses = array_filter($processes, function ($process) { + return ! $process->running(); + }); + foreach ($finishedProcesses as $containerName => $process) { + unset($processes[$containerName]); + $this->removeContainer($containerName, $server); + } + + if (time() - $startTime >= $timeout) { + $this->forceStopRemainingContainers(array_keys($processes), $server); + break; + } + + usleep(100000); + } + } + + public function stopContainer(string $containerName, $server, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + public function removeContainer(string $containerName, $server) + { + instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false); + } + + public function forceStopRemainingContainers(array $containerNames, $server) + { + foreach ($containerNames as $containerName) { + instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false); + $this->removeContainer($containerName, $server); + } + } + public function delete_configurations() { $server = data_get($this, 'destination.server'); $workdir = $this->workdir(); if (str($workdir)->endsWith($this->uuid)) { - ray('Deleting workdir'); instant_remote_process(['rm -rf '.$this->workdir()], $server, false); } } @@ -176,6 +234,13 @@ class Application extends BaseModel } } + public function delete_connected_networks($uuid) + { + $server = data_get($this, 'destination.server'); + instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$uuid}"], $server, false); + } + public function additional_servers() { return $this->belongsToMany(Server::class, 'additional_destinations') @@ -243,7 +308,7 @@ class Application extends BaseModel 'application_uuid' => data_get($this, 'uuid'), 'task_uuid' => $task_uuid, ]); - $settings = InstanceSettings::get(); + $settings = instanceSettings(); if (data_get($settings, 'fqdn')) { $url = Url::fromString($route); $url = $url->withPort(null); @@ -649,6 +714,11 @@ class Application extends BaseModel return $this->hasMany(ApplicationPreview::class); } + public function deployment_queue() + { + return $this->hasMany(ApplicationDeploymentQueue::class); + } + public function destination() { return $this->morphTo(); @@ -1034,6 +1104,7 @@ class Application extends BaseModel throw new \Exception($e->getMessage()); } $services = data_get($yaml, 'services'); + $commands = collect([]); $services = collect($services)->map(function ($service) use ($commands) { $serviceVolumes = collect(data_get($service, 'volumes', [])); @@ -1088,7 +1159,7 @@ class Application extends BaseModel public function parse(int $pull_request_id = 0, ?int $preview_id = null) { - if ($this->compose_parsing_version === '3') { + if ((int) $this->compose_parsing_version >= 3) { return newParser($this, $pull_request_id, $preview_id); } elseif ($this->docker_compose_raw) { return parseDockerComposeFile(resource: $this, isNew: false, pull_request_id: $pull_request_id, preview_id: $preview_id); @@ -1166,7 +1237,6 @@ class Application extends BaseModel } else { throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile

Check if you used the right extension (.yaml or .yml) in the compose file name."); } - } public function parseContainerLabels(?ApplicationPreview $preview = null) @@ -1330,13 +1400,21 @@ class Application extends BaseModel return []; } - public function getMetrics(int $mins = 5) + public function getCpuMetrics(int $mins = 5) { $server = $this->destination->server; $container_name = $this->uuid; if ($server->isMetricsEnabled()) { $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->metrics_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); + if (isDev() && $server->id === 0) { + $process = Process::run("curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://host.docker.internal:8888/api/container/{$container_name}/cpu/history?from=$from"); + if ($process->failed()) { + throw new \Exception($process->errorOutput()); + } + $metrics = $process->output(); + } else { + $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false); + } if (str($metrics)->contains('error')) { $error = json_decode($metrics, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); @@ -1345,17 +1423,109 @@ class Application extends BaseModel } throw new \Exception($error); } - $metrics = str($metrics)->explode("\n")->skip(1)->all(); - $parsedCollection = collect($metrics)->flatMap(function ($item) { - return collect(explode("\n", trim($item)))->map(function ($line) { - [$time, $cpu_usage_percent, $memory_usage, $memory_usage_percent] = explode(',', trim($line)); - $cpu_usage_percent = number_format($cpu_usage_percent, 2); - - return [(int) $time, (float) $cpu_usage_percent, (int) $memory_usage]; - }); + $metrics = json_decode($metrics, true); + $parsedCollection = collect($metrics)->map(function ($metric) { + return [(int) $metric['time'], (float) $metric['percent']]; }); return $parsedCollection->toArray(); } } + + public function getMemoryMetrics(int $mins = 5) + { + $server = $this->destination->server; + $container_name = $this->uuid; + if ($server->isMetricsEnabled()) { + $from = now()->subMinutes($mins)->toIso8601ZuluString(); + if (isDev() && $server->id === 0) { + $process = Process::run("curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://host.docker.internal:8888/api/container/{$container_name}/memory/history?from=$from"); + if ($process->failed()) { + throw new \Exception($process->errorOutput()); + } + $metrics = $process->output(); + } else { + $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false); + } + if (str($metrics)->contains('error')) { + $error = json_decode($metrics, true); + $error = data_get($error, 'error', 'Something is not okay, are you okay?'); + if ($error == 'Unauthorized') { + $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; + } + throw new \Exception($error); + } + $metrics = json_decode($metrics, true); + $parsedCollection = collect($metrics)->map(function ($metric) { + return [(int) $metric['time'], (float) $metric['used']]; + }); + + return $parsedCollection->toArray(); + } + } + + public function generateConfig($is_json = false) + { + $config = collect([]); + if ($this->build_pack = 'nixpacks') { + $config = collect([ + 'build_pack' => 'nixpacks', + 'docker_registry_image_name' => $this->docker_registry_image_name, + 'docker_registry_image_tag' => $this->docker_registry_image_tag, + 'install_command' => $this->install_command, + 'build_command' => $this->build_command, + 'start_command' => $this->start_command, + 'base_directory' => $this->base_directory, + 'publish_directory' => $this->publish_directory, + 'custom_docker_run_options' => $this->custom_docker_run_options, + 'ports_exposes' => $this->ports_exposes, + 'ports_mappings' => $this->ports_mapping, + 'settings' => collect([ + 'is_static' => $this->settings->is_static, + ]), + ]); + } + $config = $config->filter(function ($value) { + return str($value)->isNotEmpty(); + }); + if ($is_json) { + return json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + return $config; + } + + public function setConfig($config) + { + + $config = $config; + $validator = Validator::make(['config' => $config], [ + 'config' => 'required|json', + ]); + if ($validator->fails()) { + throw new \Exception('Invalid JSON format'); + } + $config = json_decode($config, true); + + $deepValidator = Validator::make(['config' => $config], [ + 'config.build_pack' => 'required|string', + 'config.base_directory' => 'required|string', + 'config.publish_directory' => 'required|string', + 'config.ports_exposes' => 'required|string', + 'config.settings.is_static' => 'required|boolean', + ]); + if ($deepValidator->fails()) { + throw new \Exception('Invalid data'); + } + $config = $deepValidator->validated()['config']; + + try { + $settings = data_get($config, 'settings', []); + data_forget($config, 'settings'); + $this->update($config); + $this->settings()->update($settings); + } catch (\Exception $e) { + throw new \Exception('Failed to update application settings'); + } + } } diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 90d7608cc..c261c30c6 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; use OpenApi\Attributes as OA; @@ -39,6 +40,20 @@ class ApplicationDeploymentQueue extends Model { protected $guarded = []; + public function application(): Attribute + { + return Attribute::make( + get: fn () => Application::find($this->application_id), + ); + } + + public function server(): Attribute + { + return Attribute::make( + get: fn () => Server::find($this->server_id), + ); + } + public function setStatus(string $status) { $this->update([ diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 138775aba..531c8fa40 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -44,7 +44,7 @@ class EnvironmentVariable extends Model 'version' => 'string', ]; - protected $appends = ['real_value', 'is_shared']; + protected $appends = ['real_value', 'is_shared', 'is_really_required']; protected static function booted() { @@ -126,15 +126,17 @@ class EnvironmentVariable extends Model $env = $this->get_real_environment_variables($this->value, $resource); return data_get($env, 'value', $env); - if (is_string($env)) { - return $env; - } - - return $env->value; } ); } + protected function isReallyRequired(): Attribute + { + return Attribute::make( + get: fn () => $this->is_required && str($this->real_value)->isEmpty(), + ); + } + protected function isShared(): Attribute { return Attribute::make( diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 27a181ee4..3ee142050 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -21,6 +21,7 @@ class InstanceSettings extends Model implements SendsEmail 'is_auto_update_enabled' => 'boolean', 'auto_update_frequency' => 'string', 'update_check_frequency' => 'string', + 'sentinel_token' => 'encrypted', ]; public function fqdn(): Attribute @@ -85,4 +86,17 @@ class InstanceSettings extends Model implements SendsEmail return "[{$instanceName}]"; } + + public function helperVersion(): Attribute + { + return Attribute::make( + get: function ($value) { + if (isDev()) { + return 'latest'; + } + + return $value; + } + ); + } } diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 45bc6bc84..065746ede 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -2,6 +2,9 @@ namespace App\Models; +use DanHarrin\LivewireRateLimiting\WithRateLimiting; +use Illuminate\Support\Facades\Storage; +use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; use phpseclib3\Crypt\PublicKeyLoader; @@ -22,48 +25,144 @@ use phpseclib3\Crypt\PublicKeyLoader; )] class PrivateKey extends BaseModel { + use WithRateLimiting; + protected $fillable = [ 'name', 'description', 'private_key', 'is_git_related', 'team_id', + 'fingerprint', + ]; + + protected $casts = [ + 'private_key' => 'encrypted', ]; protected static function booted() { static::saving(function ($key) { - $privateKey = data_get($key, 'private_key'); - if (substr($privateKey, -1) !== "\n") { - $key->private_key = $privateKey."\n"; + $key->private_key = formatPrivateKey($key->private_key); + + if (! self::validatePrivateKey($key->private_key)) { + throw ValidationException::withMessages([ + 'private_key' => ['The private key is invalid.'], + ]); + } + + $key->fingerprint = self::generateFingerprint($key->private_key); + if (self::fingerprintExists($key->fingerprint, $key->id)) { + throw ValidationException::withMessages([ + 'private_key' => ['This private key already exists.'], + ]); } }); + static::deleted(function ($key) { + self::deleteFromStorage($key); + }); + } + + public function getPublicKey() + { + return self::extractPublicKeyFromPrivate($this->private_key) ?? 'Error loading private key'; } public static function ownedByCurrentTeam(array $select = ['*']) { $selectArray = collect($select)->concat(['id']); - return PrivateKey::whereTeamId(currentTeam()->id)->select($selectArray->all()); + return self::whereTeamId(currentTeam()->id)->select($selectArray->all()); } - public function publicKey() + public static function validatePrivateKey($privateKey) { try { - return PublicKeyLoader::load($this->private_key)->getPublicKey()->toString('OpenSSH', ['comment' => '']); + PublicKeyLoader::load($privateKey); + + return true; } catch (\Throwable $e) { - return 'Error loading private key'; + return false; } } - public function isEmpty() + public static function createAndStore(array $data) { - if ($this->servers()->count() === 0 && $this->applications()->count() === 0 && $this->githubApps()->count() === 0 && $this->gitlabApps()->count() === 0) { - return true; - } + $privateKey = new self($data); + $privateKey->save(); + $privateKey->storeInFileSystem(); - return false; + return $privateKey; + } + + public static function generateNewKeyPair($type = 'rsa') + { + try { + $instance = new self; + $instance->rateLimit(10); + $name = generate_random_name(); + $description = 'Created by Coolify'; + $keyPair = generateSSHKey($type === 'ed25519' ? 'ed25519' : 'rsa'); + + return [ + 'name' => $name, + 'description' => $description, + 'private_key' => $keyPair['private'], + 'public_key' => $keyPair['public'], + ]; + } catch (\Throwable $e) { + throw new \Exception("Failed to generate new {$type} key: ".$e->getMessage()); + } + } + + public static function extractPublicKeyFromPrivate($privateKey) + { + try { + $key = PublicKeyLoader::load($privateKey); + + return $key->getPublicKey()->toString('OpenSSH', ['comment' => '']); + } catch (\Throwable $e) { + return null; + } + } + + public static function validateAndExtractPublicKey($privateKey) + { + $isValid = self::validatePrivateKey($privateKey); + $publicKey = $isValid ? self::extractPublicKeyFromPrivate($privateKey) : ''; + + return [ + 'isValid' => $isValid, + 'publicKey' => $publicKey, + ]; + } + + public function storeInFileSystem() + { + $filename = "ssh_key@{$this->uuid}"; + Storage::disk('ssh-keys')->put($filename, $this->private_key); + + return "/var/www/html/storage/app/ssh/keys/{$filename}"; + } + + public static function deleteFromStorage(self $privateKey) + { + $filename = "ssh_key@{$privateKey->uuid}"; + Storage::disk('ssh-keys')->delete($filename); + } + + public function getKeyLocation() + { + return "/var/www/html/storage/app/ssh/keys/ssh_key@{$this->uuid}"; + } + + public function updatePrivateKey(array $data) + { + $this->update($data); + $this->storeInFileSystem(); + + return $this; } public function servers() @@ -85,4 +184,53 @@ class PrivateKey extends BaseModel { return $this->hasMany(GitlabApp::class); } + + public function isInUse() + { + return $this->servers()->exists() + || $this->applications()->exists() + || $this->githubApps()->exists() + || $this->gitlabApps()->exists(); + } + + public function safeDelete() + { + if (! $this->isInUse()) { + $this->delete(); + + return true; + } + + return false; + } + + public static function generateFingerprint($privateKey) + { + try { + $key = PublicKeyLoader::load($privateKey); + $publicKey = $key->getPublicKey(); + + return $publicKey->getFingerprint('sha256'); + } catch (\Throwable $e) { + return null; + } + } + + private static function fingerprintExists($fingerprint, $excludeId = null) + { + $query = self::where('fingerprint', $fingerprint); + + if (! is_null($excludeId)) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } + + public static function cleanupUnusedKeys() + { + self::ownedByCurrentTeam()->each(function ($privateKey) { + $privateKey->safeDelete(); + }); + } } diff --git a/app/Models/Project.php b/app/Models/Project.php index 18481751c..5a9dd964a 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -24,9 +24,11 @@ class Project extends BaseModel { protected $guarded = []; + protected $appends = ['default_environment']; + public static function ownedByCurrentTeam() { - return Project::whereTeamId(currentTeam()->id)->orderBy('name'); + return Project::whereTeamId(currentTeam()->id)->orderByRaw('LOWER(name)'); } protected static function booted() @@ -131,7 +133,7 @@ class Project extends BaseModel return $this->postgresqls()->get()->merge($this->redis()->get())->merge($this->mongodbs()->get())->merge($this->mysqls()->get())->merge($this->mariadbs()->get())->merge($this->keydbs()->get())->merge($this->dragonflies()->get())->merge($this->clickhouses()->get()); } - public function default_environment() + public function getDefaultEnvironmentAttribute() { $default = $this->environments()->where('name', 'production')->first(); if ($default) { diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 4c7faaa6f..a432a6e9c 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -40,6 +40,16 @@ class S3Storage extends BaseModel return "{$this->endpoint}/{$this->bucket}"; } + public function isHetzner() + { + return str($this->endpoint)->contains('your-objectstorage.com'); + } + + public function isDigitalOcean() + { + return str($this->endpoint)->contains('digitaloceanspaces.com'); + } + public function testConnection(bool $shouldSave = false) { try { diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 50a0c8173..3921e32e4 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -35,14 +35,23 @@ class ScheduledDatabaseBackup extends BaseModel { return $this->hasMany(ScheduledDatabaseBackupExecution::class)->where('created_at', '>=', now()->subDays($days))->get(); } + public function server() { if ($this->database) { - if ($this->database->destination && $this->database->destination->server) { - $server = $this->database->destination->server; + if ($this->database instanceof ServiceDatabase) { + $destination = data_get($this->database->service, 'destination'); + $server = data_get($destination, 'server'); + } else { + $destination = data_get($this->database, 'destination'); + $server = data_get($destination, 'server'); + } + if ($server) { return $server; } } + + return null; } } diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index 82f0036a5..3cee5a875 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -4,8 +4,6 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; -use App\Models\Service; -use App\Models\Application; class ScheduledTask extends BaseModel { @@ -37,19 +35,23 @@ class ScheduledTask extends BaseModel if ($this->application) { if ($this->application->destination && $this->application->destination->server) { $server = $this->application->destination->server; + return $server; } } elseif ($this->service) { if ($this->service->destination && $this->service->destination->server) { $server = $this->service->destination->server; + return $server; } } elseif ($this->database) { if ($this->database->destination && $this->database->destination->server) { $server = $this->database->destination->server; + return $server; } } + return null; } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 65d70083f..2468fc2b4 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -5,9 +5,10 @@ namespace App\Models; use App\Actions\Server\InstallDocker; use App\Enums\ProxyTypes; use App\Jobs\PullSentinelImageJob; -use App\Notifications\Server\Revived; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Process; @@ -37,12 +38,14 @@ use Symfony\Component\Yaml\Yaml; 'validation_logs' => ['type' => 'string'], 'log_drain_notification_sent' => ['type' => 'boolean'], 'swarm_cluster' => ['type' => 'string'], + 'delete_unused_volumes' => ['type' => 'boolean'], + 'delete_unused_networks' => ['type' => 'boolean'], ] )] class Server extends BaseModel { - use SchemalessAttributesTrait; + use SchemalessAttributesTrait,SoftDeletes; public static $batch_counter = 0; @@ -94,7 +97,8 @@ class Server extends BaseModel } } }); - static::deleting(function ($server) { + + static::forceDeleting(function ($server) { $server->destinations()->each(function ($destination) { $destination->delete(); }); @@ -106,6 +110,8 @@ class Server extends BaseModel 'proxy' => SchemalessAttributes::class, 'logdrain_axiom_api_key' => 'encrypted', 'logdrain_newrelic_license_key' => 'encrypted', + 'delete_unused_volumes' => 'boolean', + 'delete_unused_networks' => 'boolean', ]; protected $schemalessAttributes = [ @@ -156,24 +162,29 @@ class Server extends BaseModel return $this->hasOne(ServerSetting::class); } + public function proxySet() + { + return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && ! $this->settings->is_build_server; + } + public function setupDefault404Redirect() { - $dynamic_conf_path = $this->proxyPath().'/dynamic'; + $dynamic_conf_path = $this->proxyPath() . '/dynamic'; $proxy_type = $this->proxyType(); $redirect_url = $this->proxy->redirect_url; if ($proxy_type === ProxyTypes::TRAEFIK->value) { $default_redirect_file = "$dynamic_conf_path/default_redirect_404.yaml"; - } elseif ($proxy_type === 'CADDY') { + } elseif ($proxy_type === ProxyTypes::CADDY->value) { $default_redirect_file = "$dynamic_conf_path/default_redirect_404.caddy"; } if (empty($redirect_url)) { - if ($proxy_type === 'CADDY') { + if ($proxy_type === ProxyTypes::CADDY->value) { $conf = ':80, :443 { respond 404 }'; $conf = - "# This file is automatically generated by Coolify.\n". - "# Do not edit it manually (only if you know what are you doing).\n\n". + "# This file is automatically generated by Coolify.\n" . + "# Do not edit it manually (only if you know what are you doing).\n\n" . $conf; $base64 = base64_encode($conf); instant_remote_process([ @@ -201,10 +212,13 @@ respond 404 1 => 'https', ], 'service' => 'noop', - 'rule' => 'HostRegexp(`{catchall:.*}`)', + 'rule' => 'HostRegexp(`.+`)', + 'tls' => [ + 'certResolver' => 'letsencrypt', + ], 'priority' => 1, 'middlewares' => [ - 0 => 'redirect-regexp@file', + 0 => 'redirect-regexp', ], ], ], @@ -232,18 +246,18 @@ respond 404 ]; $conf = Yaml::dump($dynamic_conf, 12, 2); $conf = - "# This file is automatically generated by Coolify.\n". - "# Do not edit it manually (only if you know what are you doing).\n\n". + "# This file is automatically generated by Coolify.\n" . + "# Do not edit it manually (only if you know what are you doing).\n\n" . $conf; $base64 = base64_encode($conf); - } elseif ($proxy_type === 'CADDY') { + } elseif ($proxy_type === ProxyTypes::CADDY->value) { $conf = ":80, :443 { redir $redirect_url }"; $conf = - "# This file is automatically generated by Coolify.\n". - "# Do not edit it manually (only if you know what are you doing).\n\n". + "# This file is automatically generated by Coolify.\n" . + "# Do not edit it manually (only if you know what are you doing).\n\n" . $conf; $base64 = base64_encode($conf); } @@ -253,9 +267,6 @@ respond 404 "echo '$base64' | base64 -d | tee $default_redirect_file > /dev/null", ], $this); - if (config('app.env') == 'local') { - ray($conf); - } if ($proxy_type === 'CADDY') { $this->reloadCaddy(); } @@ -263,8 +274,8 @@ respond 404 public function setupDynamicProxyConfiguration() { - $settings = \App\Models\InstanceSettings::get(); - $dynamic_config_path = $this->proxyPath().'/dynamic'; + $settings = instanceSettings(); + $dynamic_config_path = $this->proxyPath() . '/dynamic'; if ($this->proxyType() === ProxyTypes::TRAEFIK->value) { $file = "$dynamic_config_path/coolify.yaml"; if (empty($settings->fqdn) || (isCloud() && $this->id !== 0) || ! $this->isLocalhost()) { @@ -305,6 +316,13 @@ respond 404 'service' => 'coolify-realtime', 'rule' => "Host(`{$host}`) && PathPrefix(`/app`)", ], + 'coolify-terminal-ws' => [ + 'entryPoints' => [ + 0 => 'http', + ], + 'service' => 'coolify-terminal', + 'rule' => "Host(`{$host}`) && PathPrefix(`/terminal/ws`)", + ], ], 'services' => [ 'coolify' => [ @@ -325,6 +343,15 @@ respond 404 ], ], ], + 'coolify-terminal' => [ + 'loadBalancer' => [ + 'servers' => [ + 0 => [ + 'url' => 'http://coolify-realtime:6002', + ], + ], + ], + ], ], ], ]; @@ -354,11 +381,21 @@ respond 404 'certresolver' => 'letsencrypt', ], ]; + $traefik_dynamic_conf['http']['routers']['coolify-terminal-wss'] = [ + 'entryPoints' => [ + 0 => 'https', + ], + 'service' => 'coolify-terminal', + 'rule' => "Host(`{$host}`) && PathPrefix(`/terminal/ws`)", + 'tls' => [ + 'certresolver' => 'letsencrypt', + ], + ]; } $yaml = Yaml::dump($traefik_dynamic_conf, 12, 2); $yaml = - "# This file is automatically generated by Coolify.\n". - "# Do not edit it manually (only if you know what are you doing).\n\n". + "# This file is automatically generated by Coolify.\n" . + "# Do not edit it manually (only if you know what are you doing).\n\n" . $yaml; $base64 = base64_encode($yaml); @@ -387,6 +424,9 @@ $schema://$host { handle /app/* { reverse_proxy coolify-realtime:6001 } + handle /terminal/ws { + reverse_proxy coolify-realtime:6002 + } reverse_proxy coolify:80 }"; $base64 = base64_encode($caddy_file); @@ -414,11 +454,19 @@ $schema://$host { // Should move everything except /caddy and /nginx to /traefik // The code needs to be modified as well, so maybe it does not worth it if ($proxyType === ProxyTypes::TRAEFIK->value) { - $proxy_path = $proxy_path; + // Do nothing } elseif ($proxyType === ProxyTypes::CADDY->value) { - $proxy_path = $proxy_path.'/caddy'; + if (isDev()) { + $proxy_path = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/caddy'; + } else { + $proxy_path = $proxy_path . '/caddy'; + } } elseif ($proxyType === ProxyTypes::NGINX->value) { - $proxy_path = $proxy_path.'/nginx'; + if (isDev()) { + $proxy_path = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/nginx'; + } else { + $proxy_path = $proxy_path . '/nginx'; + } } return $proxy_path; @@ -426,15 +474,6 @@ $schema://$host { public function proxyType() { - // $proxyType = $this->proxy->get('type'); - // if ($proxyType === ProxyTypes::NONE->value) { - // return $proxyType; - // } - // if (is_null($proxyType)) { - // $this->proxy->type = ProxyTypes::TRAEFIK->value; - // $this->proxy->status = ProxyStatus::EXITED->value; - // $this->save(); - // } return data_get($this->proxy, 'type'); } @@ -489,9 +528,20 @@ $schema://$host { Storage::disk('ssh-mux')->delete($this->muxFilename()); } + + public function sentinelHeartbeat(bool $isReset = false) + { + $this->sentinel_updated_at = $isReset ? now()->subMinutes(6000) : now(); + $this->save(); + } + public function isSentinelLive() + { + return Carbon::parse($this->sentinel_updated_at)->isAfter(now()->subMinutes(4)); + } + public function isSentinelEnabled() { - return $this->isMetricsEnabled() || $this->isServerApiEnabled(); + return ($this->isMetricsEnabled() || $this->isServerApiEnabled()) && !$this->isBuildServer(); } public function isMetricsEnabled() @@ -501,7 +551,7 @@ $schema://$host { public function isServerApiEnabled() { - return $this->settings->is_server_api_enabled; + return $this->settings->is_sentinel_enabled; } public function checkServerApi() @@ -519,7 +569,6 @@ $schema://$host { ray($process->exitCode(), $process->output(), $process->errorOutput()); throw new \Exception("Server API is not reachable on http://{$server_ip}:12172"); } - } } @@ -543,7 +592,15 @@ $schema://$host { { if ($this->isMetricsEnabled()) { $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $cpu = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->metrics_token}\" http://localhost:8888/api/cpu/history?from=$from'"], $this, false); + if (isDev() && $this->id === 0) { + $process = Process::run("curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://host.docker.internal:8888/api/cpu/history?from=$from"); + if ($process->failed()) { + throw new \Exception($process->errorOutput()); + } + $cpu = $process->output(); + } else { + $cpu = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://localhost:8888/api/cpu/history?from=$from'"], $this, false); + } if (str($cpu)->contains('error')) { $error = json_decode($cpu, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); @@ -552,17 +609,12 @@ $schema://$host { } throw new \Exception($error); } - $cpu = str($cpu)->explode("\n")->skip(1)->all(); - $parsedCollection = collect($cpu)->flatMap(function ($item) { - return collect(explode("\n", trim($item)))->map(function ($line) { - [$time, $cpu_usage_percent] = explode(',', trim($line)); - $cpu_usage_percent = number_format($cpu_usage_percent, 0); - - return [(int) $time, (float) $cpu_usage_percent]; - }); + $cpu = json_decode($cpu, true); + $parsedCollection = collect($cpu)->map(function ($metric) { + return [(int)$metric['time'], (float)$metric['percent']]; }); + return $parsedCollection; - return $parsedCollection->toArray(); } } @@ -570,7 +622,15 @@ $schema://$host { { if ($this->isMetricsEnabled()) { $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $memory = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->metrics_token}\" http://localhost:8888/api/memory/history?from=$from'"], $this, false); + if (isDev() && $this->id === 0) { + $process = Process::run("curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://host.docker.internal:8888/api/memory/history?from=$from"); + if ($process->failed()) { + throw new \Exception($process->errorOutput()); + } + $memory = $process->output(); + } else { + $memory = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://localhost:8888/api/memory/history?from=$from'"], $this, false); + } if (str($memory)->contains('error')) { $error = json_decode($memory, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); @@ -579,14 +639,9 @@ $schema://$host { } throw new \Exception($error); } - $memory = str($memory)->explode("\n")->skip(1)->all(); - $parsedCollection = collect($memory)->flatMap(function ($item) { - return collect(explode("\n", trim($item)))->map(function ($line) { - [$time, $used, $free, $usedPercent] = explode(',', trim($line)); - $usedPercent = number_format($usedPercent, 0); - - return [(int) $time, (float) $usedPercent]; - }); + $memory = json_decode($memory, true); + $parsedCollection = collect($memory)->map(function ($metric) { + return [(int)$metric['time'], (float)$metric['usedPercent']]; }); return $parsedCollection->toArray(); @@ -746,6 +801,18 @@ $schema://$host { } } + public function loadAllContainers(): Collection + { + if ($this->isFunctional()) { + $containers = instant_remote_process(["docker ps -a --format '{{json .}}'"], $this); + $containers = format_docker_command_output_to_json($containers); + + return collect($containers); + } + + return collect([]); + } + public function loadUnmanagedContainers(): Collection { if ($this->isFunctional()) { @@ -792,9 +859,9 @@ $schema://$host { $clickhouses = data_get($standaloneDocker, 'clickhouses', collect([])); return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses); - })->filter(function ($item) { + })->flatten()->filter(function ($item) { return data_get($item, 'name') !== 'coolify-db'; - })->flatten(); + }); } public function applications() @@ -838,6 +905,35 @@ $schema://$host { return $this->hasMany(Service::class); } + public function port(): Attribute + { + return Attribute::make( + get: function ($value) { + return preg_replace('/[^0-9]/', '', $value); + } + ); + } + + public function user(): Attribute + { + return Attribute::make( + get: function ($value) { + $sanitizedValue = preg_replace('/[^A-Za-z0-9\-_]/', '', $value); + + return $sanitizedValue; + } + ); + } + + public function ip(): Attribute + { + return Attribute::make( + get: function ($value) { + return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); + } + ); + } + public function getIp(): Attribute { return Attribute::make( @@ -900,7 +996,8 @@ $schema://$host { public function isProxyShouldRun() { - if ($this->proxyType() === ProxyTypes::NONE->value || $this->settings->is_build_server) { + // TODO: Do we need "|| $this->proxy->force_stop" here? + if ($this->proxyType() === ProxyTypes::NONE->value || $this->isBuildServer()) { return false; } @@ -910,10 +1007,9 @@ $schema://$host { public function isFunctional() { $isFunctional = $this->settings->is_reachable && $this->settings->is_usable && ! $this->settings->force_disabled; - ['private_key_filename' => $private_key_filename, 'mux_filename' => $mux_filename] = server_ssh_configuration($this); + if (! $isFunctional) { - Storage::disk('ssh-keys')->delete($private_key_filename); - Storage::disk('ssh-mux')->delete($mux_filename); + Storage::disk('ssh-mux')->delete($this->muxFilename()); } return $isFunctional; @@ -965,9 +1061,41 @@ $schema://$host { return data_get($this, 'settings.is_swarm_worker'); } - public function validateConnection() + public function status(): bool { - config()->set('constants.ssh.mux_enabled', false); + ['uptime' => $uptime] = $this->validateConnection(false); + if ($uptime) { + if ($this->unreachable_notification_sent === true) { + $this->update(['unreachable_notification_sent' => false]); + } + } else { + // $this->server->team?->notify(new Unreachable($this->server)); + foreach ($this->applications as $application) { + $application->update(['status' => 'exited']); + } + foreach ($this->databases as $database) { + $database->update(['status' => 'exited']); + } + foreach ($this->services as $service) { + $apps = $service->applications()->get(); + $dbs = $service->databases()->get(); + foreach ($apps as $app) { + $app->update(['status' => 'exited']); + } + foreach ($dbs as $db) { + $db->update(['status' => 'exited']); + } + } + + return false; + } + + return true; + } + public function validateConnection($isManualCheck = true) + { + config()->set('constants.ssh.mux_enabled', ! $isManualCheck); + // ray('Manual Check: ' . ($isManualCheck ? 'true' : 'false')); $server = Server::find($this->id); if (! $server) { @@ -977,7 +1105,10 @@ $schema://$host { return ['uptime' => false, 'error' => 'Server skipped.']; } try { - // EC2 does not have `uptime` command, lol + // Make sure the private key is stored + if ($server->privateKey) { + $server->privateKey->storeInFileSystem(); + } instant_remote_process(['ls /'], $server); $server->settings()->update([ 'is_reachable' => true, @@ -986,7 +1117,6 @@ $schema://$host { 'unreachable_count' => 0, ]); if (data_get($server, 'unreachable_notification_sent') === true) { - // $server->team?->notify(new Revived($server)); $server->update(['unreachable_notification_sent' => false]); } @@ -1115,4 +1245,38 @@ $schema://$host { { return $this->settings->is_build_server; } + + public static function createWithPrivateKey(array $data, PrivateKey $privateKey) + { + $server = new self($data); + $server->privateKey()->associate($privateKey); + $server->save(); + + return $server; + } + + public function updateWithPrivateKey(array $data, ?PrivateKey $privateKey = null) + { + $this->update($data); + if ($privateKey) { + $this->privateKey()->associate($privateKey); + $this->save(); + } + + return $this; + } + + public function storageCheck(): ?string + { + $commands = [ + 'df / --output=pcent | tr -cd 0-9', + ]; + + return instant_remote_process($commands, $this, false); + } + + public function isIpv6(): bool + { + return str($this->ip)->contains(':'); + } } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index c44a393b4..8ef1420e0 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -24,7 +24,7 @@ use OpenApi\Attributes as OA; 'is_logdrain_newrelic_enabled' => ['type' => 'boolean'], 'is_metrics_enabled' => ['type' => 'boolean'], 'is_reachable' => ['type' => 'boolean'], - 'is_server_api_enabled' => ['type' => 'boolean'], + 'is_sentinel_enabled' => ['type' => 'boolean'], 'is_swarm_manager' => ['type' => 'boolean'], 'is_swarm_worker' => ['type' => 'boolean'], 'is_usable' => ['type' => 'boolean'], @@ -35,9 +35,9 @@ use OpenApi\Attributes as OA; 'logdrain_highlight_project_id' => ['type' => 'string'], 'logdrain_newrelic_base_uri' => ['type' => 'string'], 'logdrain_newrelic_license_key' => ['type' => 'string'], - 'metrics_history_days' => ['type' => 'integer'], - 'metrics_refresh_rate_seconds' => ['type' => 'integer'], - 'metrics_token' => ['type' => 'string'], + 'sentinel_metrics_history_days' => ['type' => 'integer'], + 'sentinel_metrics_refresh_rate_seconds' => ['type' => 'integer'], + 'sentinel_token' => ['type' => 'string'], 'docker_cleanup_frequency' => ['type' => 'string'], 'docker_cleanup_threshold' => ['type' => 'integer'], 'server_id' => ['type' => 'integer'], @@ -53,8 +53,65 @@ class ServerSetting extends Model protected $casts = [ 'force_docker_cleanup' => 'boolean', 'docker_cleanup_threshold' => 'integer', + 'sentinel_token' => 'encrypted', ]; + protected static function booted() + { + static::creating(function ($setting) { + try { + if (str($setting->sentinel_token)->isEmpty()) { + $setting->generateSentinelToken(save: false); + } + if (str($setting->sentinel_custom_url)->isEmpty()) { + $url = $setting->generateSentinelUrl(save: false); + if (str($url)->isEmpty()) { + $setting->is_sentinel_enabled = false; + } else { + $setting->is_sentinel_enabled = true; + } + } + } catch (\Throwable $e) { + loggy('Error creating server setting: ' . $e->getMessage()); + } + }); + } + + public function generateSentinelToken(bool $save = true) + { + $data = [ + 'server_uuid' => $this->server->uuid, + ]; + $token = json_encode($data); + $encrypted = encrypt($token); + $this->sentinel_token = $encrypted; + if ($save) { + $this->save(); + } + + return $encrypted; + } + + public function generateSentinelUrl(bool $save = true) + { + $domain = null; + $settings = InstanceSettings::get(); + if ($this->server->isLocalhost()) { + $domain = 'http://host.docker.internal:8000'; + } else if ($settings->fqdn) { + $domain = $settings->fqdn; + } else if ($settings->ipv4) { + $domain = $settings->ipv4 . ':8000'; + } else if ($settings->ipv6) { + $domain = $settings->ipv6 . ':8000'; + } + $this->sentinel_custom_url = $domain; + if ($save) { + $this->save(); + } + return $domain; + } + public function server() { return $this->belongsTo(Server::class); diff --git a/app/Models/Service.php b/app/Models/Service.php index d8def6663..16e11ecb6 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -6,7 +6,9 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Process\InvokedProcess; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; use Spatie\Url\Url; @@ -40,7 +42,7 @@ class Service extends BaseModel { use HasFactory, SoftDeletes; - private static $parserVersion = '3'; + private static $parserVersion = '4'; protected $guarded = []; @@ -131,15 +133,81 @@ class Service extends BaseModel return $this->morphToMany(Tag::class, 'taggable'); } + public function getContainersToStop(): array + { + $containersToStop = []; + $applications = $this->applications()->get(); + foreach ($applications as $application) { + $containersToStop[] = "{$application->name}-{$this->uuid}"; + } + $dbs = $this->databases()->get(); + foreach ($dbs as $db) { + $containersToStop[] = "{$db->name}-{$this->uuid}"; + } + + return $containersToStop; + } + + public function stopContainers(array $containerNames, $server, int $timeout = 300) + { + $processes = []; + foreach ($containerNames as $containerName) { + $processes[$containerName] = $this->stopContainer($containerName, $timeout); + } + + $startTime = time(); + while (count($processes) > 0) { + $finishedProcesses = array_filter($processes, function ($process) { + return ! $process->running(); + }); + foreach (array_keys($finishedProcesses) as $containerName) { + unset($processes[$containerName]); + $this->removeContainer($containerName, $server); + } + + if (time() - $startTime >= $timeout) { + $this->forceStopRemainingContainers(array_keys($processes), $server); + break; + } + + usleep(100000); + } + } + + public function stopContainer(string $containerName, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + public function removeContainer(string $containerName, $server) + { + instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false); + } + + public function forceStopRemainingContainers(array $containerNames, $server) + { + foreach ($containerNames as $containerName) { + instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false); + $this->removeContainer($containerName, $server); + } + } + public function delete_configurations() { - $server = data_get($this, 'server'); + $server = data_get($this, 'destination.server'); $workdir = $this->workdir(); if (str($workdir)->endsWith($this->uuid)) { instant_remote_process(['rm -rf '.$this->workdir()], $server, false); } } + public function delete_connected_networks($uuid) + { + $server = data_get($this, 'destination.server'); + instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$uuid}"], $server, false); + } + public function status() { $applications = $this->applications; @@ -215,9 +283,162 @@ class Service extends BaseModel $fields = collect([]); $applications = $this->applications()->get(); foreach ($applications as $application) { - $image = str($application->image)->before(':')->value(); + $image = str($application->image)->before(':'); + if ($image->isEmpty()) { + continue; + } switch ($image) { - case str($image)?->contains('rabbitmq'): + case $image->contains('castopod'): + $data = collect([]); + $disable_https = $this->environment_variables()->where('key', 'CP_DISABLE_HTTPS')->first(); + if ($disable_https) { + $data = $data->merge([ + 'Disable HTTPS' => [ + 'key' => 'CP_DISABLE_HTTPS', + 'value' => data_get($disable_https, 'value'), + 'rules' => 'required', + 'customHelper' => "If you want to use https, set this to 0. Variable name: CP_DISABLE_HTTPS", + ], + ]); + } + $fields->put('Castopod', $data->toArray()); + break; + case $image->contains('label-studio'): + $data = collect([]); + $username = $this->environment_variables()->where('key', 'LABEL_STUDIO_USERNAME')->first(); + $password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_LABELSTUDIO')->first(); + if ($username) { + $data = $data->merge([ + 'Username' => [ + 'key' => 'LABEL_STUDIO_USERNAME', + 'value' => data_get($username, 'value'), + 'rules' => 'required', + ], + ]); + } + if ($password) { + $data = $data->merge([ + 'Password' => [ + 'key' => data_get($password, 'key'), + 'value' => data_get($password, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + $fields->put('Label Studio', $data->toArray()); + break; + case $image->contains('litellm'): + $data = collect([]); + $username = $this->environment_variables()->where('key', 'SERVICE_USER_UI')->first(); + $password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_UI')->first(); + if ($username) { + $data = $data->merge([ + 'Username' => [ + 'key' => data_get($username, 'key'), + 'value' => data_get($username, 'value'), + 'rules' => 'required', + ], + ]); + } + if ($password) { + $data = $data->merge([ + 'Password' => [ + 'key' => data_get($password, 'key'), + 'value' => data_get($password, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + $fields->put('Litellm', $data->toArray()); + break; + case $image->contains('langfuse'): + $data = collect([]); + $email = $this->environment_variables()->where('key', 'LANGFUSE_INIT_USER_EMAIL')->first(); + if ($email) { + $data = $data->merge([ + 'Admin Email' => [ + 'key' => data_get($email, 'key'), + 'value' => data_get($email, 'value'), + 'rules' => 'required|email', + ], + ]); + } + $password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_LANGFUSE')->first(); + ray('password', $password); + if ($password) { + $data = $data->merge([ + 'Admin Password' => [ + 'key' => data_get($password, 'key'), + 'value' => data_get($password, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + $fields->put('Langfuse', $data->toArray()); + break; + case $image->contains('invoiceninja'): + $data = collect([]); + $email = $this->environment_variables()->where('key', 'IN_USER_EMAIL')->first(); + $data = $data->merge([ + 'Email' => [ + 'key' => data_get($email, 'key'), + 'value' => data_get($email, 'value'), + 'rules' => 'required|email', + ], + ]); + $password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_INVOICENINJAUSER')->first(); + $data = $data->merge([ + 'Password' => [ + 'key' => data_get($password, 'key'), + 'value' => data_get($password, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + $fields->put('Invoice Ninja', $data->toArray()); + break; + case $image->contains('argilla'): + $data = collect([]); + $api_key = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_APIKEY')->first(); + $data = $data->merge([ + 'API Key' => [ + 'key' => data_get($api_key, 'key'), + 'value' => data_get($api_key, 'value'), + 'isPassword' => true, + 'rules' => 'required', + ], + ]); + $data = $data->merge([ + 'API Key' => [ + 'key' => data_get($api_key, 'key'), + 'value' => data_get($api_key, 'value'), + 'isPassword' => true, + 'rules' => 'required', + ], + ]); + $username = $this->environment_variables()->where('key', 'ARGILLA_USERNAME')->first(); + $data = $data->merge([ + 'Username' => [ + 'key' => data_get($username, 'key'), + 'value' => data_get($username, 'value'), + 'rules' => 'required', + ], + ]); + $password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ARGILLA')->first(); + $data = $data->merge([ + 'Password' => [ + 'key' => data_get($password, 'key'), + 'value' => data_get($password, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + $fields->put('Argilla', $data->toArray()); + break; + case $image->contains('rabbitmq'): $data = collect([]); $host_port = $this->environment_variables()->where('key', 'PORT')->first(); $username = $this->environment_variables()->where('key', 'SERVICE_USER_RABBITMQ')->first(); @@ -252,7 +473,7 @@ class Service extends BaseModel } $fields->put('RabbitMQ', $data->toArray()); break; - case str($image)?->contains('tolgee'): + case $image->contains('tolgee'): $data = collect([]); $admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_TOLGEE')->first(); $data = $data->merge([ @@ -266,7 +487,7 @@ class Service extends BaseModel if ($admin_password) { $data = $data->merge([ 'Admin Password' => [ - 'key' => 'SERVICE_PASSWORD_TOLGEE', + 'key' => data_get($admin_password, 'key'), 'value' => data_get($admin_password, 'value'), 'rules' => 'required', 'isPassword' => true, @@ -275,7 +496,7 @@ class Service extends BaseModel } $fields->put('Tolgee', $data->toArray()); break; - case str($image)?->contains('logto'): + case $image->contains('logto'): $data = collect([]); $logto_endpoint = $this->environment_variables()->where('key', 'LOGTO_ENDPOINT')->first(); $logto_admin_endpoint = $this->environment_variables()->where('key', 'LOGTO_ADMIN_ENDPOINT')->first(); @@ -299,7 +520,7 @@ class Service extends BaseModel } $fields->put('Logto', $data->toArray()); break; - case str($image)?->contains('unleash-server'): + case $image->contains('unleash-server'): $data = collect([]); $admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_UNLEASH')->first(); $data = $data->merge([ @@ -313,7 +534,7 @@ class Service extends BaseModel if ($admin_password) { $data = $data->merge([ 'Admin Password' => [ - 'key' => 'SERVICE_PASSWORD_UNLEASH', + 'key' => data_get($admin_password, 'key'), 'value' => data_get($admin_password, 'value'), 'rules' => 'required', 'isPassword' => true, @@ -322,7 +543,7 @@ class Service extends BaseModel } $fields->put('Unleash', $data->toArray()); break; - case str($image)?->contains('grafana'): + case $image->contains('grafana'): $data = collect([]); $admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GRAFANA')->first(); $data = $data->merge([ @@ -336,7 +557,7 @@ class Service extends BaseModel if ($admin_password) { $data = $data->merge([ 'Admin Password' => [ - 'key' => 'GF_SECURITY_ADMIN_PASSWORD', + 'key' => data_get($admin_password, 'key'), 'value' => data_get($admin_password, 'value'), 'rules' => 'required', 'isPassword' => true, @@ -345,7 +566,7 @@ class Service extends BaseModel } $fields->put('Grafana', $data->toArray()); break; - case str($image)?->contains('directus'): + case $image->contains('directus'): $data = collect([]); $admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first(); $admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ADMIN')->first(); @@ -371,7 +592,7 @@ class Service extends BaseModel } $fields->put('Directus', $data->toArray()); break; - case str($image)?->contains('kong'): + case $image->contains('kong'): $data = collect([]); $dashboard_user = $this->environment_variables()->where('key', 'SERVICE_USER_ADMIN')->first(); $dashboard_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ADMIN')->first(); @@ -395,7 +616,7 @@ class Service extends BaseModel ]); } $fields->put('Supabase', $data->toArray()); - case str($image)?->contains('minio'): + case $image->contains('minio'): $data = collect([]); $console_url = $this->environment_variables()->where('key', 'MINIO_BROWSER_REDIRECT_URL')->first(); $s3_api_url = $this->environment_variables()->where('key', 'MINIO_SERVER_URL')->first(); @@ -448,7 +669,7 @@ class Service extends BaseModel $fields->put('MinIO', $data->toArray()); break; - case str($image)?->contains('weblate'): + case $image->contains('weblate'): $data = collect([]); $admin_email = $this->environment_variables()->where('key', 'WEBLATE_ADMIN_EMAIL')->first(); $admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_WEBLATE')->first(); @@ -474,7 +695,7 @@ class Service extends BaseModel } $fields->put('Weblate', $data->toArray()); break; - case str($image)?->contains('meilisearch'): + case $image->contains('meilisearch'): $data = collect([]); $SERVICE_PASSWORD_MEILISEARCH = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_MEILISEARCH')->first(); if ($SERVICE_PASSWORD_MEILISEARCH) { @@ -488,7 +709,7 @@ class Service extends BaseModel } $fields->put('Meilisearch', $data->toArray()); break; - case str($image)?->contains('ghost'): + case $image->contains('ghost'): $data = collect([]); $MAIL_OPTIONS_AUTH_PASS = $this->environment_variables()->where('key', 'MAIL_OPTIONS_AUTH_PASS')->first(); $MAIL_OPTIONS_AUTH_USER = $this->environment_variables()->where('key', 'MAIL_OPTIONS_AUTH_USER')->first(); @@ -548,45 +769,8 @@ class Service extends BaseModel $fields->put('Ghost', $data->toArray()); break; - default: - $data = collect([]); - $admin_user = $this->environment_variables()->where('key', 'SERVICE_USER_ADMIN')->first(); - // Chaskiq - $admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first(); - $admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ADMIN')->first(); - if ($admin_user) { - $data = $data->merge([ - 'User' => [ - 'key' => 'SERVICE_USER_ADMIN', - 'value' => data_get($admin_user, 'value', 'admin'), - 'readonly' => true, - 'rules' => 'required', - ], - ]); - } - if ($admin_password) { - $data = $data->merge([ - 'Password' => [ - 'key' => 'SERVICE_PASSWORD_ADMIN', - 'value' => data_get($admin_password, 'value'), - 'rules' => 'required', - 'isPassword' => true, - ], - ]); - } - if ($admin_email) { - $data = $data->merge([ - 'Email' => [ - 'key' => 'ADMIN_EMAIL', - 'value' => data_get($admin_email, 'value'), - 'rules' => 'required|email', - ], - ]); - } - $fields->put('Admin', $data->toArray()); - break; - case str($image)?->contains('vaultwarden'): + case $image->contains('vaultwarden'): $data = collect([]); $DATABASE_URL = $this->environment_variables()->where('key', 'DATABASE_URL')->first(); @@ -652,7 +836,7 @@ class Service extends BaseModel $fields->put('Vaultwarden', $data); break; - case str($image)->contains('gitlab/gitlab'): + case $image->contains('gitlab/gitlab'): $password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GITLAB')->first(); $data = collect([]); if ($password) { @@ -676,7 +860,7 @@ class Service extends BaseModel $fields->put('GitLab', $data->toArray()); break; - case str($image)->contains('code-server'): + case $image->contains('code-server'): $data = collect([]); $password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_64_PASSWORDCODESERVER')->first(); if ($password) { @@ -702,14 +886,78 @@ class Service extends BaseModel } $fields->put('Code Server', $data->toArray()); break; + case $image->contains('elestio/strapi'): + $data = collect([]); + $license = $this->environment_variables()->where('key', 'STRAPI_LICENSE')->first(); + if ($license) { + $data = $data->merge([ + 'License' => [ + 'key' => data_get($license, 'key'), + 'value' => data_get($license, 'value'), + ], + ]); + } + $nodeEnv = $this->environment_variables()->where('key', 'NODE_ENV')->first(); + if ($nodeEnv) { + $data = $data->merge([ + 'Node Environment' => [ + 'key' => data_get($nodeEnv, 'key'), + 'value' => data_get($nodeEnv, 'value'), + ], + ]); + } + + $fields->put('Strapi', $data->toArray()); + break; + default: + $data = collect([]); + $admin_user = $this->environment_variables()->where('key', 'SERVICE_USER_ADMIN')->first(); + // Chaskiq + $admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first(); + + $admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ADMIN')->first(); + if ($admin_user) { + $data = $data->merge([ + 'User' => [ + 'key' => data_get($admin_user, 'key'), + 'value' => data_get($admin_user, 'value', 'admin'), + 'readonly' => true, + 'rules' => 'required', + ], + ]); + } + if ($admin_password) { + $data = $data->merge([ + 'Password' => [ + 'key' => data_get($admin_password, 'key'), + 'value' => data_get($admin_password, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + if ($admin_email) { + $data = $data->merge([ + 'Email' => [ + 'key' => data_get($admin_email, 'key'), + 'value' => data_get($admin_email, 'value'), + 'rules' => 'required|email', + ], + ]); + } + $fields->put('Admin', $data->toArray()); + break; } } $databases = $this->databases()->get(); foreach ($databases as $database) { - $image = str($database->image)->before(':')->value(); + $image = str($database->image)->before(':'); + if ($image->isEmpty()) { + continue; + } switch ($image) { - case str($image)->contains('postgres'): + case $image->contains('postgres'): $userVariables = ['SERVICE_USER_POSTGRES', 'SERVICE_USER_POSTGRESQL']; $passwordVariables = ['SERVICE_PASSWORD_POSTGRES', 'SERVICE_PASSWORD_POSTGRESQL']; $dbNameVariables = ['POSTGRESQL_DATABASE', 'POSTGRES_DB']; @@ -747,10 +995,10 @@ class Service extends BaseModel } $fields->put('PostgreSQL', $data->toArray()); break; - case str($image)->contains('mysql'): + case $image->contains('mysql'): $userVariables = ['SERVICE_USER_MYSQL', 'SERVICE_USER_WORDPRESS', 'MYSQL_USER']; - $passwordVariables = ['SERVICE_PASSWORD_MYSQL', 'SERVICE_PASSWORD_WORDPRESS', 'MYSQL_PASSWORD']; - $rootPasswordVariables = ['SERVICE_PASSWORD_MYSQLROOT', 'SERVICE_PASSWORD_ROOT']; + $passwordVariables = ['SERVICE_PASSWORD_MYSQL', 'SERVICE_PASSWORD_WORDPRESS', 'MYSQL_PASSWORD','SERVICE_PASSWORD_64_MYSQL']; + $rootPasswordVariables = ['SERVICE_PASSWORD_MYSQLROOT', 'SERVICE_PASSWORD_ROOT','SERVICE_PASSWORD_64_MYSQLROOT']; $dbNameVariables = ['MYSQL_DATABASE']; $mysql_user = $this->environment_variables()->whereIn('key', $userVariables)->first(); $mysql_password = $this->environment_variables()->whereIn('key', $passwordVariables)->first(); @@ -797,7 +1045,7 @@ class Service extends BaseModel } $fields->put('MySQL', $data->toArray()); break; - case str($image)->contains('mariadb'): + case $image->contains('mariadb'): $userVariables = ['SERVICE_USER_MARIADB', 'SERVICE_USER_WORDPRESS', '_APP_DB_USER', 'SERVICE_USER_MYSQL', 'MYSQL_USER']; $passwordVariables = ['SERVICE_PASSWORD_MARIADB', 'SERVICE_PASSWORD_WORDPRESS', '_APP_DB_PASS', 'MYSQL_PASSWORD']; $rootPasswordVariables = ['SERVICE_PASSWORD_MARIADBROOT', 'SERVICE_PASSWORD_ROOT', '_APP_DB_ROOT_PASS', 'MYSQL_ROOT_PASSWORD']; @@ -860,6 +1108,7 @@ class Service extends BaseModel foreach ($fields as $field) { $key = data_get($field, 'key'); $value = data_get($field, 'value'); + ray($key, $value); $found = $this->environment_variables()->where('key', $key)->first(); if ($found) { $found->value = $value; @@ -983,13 +1232,12 @@ class Service extends BaseModel public function environment_variables(): HasMany { - - return $this->hasMany(EnvironmentVariable::class)->orderByRaw("key LIKE 'SERVICE%' DESC, value ASC"); + return $this->hasMany(EnvironmentVariable::class)->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC"); } public function environment_variables_preview(): HasMany { - return $this->hasMany(EnvironmentVariable::class)->where('is_preview', true)->orderBy('key', 'asc'); + return $this->hasMany(EnvironmentVariable::class)->where('is_preview', true)->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC"); } public function workdir() @@ -1027,7 +1275,21 @@ class Service extends BaseModel return 3; }); foreach ($sorted as $env) { - $commands[] = "echo '{$env->key}={$env->real_value}' >> .env"; + if (version_compare($env->version, '4.0.0-beta.347', '<=')) { + $commands[] = "echo '{$env->key}={$env->real_value}' >> .env"; + } else { + $real_value = $env->real_value; + if ($env->version === '4.0.0-beta.239') { + $real_value = $env->real_value; + } else { + if ($env->is_literal || $env->is_multiline) { + $real_value = '\''.$real_value.'\''; + } else { + $real_value = escapeEnvVariables($env->real_value); + } + } + $commands[] = "echo \"{$env->key}={$real_value}\" >> .env"; + } } if ($sorted->count() === 0) { $commands[] = 'touch .env'; @@ -1037,7 +1299,7 @@ class Service extends BaseModel public function parse(bool $isNew = false): Collection { - if ($this->compose_parsing_version === '3') { + if ((int) $this->compose_parsing_version >= 3) { return newParser($this); } elseif ($this->docker_compose_raw) { return parseDockerComposeFile($this, $isNew); @@ -1053,4 +1315,20 @@ class Service extends BaseModel return $networks; } + + protected function isDeployable(): Attribute + { + return Attribute::make( + get: function () { + $envs = $this->environment_variables()->where('is_required', true)->get(); + foreach ($envs as $env) { + if ($env->is_really_required) { + return false; + } + } + return true; + } + ); + } + } diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 6690f254e..0e79e1e2e 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -32,6 +32,16 @@ class ServiceApplication extends BaseModel return ServiceApplication::whereRelation('service.environment.project.team', 'id', $teamId)->orderBy('name'); } + public function isRunning() + { + return str($this->status)->contains('running'); + } + + public function isExited() + { + return str($this->status)->contains('exited'); + } + public function isLogDrainEnabled() { return data_get($this, 'is_log_drain_enabled', false); @@ -102,4 +112,9 @@ class ServiceApplication extends BaseModel { getFilesystemVolumesFromServer($this, $isInit); } + + public function isBackupSolutionAvailable() + { + return false; + } } diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 4a749913e..927527118 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -25,6 +25,16 @@ class ServiceDatabase extends BaseModel remote_process(["docker restart {$container_id}"], $this->service->server); } + public function isRunning() + { + return str($this->status)->contains('running'); + } + + public function isExited() + { + return str($this->status)->contains('exited'); + } + public function isLogDrainEnabled() { return data_get($this, 'is_log_drain_enabled', false); @@ -105,4 +115,13 @@ class ServiceDatabase extends BaseModel { return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); } + + public function isBackupSolutionAvailable() + { + return str($this->databaseType())->contains('mysql') || + str($this->databaseType())->contains('postgres') || + str($this->databaseType())->contains('postgis') || + str($this->databaseType())->contains('mariadb') || + str($this->databaseType())->contains('mongodb'); + } } diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 4cd194cd8..6274f51b2 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -75,6 +75,11 @@ class StandaloneClickhouse extends BaseModel } } + public function isRunning() + { + return (bool) str($this->status)->contains('running'); + } + public function isExited() { return (bool) str($this->status)->startsWith('exited'); @@ -267,7 +272,7 @@ class StandaloneClickhouse extends BaseModel $container_name = $this->uuid; if ($server->isMetricsEnabled()) { $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->metrics_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); + $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); if (str($metrics)->contains('error')) { $error = json_decode($metrics, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); @@ -289,4 +294,9 @@ class StandaloneClickhouse extends BaseModel return $parsedCollection->toArray(); } } + + public function isBackupSolutionAvailable() + { + return false; + } } diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 8726b2546..3555e7afd 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -75,6 +75,11 @@ class StandaloneDragonfly extends BaseModel } } + public function isRunning() + { + return (bool) str($this->status)->contains('running'); + } + public function isExited() { return (bool) str($this->status)->startsWith('exited'); @@ -267,7 +272,7 @@ class StandaloneDragonfly extends BaseModel $container_name = $this->uuid; if ($server->isMetricsEnabled()) { $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->metrics_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); + $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); if (str($metrics)->contains('error')) { $error = json_decode($metrics, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); @@ -289,4 +294,9 @@ class StandaloneDragonfly extends BaseModel return $parsedCollection->toArray(); } } + + public function isBackupSolutionAvailable() + { + return false; + } } diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 7ecb00348..4725ca533 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -75,6 +75,11 @@ class StandaloneKeydb extends BaseModel } } + public function isRunning() + { + return (bool) str($this->status)->contains('running'); + } + public function isExited() { return (bool) str($this->status)->startsWith('exited'); @@ -267,7 +272,7 @@ class StandaloneKeydb extends BaseModel $container_name = $this->uuid; if ($server->isMetricsEnabled()) { $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->metrics_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); + $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); if (str($metrics)->contains('error')) { $error = json_decode($metrics, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); @@ -289,4 +294,9 @@ class StandaloneKeydb extends BaseModel return $parsedCollection->toArray(); } } + + public function isBackupSolutionAvailable() + { + return false; + } } diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index d88653e41..8f1a2c1ee 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -75,6 +75,11 @@ class StandaloneMariadb extends BaseModel } } + public function isRunning() + { + return (bool) str($this->status)->contains('running'); + } + public function isExited() { return (bool) str($this->status)->startsWith('exited'); @@ -267,7 +272,7 @@ class StandaloneMariadb extends BaseModel $container_name = $this->uuid; if ($server->isMetricsEnabled()) { $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->metrics_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); + $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); if (str($metrics)->contains('error')) { $error = json_decode($metrics, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); @@ -289,4 +294,9 @@ class StandaloneMariadb extends BaseModel return $parsedCollection->toArray(); } } + + public function isBackupSolutionAvailable() + { + return true; + } } diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index f09e932bf..41b2ce9eb 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -79,6 +79,11 @@ class StandaloneMongodb extends BaseModel } } + public function isRunning() + { + return (bool) str($this->status)->contains('running'); + } + public function isExited() { return (bool) str($this->status)->startsWith('exited'); @@ -287,7 +292,7 @@ class StandaloneMongodb extends BaseModel $container_name = $this->uuid; if ($server->isMetricsEnabled()) { $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->metrics_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); + $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); if (str($metrics)->contains('error')) { $error = json_decode($metrics, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); @@ -309,4 +314,9 @@ class StandaloneMongodb extends BaseModel return $parsedCollection->toArray(); } } + + public function isBackupSolutionAvailable() + { + return true; + } } diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index f4e56fab2..da2ac070f 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -76,6 +76,11 @@ class StandaloneMysql extends BaseModel } } + public function isRunning() + { + return (bool) str($this->status)->contains('running'); + } + public function isExited() { return (bool) str($this->status)->startsWith('exited'); @@ -268,7 +273,7 @@ class StandaloneMysql extends BaseModel $container_name = $this->uuid; if ($server->isMetricsEnabled()) { $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->metrics_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); + $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); if (str($metrics)->contains('error')) { $error = json_decode($metrics, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); @@ -290,4 +295,9 @@ class StandaloneMysql extends BaseModel return $parsedCollection->toArray(); } } + + public function isBackupSolutionAvailable() + { + return true; + } } diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 311c09c36..e0f42269d 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -102,6 +102,11 @@ class StandalonePostgresql extends BaseModel } } + public function isRunning() + { + return (bool) str($this->status)->contains('running'); + } + public function isExited() { return (bool) str($this->status)->startsWith('exited'); @@ -269,7 +274,7 @@ class StandalonePostgresql extends BaseModel $container_name = $this->uuid; if ($server->isMetricsEnabled()) { $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->metrics_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); + $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); if (str($metrics)->contains('error')) { $error = json_decode($metrics, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); @@ -291,4 +296,9 @@ class StandalonePostgresql extends BaseModel return $parsedCollection->toArray(); } } + + public function isBackupSolutionAvailable() + { + return true; + } } diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 8a202ea9e..fe9f6dfc7 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -71,6 +71,11 @@ class StandaloneRedis extends BaseModel } } + public function isRunning() + { + return (bool) str($this->status)->contains('running'); + } + public function isExited() { return (bool) str($this->status)->startsWith('exited'); @@ -263,7 +268,7 @@ class StandaloneRedis extends BaseModel $container_name = $this->uuid; if ($server->isMetricsEnabled()) { $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->metrics_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); + $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/metrics/history?from=$from'"], $server, false); if (str($metrics)->contains('error')) { $error = json_decode($metrics, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); @@ -285,4 +290,9 @@ class StandaloneRedis extends BaseModel return $parsedCollection->toArray(); } } + + public function isBackupSolutionAvailable() + { + return false; + } } diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index 549fc6cd3..cc7d76ebf 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -13,7 +13,7 @@ class TransactionalEmailChannel { public function send(User $notifiable, Notification $notification): void { - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) { Log::info('SMTP/Resend not enabled'); diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index c0e2a3c31..6377f2f15 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -52,7 +52,7 @@ class ForceDisabled extends Notification implements ShouldQueue public function toDiscord(): string { - $message = "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subsciprtions)."; + $message = "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subscriptions)."; return $message; } @@ -60,7 +60,7 @@ class ForceDisabled extends Notification implements ShouldQueue public function toTelegram(): array { return [ - 'message' => "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subsciprtions).", + 'message' => "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subscriptions).", ]; } } diff --git a/app/Notifications/TransactionalEmails/ResetPassword.php b/app/Notifications/TransactionalEmails/ResetPassword.php index 8b1c02d39..3938a8da7 100644 --- a/app/Notifications/TransactionalEmails/ResetPassword.php +++ b/app/Notifications/TransactionalEmails/ResetPassword.php @@ -18,7 +18,7 @@ class ResetPassword extends Notification public function __construct($token) { - $this->settings = \App\Models\InstanceSettings::get(); + $this->settings = instanceSettings(); $this->token = $token; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index cd90918ad..8b4c2eef2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,10 +2,8 @@ namespace App\Providers; -use App\Models\InstanceSettings; use App\Models\PersonalAccessToken; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Laravel\Sanctum\Sanctum; @@ -30,9 +28,5 @@ class AppServiceProvider extends ServiceProvider ])->baseUrl($api_url); } }); - // if (! env('CI')) { - // View::share('instanceSettings', InstanceSettings::get()); - // } - } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 53a2e9281..b916b6234 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -46,7 +46,7 @@ class FortifyServiceProvider extends ServiceProvider Fortify::registerView(function () { $isFirstUser = User::count() === 0; - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); if (! $settings->is_registration_enabled) { return redirect()->route('login'); } @@ -60,7 +60,7 @@ class FortifyServiceProvider extends ServiceProvider }); Fortify::loginView(function () { - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); $enabled_oauth_providers = OauthSetting::where('enabled', true)->get(); $users = User::count(); if ($users == 0) { diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 9b58882eb..f8ccee9db 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -3,6 +3,7 @@ namespace App\Traits; use App\Enums\ApplicationDeploymentStatus; +use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use Carbon\Carbon; use Illuminate\Support\Collection; @@ -42,7 +43,7 @@ trait ExecuteRemoteCommand $command = parseLineForSudo($command, $this->server); } } - $remote_command = generateSshCommand($this->server, $command); + $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command); $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) { $output = str($output)->trim(); if ($output->startsWith('╔')) { diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 6c9378cac..fbd7b0b15 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -22,6 +22,7 @@ class Input extends Component public bool $allowToPeak = true, public bool $isMultiline = false, public string $defaultClass = 'input', + public string $autocomplete = 'off', ) {} public function render(): View|Closure|string diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 8e14ef9ee..d7c16b607 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -2,6 +2,7 @@ use App\Enums\BuildPackTypes; use App\Enums\RedirectTypes; +use App\Enums\StaticImageTypes; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -89,6 +90,7 @@ function sharedDataApplications() 'git_branch' => 'string', 'build_pack' => Rule::enum(BuildPackTypes::class), 'is_static' => 'boolean', + 'static_image' => Rule::enum(StaticImageTypes::class), 'domains' => 'string', 'redirect' => Rule::enum(RedirectTypes::class), 'git_commit_sha' => 'string', @@ -175,4 +177,6 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('instant_deploy'); $request->offsetUnset('github_app_uuid'); $request->offsetUnset('private_key_uuid'); + $request->offsetUnset('use_build_server'); + $request->offsetUnset('is_static'); } diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 1eeec8f94..303fcab8e 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -20,12 +20,16 @@ const RESTART_MODE = 'unless-stopped'; const DATABASE_DOCKER_IMAGES = [ 'bitnami/mariadb', 'bitnami/mongodb', - 'bitnami/mysql', - 'bitnami/postgresql', 'bitnami/redis', 'mysql', + 'bitnami/mysql', + 'mysql/mysql-server', 'mariadb', + 'postgis/postgis', 'postgres', + 'bitnami/postgresql', + 'supabase/postgres', + 'elestio/postgres', 'mongo', 'redis', 'memcached', @@ -33,10 +37,10 @@ const DATABASE_DOCKER_IMAGES = [ 'neo4j', 'influxdb', 'clickhouse/clickhouse-server', - 'supabase/postgres', ]; const SPECIFIC_SERVICES = [ 'quay.io/minio/minio', + 'minio/minio', 'svhd/logto', ]; diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 90093deb8..397bce029 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -40,6 +40,20 @@ function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pul return $containers; } +function getCurrentServiceContainerStatus(Server $server, int $id): Collection +{ + $containers = collect([]); + if (! $server->isSwarm()) { + $containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --format '{{json .}}' "], $server); + $containers = format_docker_command_output_to_json($containers); + $containers = $containers->filter(); + + return $containers; + } + + return $containers; +} + function format_docker_command_output_to_json($rawOutput): Collection { $outputLines = explode(PHP_EOL, $rawOutput); @@ -120,6 +134,9 @@ function getContainerStatus(Server $server, string $container_id, bool $all_data return 'exited'; } $container = format_docker_command_output_to_json($container); + if ($container->isEmpty()) { + return 'exited'; + } if ($all_data) { return $container[0]; } @@ -215,12 +232,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource) } if (is_null($MINIO_BROWSER_REDIRECT_URL?->value)) { $MINIO_BROWSER_REDIRECT_URL?->update([ - 'value' => generateFqdn($server, 'console-'.$uuid), + 'value' => generateFqdn($server, 'console-'.$uuid, true), ]); } if (is_null($MINIO_SERVER_URL?->value)) { $MINIO_SERVER_URL?->update([ - 'value' => generateFqdn($server, 'minio-'.$uuid), + 'value' => generateFqdn($server, 'minio-'.$uuid, true), ]); } $payload = collect([ @@ -308,38 +325,20 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $labels->push('traefik.http.middlewares.gzip.compress=true'); $labels->push('traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https'); - $basic_auth = false; - $basic_auth_middleware = null; - $redirect = false; - $redirect_middleware = null; + $middlewares_from_labels = collect([]); if ($serviceLabels) { - $basic_auth = $serviceLabels->contains(function ($value) { - return str_contains($value, 'basicauth'); - }); - if ($basic_auth) { - $basic_auth_middleware = $serviceLabels - ->map(function ($item) { - if (preg_match('/traefik\.http\.middlewares\.(.*?)\.basicauth\.users/', $item, $matches)) { - return $matches[1]; - } - }) - ->filter() - ->first(); - } - $redirect = $serviceLabels->contains(function ($value) { - return str_contains($value, 'redirectregex'); - }); - if ($redirect) { - $redirect_middleware = $serviceLabels - ->map(function ($item) { - if (preg_match('/traefik\.http\.middlewares\.(.*?)\.redirectregex\.regex/', $item, $matches)) { - return $matches[1]; - } - }) - ->filter() - ->first(); - } + $middlewares_from_labels = $serviceLabels->map(function ($item) { + if (preg_match('/traefik\.http\.middlewares\.(.*?)(\.|$)/', $item, $matches)) { + return $matches[1]; + } + if (preg_match('/coolify\.traefik\.middlewares=(.*)/', $item, $matches)) { + return explode(',', $matches[1]); + } + return null; + })->flatten() + ->filter() + ->unique(); } foreach ($domains as $loop => $domain) { try { @@ -387,20 +386,15 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $labels->push("traefik.http.services.{$https_label}.loadbalancer.server.port=$port"); } if ($path !== '/') { + // Middleware handling $middlewares = collect([]); - if ($is_stripprefix_enabled && ! str($image)->contains('ghost')) { + if ($is_stripprefix_enabled && !str($image)->contains('ghost')) { $labels->push("traefik.http.middlewares.{$https_label}-stripprefix.stripprefix.prefixes={$path}"); $middlewares->push("{$https_label}-stripprefix"); } if ($is_gzip_enabled) { $middlewares->push('gzip'); } - if ($basic_auth && $basic_auth_middleware) { - $middlewares->push($basic_auth_middleware); - } - if ($redirect && $redirect_middleware) { - $middlewares->push($redirect_middleware); - } if (str($image)->contains('ghost')) { $middlewares->push('redir-ghost'); } @@ -408,10 +402,13 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $labels = $labels->merge($redirect_to_non_www); $middlewares->push($to_non_www_name); } - if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) { + if ($redirect_direction === 'www' && !str($host)->startsWith('www.')) { $labels = $labels->merge($redirect_to_www); $middlewares->push($to_www_name); } + $middlewares_from_labels->each(function ($middleware_name) use ($middlewares) { + $middlewares->push($middleware_name); + }); if ($middlewares->isNotEmpty()) { $middlewares = $middlewares->join(','); $labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares}"); @@ -420,13 +417,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $middlewares = collect([]); if ($is_gzip_enabled) { $middlewares->push('gzip'); - } - if ($basic_auth && $basic_auth_middleware) { - $middlewares->push($basic_auth_middleware); - } - if ($redirect && $redirect_middleware) { - $middlewares->push($redirect_middleware); - } + } if (str($image)->contains('ghost')) { $middlewares->push('redir-ghost'); } @@ -438,6 +429,9 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $labels = $labels->merge($redirect_to_www); $middlewares->push($to_www_name); } + $middlewares_from_labels->each(function ($middleware_name) use ($middlewares) { + $middlewares->push($middleware_name); + }); if ($middlewares->isNotEmpty()) { $middlewares = $middlewares->join(','); $labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares}"); @@ -473,12 +467,6 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ if ($is_gzip_enabled) { $middlewares->push('gzip'); } - if ($basic_auth && $basic_auth_middleware) { - $middlewares->push($basic_auth_middleware); - } - if ($redirect && $redirect_middleware) { - $middlewares->push($redirect_middleware); - } if (str($image)->contains('ghost')) { $middlewares->push('redir-ghost'); } @@ -490,6 +478,9 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $labels = $labels->merge($redirect_to_www); $middlewares->push($to_www_name); } + $middlewares_from_labels->each(function ($middleware_name) use ($middlewares) { + $middlewares->push($middleware_name); + }); if ($middlewares->isNotEmpty()) { $middlewares = $middlewares->join(','); $labels->push("traefik.http.routers.{$http_label}.middlewares={$middlewares}"); @@ -499,12 +490,6 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ if ($is_gzip_enabled) { $middlewares->push('gzip'); } - if ($basic_auth && $basic_auth_middleware) { - $middlewares->push($basic_auth_middleware); - } - if ($redirect && $redirect_middleware) { - $middlewares->push($redirect_middleware); - } if (str($image)->contains('ghost')) { $middlewares->push('redir-ghost'); } @@ -516,6 +501,9 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $labels = $labels->merge($redirect_to_www); $middlewares->push($to_www_name); } + $middlewares_from_labels->each(function ($middleware_name) use ($middlewares) { + $middlewares->push($middleware_name); + }); if ($middlewares->isNotEmpty()) { $middlewares = $middlewares->join(','); $labels->push("traefik.http.routers.{$http_label}.middlewares={$middlewares}"); diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index c4c15b8fe..e2693a2cd 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -96,6 +96,8 @@ function connectProxyToNetworks(Server $server) "echo 'Connecting coolify-proxy to $network network...'", "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --driver overlay --attachable $network >/dev/null", "docker network connect $network coolify-proxy >/dev/null 2>&1 || true", + "echo 'Successfully connected coolify-proxy to $network network.'", + "echo 'Proxy started and configured successfully!'", ]; }); } else { @@ -104,6 +106,8 @@ function connectProxyToNetworks(Server $server) "echo 'Connecting coolify-proxy to $network network...'", "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --attachable $network >/dev/null", "docker network connect $network coolify-proxy >/dev/null 2>&1 || true", + "echo 'Successfully connected coolify-proxy to $network network.'", + "echo 'Proxy started and configured successfully!'", ]; }); } @@ -144,6 +148,7 @@ function generate_default_proxy_configuration(Server $server) 'traefik.http.routers.traefik.service=api@internal', 'traefik.http.services.traefik.loadbalancer.server.port=8080', 'coolify.managed=true', + 'coolify.proxy=true', ]; $config = [ 'networks' => $array_of_networks->toArray(), @@ -159,6 +164,7 @@ function generate_default_proxy_configuration(Server $server) 'ports' => [ '80:80', '443:443', + '443:443/udp', '8080:8080', ], 'healthcheck' => [ @@ -182,6 +188,7 @@ function generate_default_proxy_configuration(Server $server) '--entryPoints.http.http2.maxConcurrentStreams=50', '--entrypoints.https.http.encodequerysemicolons=true', '--entryPoints.https.http2.maxConcurrentStreams=50', + '--entrypoints.https.http3', '--providers.docker.exposedbydefault=false', '--providers.file.directory=/traefik/dynamic/', '--providers.file.watch=true', @@ -217,7 +224,6 @@ function generate_default_proxy_configuration(Server $server) } } elseif ($proxy_type === 'CADDY') { $config = [ - 'version' => '3.8', 'networks' => $array_of_networks->toArray(), 'services' => [ 'caddy' => [ @@ -235,13 +241,11 @@ function generate_default_proxy_configuration(Server $server) 'ports' => [ '80:80', '443:443', + '443:443/udp', + ], + 'labels' => [ + 'coolify.managed=true', ], - // "healthcheck" => [ - // "test" => "wget -qO- http://localhost:80|| exit 1", - // "interval" => "4s", - // "timeout" => "2s", - // "retries" => 5, - // ], 'volumes' => [ '/var/run/docker.sock:/var/run/docker.sock:ro', "{$proxy_path}/dynamic:/dynamic", diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 4ba378e67..67b60d6b7 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -3,6 +3,7 @@ use App\Actions\CoolifyTask\PrepareCoolifyTask; use App\Data\CoolifyTaskArgs; use App\Enums\ActivityTypes; +use App\Helpers\SshMultiplexingHelper; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\PrivateKey; @@ -10,9 +11,8 @@ use App\Models\Server; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Process; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Spatie\Activitylog\Contracts\Activity; @@ -26,29 +26,28 @@ function remote_process( $callEventOnFinish = null, $callEventData = null ): Activity { - if (is_null($type)) { - $type = ActivityTypes::INLINE->value; - } - if ($command instanceof Collection) { - $command = $command->toArray(); - } + $type = $type ?? ActivityTypes::INLINE->value; + $command = $command instanceof Collection ? $command->toArray() : $command; + if ($server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $server); } + $command_string = implode("\n", $command); - if (auth()->user()) { - $teams = auth()->user()->teams->pluck('id'); + + if (Auth::check()) { + $teams = Auth::user()->teams->pluck('id'); if (! $teams->contains($server->team_id) && ! $teams->contains(0)) { throw new \Exception('User is not part of the team that owns this server'); } } + SshMultiplexingHelper::ensureMultiplexedConnection($server); + return resolve(PrepareCoolifyTask::class, [ 'remoteProcessArgs' => new CoolifyTaskArgs( server_uuid: $server->uuid, - command: <<muxFilename(); - return [ - 'location' => $location, - 'mux_filename' => $mux_filename, - 'private_key_filename' => $private_key_filename, - ]; -} -function savePrivateKeyToFs(Server $server) -{ - if (data_get($server, 'privateKey.private_key') === null) { - throw new \Exception("Server {$server->name} does not have a private key"); - } - ['location' => $location, 'private_key_filename' => $private_key_filename] = server_ssh_configuration($server); - Storage::disk('ssh-keys')->makeDirectory('.'); - Storage::disk('ssh-mux')->makeDirectory('.'); - Storage::disk('ssh-keys')->put($private_key_filename, $server->privateKey->private_key); - - return $location; -} - -function generateScpCommand(Server $server, string $source, string $dest) -{ - $user = $server->user; - $port = $server->port; - $privateKeyLocation = savePrivateKeyToFs($server); - $timeout = config('constants.ssh.command_timeout'); - $connectionTimeout = config('constants.ssh.connection_timeout'); - $serverInterval = config('constants.ssh.server_interval'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $scp_command = "timeout $timeout scp "; - $muxEnabled = config('constants.ssh.mux_enabled', true) && config('coolify.is_windows_docker_desktop') == false; - // ray('SSH Multiplexing Enabled:', $muxEnabled)->blue(); - - if ($muxEnabled) { - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$server->muxFilename()}"; - $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - ensureMultiplexedConnection($server); - // ray('Using SSH Multiplexing')->green(); - } else { - // ray('Not using SSH Multiplexing')->red(); - } - - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $scp_command .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" '; - } - $scp_command .= "-i {$privateKeyLocation} " - .'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ' - .'-o PasswordAuthentication=no ' - ."-o ConnectTimeout=$connectionTimeout " - ."-o ServerAliveInterval=$serverInterval " - .'-o RequestTTY=no ' - .'-o LogLevel=ERROR ' - ."-P {$port} " - ."{$source} " - ."{$user}@{$server->ip}:{$dest}"; - - return $scp_command; -} function instant_scp(string $source, string $dest, Server $server, $throwError = true) { - $timeout = config('constants.ssh.command_timeout'); - $scp_command = generateScpCommand($server, $source, $dest); - $process = Process::timeout($timeout)->run($scp_command); + $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest); + $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command); $output = trim($process->output()); $exitCode = $process->exitCode(); if ($exitCode !== 0) { - if (! $throwError) { - return null; - } - - return excludeCertainErrors($process->errorOutput(), $exitCode); - } - if ($output === 'null') { - $output = null; + return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null; } - return $output; -} -function generateSshCommand(Server $server, string $command) -{ - if ($server->settings->force_disabled) { - throw new \RuntimeException('Server is disabled.'); - } - $user = $server->user; - $port = $server->port; - $privateKeyLocation = savePrivateKeyToFs($server); - $timeout = config('constants.ssh.command_timeout'); - $connectionTimeout = config('constants.ssh.connection_timeout'); - $serverInterval = config('constants.ssh.server_interval'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $ssh_command = "timeout $timeout ssh "; - - $muxEnabled = config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false; - // ray('SSH Multiplexing Enabled:', $muxEnabled)->blue(); - if ($muxEnabled) { - // Always use multiplexing when enabled - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$server->muxFilename()}"; - $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - ensureMultiplexedConnection($server); - // ray('Using SSH Multiplexing')->green(); - } else { - // ray('Not using SSH Multiplexing')->red(); - } - - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $ssh_command .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" '; - } - $command = "PATH=\$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/host/usr/local/sbin:/host/usr/local/bin:/host/usr/sbin:/host/usr/bin:/host/sbin:/host/bin && $command"; - $delimiter = Hash::make($command); - $command = str_replace($delimiter, '', $command); - $ssh_command .= "-i {$privateKeyLocation} " - .'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ' - .'-o PasswordAuthentication=no ' - ."-o ConnectTimeout=$connectionTimeout " - ."-o ServerAliveInterval=$serverInterval " - .'-o RequestTTY=no ' - .'-o LogLevel=ERROR ' - ."-p {$port} " - ."{$user}@{$server->ip} " - ." 'bash -se' << \\$delimiter".PHP_EOL - .$command.PHP_EOL - .$delimiter; - - return $ssh_command; -} - -function ensureMultiplexedConnection(Server $server) -{ - if (! (config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false)) { - return; - } - - static $ensuredConnections = []; - - if (isset($ensuredConnections[$server->id])) { - if (! shouldResetMultiplexedConnection($server)) { - // ray('Using Existing Multiplexed Connection')->green(); - - return; - } - } - - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$server->muxFilename()}"; - $checkCommand = "ssh -O check -o ControlPath=$muxSocket "; - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $checkCommand .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" '; - } - $checkCommand .= " {$server->user}@{$server->ip}"; - - $process = Process::run($checkCommand); - - if ($process->exitCode() === 0) { - // ray('Existing Multiplexed Connection is Valid')->green(); - $ensuredConnections[$server->id] = [ - 'timestamp' => now(), - 'muxSocket' => $muxSocket, - ]; - - return; - } - - // ray('Establishing New Multiplexed Connection')->orange(); - - $privateKeyLocation = savePrivateKeyToFs($server); - $connectionTimeout = config('constants.ssh.connection_timeout'); - $serverInterval = config('constants.ssh.server_interval'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $establishCommand .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" '; - } - $establishCommand .= "-i {$privateKeyLocation} " - .'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ' - .'-o PasswordAuthentication=no ' - ."-o ConnectTimeout=$connectionTimeout " - ."-o ServerAliveInterval=$serverInterval " - .'-o RequestTTY=no ' - .'-o LogLevel=ERROR ' - ."-p {$server->port} " - ."{$server->user}@{$server->ip}"; - - $establishProcess = Process::run($establishCommand); - - if ($establishProcess->exitCode() !== 0) { - throw new \RuntimeException('Failed to establish multiplexed connection: '.$establishProcess->errorOutput()); - } - - $ensuredConnections[$server->id] = [ - 'timestamp' => now(), - 'muxSocket' => $muxSocket, - ]; - - // ray('Established New Multiplexed Connection')->green(); -} - -function shouldResetMultiplexedConnection(Server $server) -{ - if (! (config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false)) { - return false; - } - - static $ensuredConnections = []; - - if (! isset($ensuredConnections[$server->id])) { - return true; - } - - $lastEnsured = $ensuredConnections[$server->id]['timestamp']; - $muxPersistTime = config('constants.ssh.mux_persist_time'); - $resetInterval = strtotime($muxPersistTime) - time(); - - return $lastEnsured->addSeconds($resetInterval)->isPast(); -} - -function resetMultiplexedConnection(Server $server) -{ - if (! (config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false)) { - return; - } - - static $ensuredConnections = []; - - if (isset($ensuredConnections[$server->id])) { - $muxSocket = $ensuredConnections[$server->id]['muxSocket']; - $closeCommand = "ssh -O exit -o ControlPath=$muxSocket {$server->user}@{$server->ip}"; - Process::run($closeCommand); - unset($ensuredConnections[$server->id]); - } + return $output === 'null' ? null : $output; } function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string { - static $processCount = 0; - $processCount++; - - $timeout = config('constants.ssh.command_timeout'); - if ($command instanceof Collection) { - $command = $command->toArray(); - } + $command = $command instanceof Collection ? $command->toArray() : $command; if ($server->isNonRoot() && ! $no_sudo) { $command = parseCommandsByLineForSudo(collect($command), $server); } $command_string = implode("\n", $command); - $start_time = microtime(true); - $sshCommand = generateSshCommand($server, $command_string); - $process = Process::timeout($timeout)->run($sshCommand); - $end_time = microtime(true); + // $start_time = microtime(true); + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); + $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand); + // $end_time = microtime(true); - $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds + // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds // ray('SSH command execution time:', $execution_time.' ms')->orange(); $output = trim($process->output()); $exitCode = $process->exitCode(); if ($exitCode !== 0) { - if (! $throwError) { - return null; - } - - return excludeCertainErrors($process->errorOutput(), $exitCode); - } - if ($output === 'null') { - $output = null; + return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null; } - return $output; + return $output === 'null' ? null : $output; } + function excludeCertainErrors(string $errorOutput, ?int $exitCode = null) { $ignoredErrors = collect([ 'Permission denied (publickey', 'Could not resolve hostname', ]); - $ignored = false; - foreach ($ignoredErrors as $ignoredError) { - if (Str::contains($errorOutput, $ignoredError)) { - $ignored = true; - break; - } - } + $ignored = $ignoredErrors->contains(fn ($error) => Str::contains($errorOutput, $error)); if ($ignored) { // TODO: Create new exception and disable in sentry throw new \RuntimeException($errorOutput, $exitCode); } throw new \RuntimeException($errorOutput, $exitCode); } + function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null): Collection { - $application = Application::find(data_get($application_deployment_queue, 'application_id')); - $is_debug_enabled = data_get($application, 'settings.is_debug_enabled'); if (is_null($application_deployment_queue)) { return collect([]); } + $application = Application::find(data_get($application_deployment_queue, 'application_id')); + $is_debug_enabled = data_get($application, 'settings.is_debug_enabled'); try { $decoded = json_decode( data_get($application_deployment_queue, 'logs'), @@ -379,7 +132,8 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d if (! $is_debug_enabled) { $formatted = $formatted->filter(fn ($i) => $i['hidden'] === false ?? false); } - $formatted = $formatted + + return $formatted ->sortBy(fn ($i) => data_get($i, 'order')) ->map(function ($i) { data_set($i, 'timestamp', Carbon::parse(data_get($i, 'timestamp'))->format('Y-M-d H:i:s.u')); @@ -421,36 +175,22 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d return $deploymentLogLines; }, collect()); - - return $formatted; } + function remove_iip($text) { $text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text); return preg_replace('/\x1b\[[0-9;]*m/', '', $text); } -function remove_mux_and_private_key(Server $server) -{ - $muxFilename = $server->muxFilename(); - $privateKeyLocation = savePrivateKeyToFs($server); - $closeCommand = "ssh -O exit -o ControlPath=/var/www/html/storage/app/ssh/mux/{$muxFilename} {$server->user}@{$server->ip}"; - Process::run($closeCommand); - - Storage::disk('ssh-mux')->delete($muxFilename); - Storage::disk('ssh-keys')->delete($privateKeyLocation); -} function refresh_server_connection(?PrivateKey $private_key = null) { if (is_null($private_key)) { return; } foreach ($private_key->servers as $server) { - $muxFilename = $server->muxFilename(); - $closeCommand = "ssh -O exit -o ControlPath=/var/www/html/storage/app/ssh/mux/{$muxFilename} {$server->user}@{$server->ip}"; - Process::run($closeCommand); - Storage::disk('ssh-mux')->delete($muxFilename); + SshMultiplexingHelper::removeMuxFile($server); } } @@ -468,9 +208,8 @@ function checkRequiredCommands(Server $server) break; } $commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false); - if ($commandFound) { - continue; + if (! $commandFound) { + break; } - break; } } diff --git a/bootstrap/helpers/s3.php b/bootstrap/helpers/s3.php index 4a2252016..2ee7bf44a 100644 --- a/bootstrap/helpers/s3.php +++ b/bootstrap/helpers/s3.php @@ -1,14 +1,11 @@ endpoint) { - $is_digital_ocean = Str::contains($s3->endpoint, 'digitaloceanspaces.com'); - } + config()->set('filesystems.disks.custom-s3', [ 'driver' => 's3', 'region' => $s3['region'], @@ -17,7 +14,7 @@ function set_s3_target(S3Storage $s3) 'bucket' => $s3['bucket'], 'endpoint' => $s3['endpoint'], 'use_path_style_endpoint' => true, - 'bucket_endpoint' => $is_digital_ocean, + 'bucket_endpoint' => $s3->isHetzner() || $s3->isDigitalOcean(), 'aws_url' => $s3->awsUrl(), ]); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 028d20f33..14f44ed47 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -126,7 +126,7 @@ function refreshSession(?Team $team = null): void } function handleError(?Throwable $error = null, ?Livewire\Component $livewire = null, ?string $customErrorMessage = null) { - ray($error); + loggy($error); if ($error instanceof TooManyRequestsException) { if (isset($livewire)) { return $livewire->dispatch('error', "Too many requests. Please try again in {$error->secondsUntilAvailable} seconds."); @@ -164,10 +164,10 @@ function get_route_parameters(): array function get_latest_sentinel_version(): string { try { - $response = Http::get('https://cdn.coollabs.io/sentinel/versions.json'); + $response = Http::get('https://cdn.coollabs.io/coolify/versions.json'); $versions = $response->json(); - return data_get($versions, 'sentinel.version'); + return data_get($versions, 'coolify.sentinel.version'); } catch (\Throwable $e) { //throw $e; ray($e->getMessage()); @@ -247,7 +247,7 @@ function is_transactional_emails_active(): bool function set_transanctional_email_settings(?InstanceSettings $settings = null): ?string { if (! $settings) { - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); } config()->set('mail.from.address', data_get($settings, 'smtp_from_address')); config()->set('mail.from.name', data_get($settings, 'smtp_from_name')); @@ -281,7 +281,7 @@ function base_ip(): string if (isDev()) { return 'localhost'; } - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); if ($settings->public_ipv4) { return "$settings->public_ipv4"; } @@ -309,7 +309,7 @@ function getFqdnWithoutPort(string $fqdn) */ function base_url(bool $withPort = true): string { - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); if ($settings->fqdn) { return $settings->fqdn; } @@ -343,6 +343,11 @@ function isSubscribed() { return isSubscriptionActive() || auth()->user()->isInstanceAdmin(); } + +function isProduction(): bool +{ + return ! isDev(); +} function isDev(): bool { return config('app.env') === 'local'; @@ -384,7 +389,7 @@ function send_internal_notification(string $message): void } function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null): void { - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); $type = set_transanctional_email_settings($settings); if (! $type) { throw new Exception('No email settings found.'); @@ -478,7 +483,7 @@ function data_get_str($data, $key, $default = null): Stringable return str($str); } -function generateFqdn(Server $server, string $random): string +function generateFqdn(Server $server, string $random, bool $forceHttps = false): string { $wildcard = data_get($server, 'settings.wildcard_domain'); if (is_null($wildcard) || $wildcard === '') { @@ -488,6 +493,9 @@ function generateFqdn(Server $server, string $random): string $host = $url->getHost(); $path = $url->getPath() === '/' ? '' : $url->getPath(); $scheme = $url->getScheme(); + if ($forceHttps) { + $scheme = 'https'; + } $finalFqdn = "$scheme://{$random}.$host$path"; return $finalFqdn; @@ -502,12 +510,23 @@ function sslip(Server $server) return "http://$baseIp.sslip.io"; } + // ipv6 + if (str($server->ip)->contains(':')) { + $ipv6 = str($server->ip)->replace(':', '-'); + + return "http://{$ipv6}.sslip.io"; + } return "http://{$server->ip}.sslip.io"; } function get_service_templates(bool $force = false): Collection { + if (isDev()) { + $services = File::get(base_path('templates/service-templates.json')); + + return collect(json_decode($services))->sortKeys(); + } if ($force) { try { $response = Http::retry(3, 1000)->get(config('constants.services.official')); @@ -694,7 +713,9 @@ function getTopLevelNetworks(Service|Application $resource) return $value == $networkName || $key == $networkName; }); if (! $networkExists) { - $topLevelNetworks->put($networkDetails, null); + if (is_string($networkDetails) || is_int($networkDetails)) { + $topLevelNetworks->put($networkDetails, null); + } } } } @@ -744,7 +765,9 @@ function getTopLevelNetworks(Service|Application $resource) return $value == $networkName || $key == $networkName; }); if (! $networkExists) { - $topLevelNetworks->put($networkDetails, null); + if (is_string($networkDetails) || is_int($networkDetails)) { + $topLevelNetworks->put($networkDetails, null); + } } } } @@ -786,7 +809,7 @@ function replaceLocalSource(Stringable $source, Stringable $replacedWith) if ($source->startsWith('..')) { $source = $source->replaceFirst('..', $replacedWith->value()); } - if ($source->endsWith('/')) { + if ($source->endsWith('/') && $source->value() !== '/') { $source = $source->replaceLast('/', ''); } @@ -810,6 +833,31 @@ function convertToArray($collection) return $collection; } +function parseCommandFromMagicEnvVariable(Str|string $key): Stringable +{ + $value = str($key); + $count = substr_count($value->value(), '_'); + if ($count === 2) { + if ($value->startsWith('SERVICE_FQDN') || $value->startsWith('SERVICE_URL')) { + // SERVICE_FQDN_UMAMI + $command = $value->after('SERVICE_')->beforeLast('_'); + } else { + // SERVICE_BASE64_UMAMI + $command = $value->after('SERVICE_')->beforeLast('_'); + } + } + if ($count === 3) { + if ($value->startsWith('SERVICE_FQDN') || $value->startsWith('SERVICE_URL')) { + // SERVICE_FQDN_UMAMI_1000 + $command = $value->after('SERVICE_')->before('_'); + } else { + // SERVICE_BASE64_64_UMAMI + $command = $value->after('SERVICE_')->beforeLast('_'); + } + } + + return str($command); +} function parseEnvVariable(Str|string $value) { $value = str($value); @@ -841,6 +889,7 @@ function parseEnvVariable(Str|string $value) } else { // SERVICE_BASE64_64_UMAMI $command = $value->after('SERVICE_')->beforeLast('_'); + ray($command); } } } @@ -961,7 +1010,7 @@ function validate_dns_entry(string $fqdn, Server $server) if (str($host)->contains('sslip.io')) { return true; } - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); $is_dns_validation_enabled = data_get($settings, 'is_dns_validation_enabled'); if (! $is_dns_validation_enabled) { return true; @@ -1081,7 +1130,7 @@ function checkIfDomainIsAlreadyUsed(Collection|array $domains, ?string $teamId = if ($domainFound) { return true; } - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); if (data_get($settings, 'fqdn')) { $domain = data_get($settings, 'fqdn'); if (str($domain)->endsWith('/')) { @@ -1125,10 +1174,10 @@ function check_domain_usage(ServiceApplication|Application|null $resource = null if ($domains->contains($naked_domain)) { if (data_get($resource, 'uuid')) { if ($resource->uuid !== $app->uuid) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource called:

{$app->name}."); + throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->name}"); } } elseif ($domain) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource called:

{$app->name}."); + throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->name}"); } } } @@ -1144,16 +1193,16 @@ function check_domain_usage(ServiceApplication|Application|null $resource = null if ($domains->contains($naked_domain)) { if (data_get($resource, 'uuid')) { if ($resource->uuid !== $app->uuid) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource called:

{$app->name}."); + throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->service->name}"); } } elseif ($domain) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource called:

{$app->name}."); + throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->service->name}"); } } } } if ($resource) { - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); if (data_get($settings, 'fqdn')) { $domain = data_get($settings, 'fqdn'); if (str($domain)->endsWith('/')) { @@ -1170,12 +1219,26 @@ function check_domain_usage(ServiceApplication|Application|null $resource = null function parseCommandsByLineForSudo(Collection $commands, Server $server): array { $commands = $commands->map(function ($line) { - if (! str($line)->startsWith('cd') && ! str($line)->startsWith('command') && ! str($line)->startsWith('echo') && ! str($line)->startsWith('true')) { + if ( + ! str(trim($line))->startsWith([ + 'cd', + 'command', + 'echo', + 'true', + 'if', + 'fi', + ]) + ) { return "sudo $line"; } + if (str(trim($line))->startsWith('if')) { + return str_replace('if', 'if sudo', $line); + } + return $line; }); + $commands = $commands->map(function ($line) use ($server) { if (Str::startsWith($line, 'sudo mkdir -p')) { return "$line && sudo chown -R $server->user:$server->user ".Str::after($line, 'sudo mkdir -p').' && sudo chmod -R o-rwx '.Str::after($line, 'sudo mkdir -p'); @@ -1183,6 +1246,7 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array return $line; }); + $commands = $commands->map(function ($line) { $line = str($line); if (str($line)->contains('$(')) { @@ -1227,8 +1291,6 @@ function parseLineForSudo(string $command, Server $server): string function get_public_ips() { try { - echo "Refreshing public ips!\n"; - $settings = \App\Models\InstanceSettings::get(); [$first, $second] = Process::concurrently(function (Pool $pool) { $pool->path(__DIR__)->command('curl -4s https://ifconfig.io'); $pool->path(__DIR__)->command('curl -6s https://ifconfig.io'); @@ -1242,8 +1304,12 @@ function get_public_ips() return; } - $settings->update(['public_ipv4' => $ipv4]); + InstanceSettings::get()->update(['public_ipv4' => $ipv4]); } + } catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n"; + } + try { $ipv6 = $second->output(); if ($ipv6) { $ipv6 = trim($ipv6); @@ -1253,7 +1319,7 @@ function get_public_ips() return; } - $settings->update(['public_ipv6' => $ipv6]); + InstanceSettings::get()->update(['public_ipv6' => $ipv6]); } } catch (\Throwable $e) { echo "Error: {$e->getMessage()}\n"; @@ -1272,13 +1338,6 @@ function isAnyDeploymentInprogress() exit(0); } -function generateSentinelToken() -{ - $token = Str::random(64); - - return $token; -} - function isBase64Encoded($strValue) { return base64_encode(base64_decode($strValue, true)) === $strValue; @@ -1577,7 +1636,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal return $value == $networkName || $key == $networkName; }); if (! $networkExists) { - $topLevelNetworks->put($networkDetails, null); + if (is_string($networkDetails) || is_int($networkDetails)) { + $topLevelNetworks->put($networkDetails, null); + } } } } @@ -2100,16 +2161,16 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal // TODO: move this in a shared function if (! $parsedServiceVariables->has('COOLIFY_APP_NAME')) { - $parsedServiceVariables->put('COOLIFY_APP_NAME', $resource->name); + $parsedServiceVariables->put('COOLIFY_APP_NAME', "\"{$resource->name}\""); } if (! $parsedServiceVariables->has('COOLIFY_SERVER_IP')) { - $parsedServiceVariables->put('COOLIFY_SERVER_IP', $resource->destination->server->ip); + $parsedServiceVariables->put('COOLIFY_SERVER_IP', "\"{$resource->destination->server->ip}\""); } if (! $parsedServiceVariables->has('COOLIFY_ENVIRONMENT_NAME')) { - $parsedServiceVariables->put('COOLIFY_ENVIRONMENT_NAME', $resource->environment->name); + $parsedServiceVariables->put('COOLIFY_ENVIRONMENT_NAME', "\"{$resource->environment->name}\""); } if (! $parsedServiceVariables->has('COOLIFY_PROJECT_NAME')) { - $parsedServiceVariables->put('COOLIFY_PROJECT_NAME', $resource->project()->name); + $parsedServiceVariables->put('COOLIFY_PROJECT_NAME', "\"{$resource->project()->name}\""); } $parsedServiceVariables = $parsedServiceVariables->map(function ($value, $key) use ($envs_from_coolify) { @@ -2492,7 +2553,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal return $value == $networkName || $key == $networkName; }); if (! $networkExists) { - $topLevelNetworks->put($networkDetails, null); + if (is_string($networkDetails) || is_int($networkDetails)) { + $topLevelNetworks->put($networkDetails, null); + } } } } @@ -2921,10 +2984,11 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int } $parsedServices = collect([]); - ray()->clearAll(); + // ray()->clearAll(); $allMagicEnvironments = collect([]); foreach ($services as $serviceName => $service) { + $predefinedPort = null; $magicEnvironments = collect([]); $image = data_get_str($service, 'image'); $environment = collect(data_get($service, 'environment', [])); @@ -2933,12 +2997,41 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $isDatabase = isDatabaseImage(data_get_str($service, 'image')); if ($isService) { + $containerName = "$serviceName-{$resource->uuid}"; + + if ($serviceName === 'registry') { + $tempServiceName = 'docker-registry'; + } else { + $tempServiceName = $serviceName; + } + if (str(data_get($service, 'image'))->contains('glitchtip')) { + $tempServiceName = 'glitchtip'; + } + if ($serviceName === 'supabase-kong') { + $tempServiceName = 'supabase'; + } + $serviceDefinition = data_get($allServices, $tempServiceName); + $predefinedPort = data_get($serviceDefinition, 'port'); + if ($serviceName === 'plausible') { + $predefinedPort = '8000'; + } if ($isDatabase) { - $savedService = ServiceDatabase::firstOrCreate([ - 'name' => $serviceName, - 'image' => $image, - 'service_id' => $resource->id, - ]); + $applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first(); + if ($applicationFound) { + $savedService = $applicationFound; + $savedService = ServiceDatabase::firstOrCreate([ + 'name' => $applicationFound->name, + 'image' => $applicationFound->image, + 'service_id' => $applicationFound->service_id, + ]); + $applicationFound->delete(); + } else { + $savedService = ServiceDatabase::firstOrCreate([ + 'name' => $serviceName, + 'image' => $image, + 'service_id' => $resource->id, + ]); + } } else { $savedService = ServiceApplication::firstOrCreate([ 'name' => $serviceName, @@ -2982,7 +3075,13 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int // Get magic environments where we need to preset the FQDN if ($key->startsWith('SERVICE_FQDN_')) { // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000 - $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value(); + if (substr_count(str($key)->value(), '_') === 3) { + $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value(); + $port = $key->afterLast('_')->value(); + } else { + $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value(); + $port = null; + } if ($isApplication) { $fqdn = generateFqdn($server, "{$resource->name}-$uuid"); } elseif ($isService) { @@ -2992,19 +3091,24 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $fqdn = generateFqdn($server, "{$savedService->name}-$uuid"); } } + if ($value && get_class($value) === 'Illuminate\Support\Stringable' && $value->startsWith('/')) { $path = $value->value(); if ($path !== '/') { $fqdn = "$fqdn$path"; } } + $fqdnWithPort = $fqdn; + if ($port) { + $fqdnWithPort = "$fqdn:$port"; + } if ($isApplication && is_null($resource->fqdn)) { data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables_preview'); - $resource->fqdn = $fqdn; + $resource->fqdn = $fqdnWithPort; $resource->save(); } elseif ($isService && is_null($savedService->fqdn)) { - $savedService->fqdn = $fqdn; + $savedService->fqdn = $fqdnWithPort; $savedService->save(); } @@ -3033,12 +3137,11 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int } $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments); - if ($magicEnvironments->count() > 0) { foreach ($magicEnvironments as $key => $value) { $key = str($key); $value = replaceVariables($value); - $command = $key->after('SERVICE_')->before('_'); + $command = parseCommandFromMagicEnvVariable($key); $found = $resource->environment_variables()->where('key', $key->value())->where($nameOfId, $resource->id)->first(); if ($found) { continue; @@ -3071,6 +3174,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int } elseif ($isService) { $fqdn = generateFqdn($server, "$fqdnFor-$uuid"); } + $fqdn = str($fqdn)->replace('http://', '')->replace('https://', ''); $resource->environment_variables()->where('key', $key->value())->where($nameOfId, $resource->id)->firstOrCreate([ 'key' => $key->value(), $nameOfId => $resource->id, @@ -3149,12 +3253,24 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int if ($serviceName === 'plausible') { $predefinedPort = '8000'; } + if ($isDatabase) { - $savedService = ServiceDatabase::firstOrCreate([ - 'name' => $serviceName, - 'image' => $image, - 'service_id' => $resource->id, - ]); + $applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first(); + if ($applicationFound) { + $savedService = $applicationFound; + $savedService = ServiceDatabase::firstOrCreate([ + 'name' => $applicationFound->name, + 'image' => $applicationFound->image, + 'service_id' => $applicationFound->service_id, + ]); + $applicationFound->delete(); + } else { + $savedService = ServiceDatabase::firstOrCreate([ + 'name' => $serviceName, + 'image' => $image, + 'service_id' => $resource->id, + ]); + } } else { $savedService = ServiceApplication::firstOrCreate([ 'name' => $serviceName, @@ -3224,12 +3340,19 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int } elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') { $volume = $source->value().':'.$target->value(); } else { - $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); + if ((int) $resource->compose_parsing_version >= 4) { + if ($isApplication) { + $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); + } elseif ($isService) { + $mainDirectory = str(base_configuration_dir().'/services/'.$uuid); + } + } else { + $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); + } $source = replaceLocalSource($source, $mainDirectory); if ($isApplication && $isPullRequest) { $source = $source."-pr-$pullRequestId"; } - LocalFileVolume::updateOrCreate( [ 'mount_path' => $target, @@ -3245,6 +3368,17 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int 'resource_type' => get_class($originalResource), ] ); + if (isDev()) { + if ((int) $resource->compose_parsing_version >= 4) { + if ($isApplication) { + $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid); + } elseif ($isService) { + $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/services/'.$uuid); + } + } else { + $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid); + } + } $volume = "$source:$target"; } } elseif ($type->value() === 'volume') { @@ -3428,6 +3562,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int ]); } else { if ($value->startsWith('$')) { + $isRequired = false; if ($value->contains(':-')) { $value = replaceVariables($value); $key = $value->before(':'); @@ -3442,13 +3577,28 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $key = $value->before(':'); $value = $value->after(':?'); + $isRequired = true; } elseif ($value->contains('?')) { $value = replaceVariables($value); $key = $value->before('?'); $value = $value->after('?'); + $isRequired = true; } if ($originalValue->value() === $value->value()) { + // This means the variable does not have a default value, so it needs to be created in Coolify + $parsedKeyValue = replaceVariables($value); + $resource->environment_variables()->where('key', $parsedKeyValue)->where($nameOfId, $resource->id)->firstOrCreate([ + 'key' => $parsedKeyValue, + $nameOfId => $resource->id, + ], [ + 'is_build_time' => false, + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + // Add the variable to the environment so it will be shown in the deployable compose file + $environment[$parsedKeyValue->value()] = $resource->environment_variables()->where('key', $parsedKeyValue)->where($nameOfId, $resource->id)->first()->value; + continue; } $resource->environment_variables()->where('key', $key)->where($nameOfId, $resource->id)->firstOrCreate([ @@ -3458,6 +3608,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int 'value' => $value, 'is_build_time' => false, 'is_preview' => false, + 'is_required' => $isRequired, ]); } @@ -3469,13 +3620,13 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $branch = "pull/{$pullRequestId}/head"; } if ($originalResource->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) { - $coolifyEnvironments->put('COOLIFY_BRANCH', $branch); + $coolifyEnvironments->put('COOLIFY_BRANCH', "\"{$branch}\""); } } // Add COOLIFY_CONTAINER_NAME to environment if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { - $coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', $containerName); + $coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "\"{$containerName}\""); } if ($isApplication) { @@ -3541,14 +3692,37 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int if ($environment->count() > 0) { $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; + } + } + + return $value; }); } $serviceLabels = $labels->merge($defaultLabels); + if ($serviceLabels->count() > 0) { + if ($isApplication) { + $isContainerLabelEscapeEnabled = data_get($resource, 'settings.is_container_label_escape_enabled'); + } else { + $isContainerLabelEscapeEnabled = data_get($resource, 'is_container_label_escape_enabled'); + } + if ($isContainerLabelEscapeEnabled) { + $serviceLabels = $serviceLabels->map(function ($value, $key) { + return escapeDollarSign($value); + }); + } + } if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) { if ($isApplication) { $shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels; $uuid = $resource->uuid; - $network = $resource->destination->network; + $network = data_get($resource, 'destination.network'); if ($isPullRequest) { $uuid = "{$resource->uuid}-{$pullRequestId}"; } @@ -3558,7 +3732,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int } else { $shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels; $uuid = $resource->uuid; - $network = $resource->destination->network; + $network = data_get($resource, 'destination.network'); } if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { @@ -3625,6 +3799,14 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int data_forget($service, 'volumes.*.is_directory'); data_forget($service, 'exclude_from_hc'); + $volumesParsed = $volumesParsed->map(function ($volume) { + data_forget($volume, 'content'); + data_forget($volume, 'is_directory'); + data_forget($volume, 'isDirectory'); + + return $volume; + }); + $payload = collect($service)->merge([ 'container_name' => $containerName, 'restart' => $restart->value(), @@ -3655,6 +3837,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $parsedServices->put($serviceName, $payload); } $topLevel->put('services', $parsedServices); + $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets']; $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) { @@ -3711,6 +3894,8 @@ function isAssociativeArray($array) */ function add_coolify_default_environment_variables(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|Application|Service $resource, Collection &$where_to_add, ?Collection $where_to_check = null) { + // Currently disabled + return; if ($resource instanceof Service) { $ip = $resource->server->ip; } else { @@ -3723,30 +3908,30 @@ function add_coolify_default_environment_variables(StandaloneRedis|StandalonePos } if ($where_to_check != null && $where_to_check->where('key', 'COOLIFY_APP_NAME')->isEmpty()) { if ($isAssociativeArray) { - $where_to_add->put('COOLIFY_APP_NAME', $resource->name); + $where_to_add->put('COOLIFY_APP_NAME', "\"{$resource->name}\""); } else { - $where_to_add->push("COOLIFY_APP_NAME={$resource->name}"); + $where_to_add->push("COOLIFY_APP_NAME=\"{$resource->name}\""); } } if ($where_to_check != null && $where_to_check->where('key', 'COOLIFY_SERVER_IP')->isEmpty()) { if ($isAssociativeArray) { - $where_to_add->put('COOLIFY_SERVER_IP', $ip); + $where_to_add->put('COOLIFY_SERVER_IP', "\"{$ip}\""); } else { - $where_to_add->push("COOLIFY_SERVER_IP={$ip}"); + $where_to_add->push("COOLIFY_SERVER_IP=\"{$ip}\""); } } if ($where_to_check != null && $where_to_check->where('key', 'COOLIFY_ENVIRONMENT_NAME')->isEmpty()) { if ($isAssociativeArray) { - $where_to_add->put('COOLIFY_ENVIRONMENT_NAME', $resource->environment->name); + $where_to_add->put('COOLIFY_ENVIRONMENT_NAME', "\"{$resource->environment->name}\""); } else { - $where_to_add->push("COOLIFY_ENVIRONMENT_NAME={$resource->environment->name}"); + $where_to_add->push("COOLIFY_ENVIRONMENT_NAME=\"{$resource->environment->name}\""); } } if ($where_to_check != null && $where_to_check->where('key', 'COOLIFY_PROJECT_NAME')->isEmpty()) { if ($isAssociativeArray) { - $where_to_add->put('COOLIFY_PROJECT_NAME', $resource->project()->name); + $where_to_add->put('COOLIFY_PROJECT_NAME', "\"{$resource->project()->name}\""); } else { - $where_to_add->push("COOLIFY_PROJECT_NAME={$resource->project()->name}"); + $where_to_add->push("COOLIFY_PROJECT_NAME=\"{$resource->project()->name}\""); } } } @@ -3755,14 +3940,37 @@ function convertComposeEnvironmentToArray($environment) { $convertedServiceVariables = collect([]); if (isAssociativeArray($environment)) { + // Example: $environment = ['FOO' => 'bar', 'BAZ' => 'qux']; + if ($environment instanceof Collection) { + $changedEnvironment = collect([]); + $environment->each(function ($value, $key) use ($changedEnvironment) { + if (is_numeric($key)) { + $parts = explode('=', $value, 2); + if (count($parts) === 2) { + $key = $parts[0]; + $realValue = $parts[1] ?? ''; + $changedEnvironment->put($key, $realValue); + } else { + $changedEnvironment->put($key, $value); + } + } else { + $changedEnvironment->put($key, $value); + } + }); + + return $changedEnvironment; + } $convertedServiceVariables = $environment; } else { + // Example: $environment = ['FOO=bar', 'BAZ=qux']; foreach ($environment as $value) { - $parts = explode('=', $value, 2); - $key = $parts[0]; - $realValue = $parts[1] ?? ''; - if ($key) { - $convertedServiceVariables->put($key, $realValue); + if (is_string($value)) { + $parts = explode('=', $value, 2); + $key = $parts[0]; + $realValue = $parts[1] ?? ''; + if ($key) { + $convertedServiceVariables->put($key, $realValue); + } } } } @@ -3770,3 +3978,50 @@ function convertComposeEnvironmentToArray($environment) return $convertedServiceVariables; } +function instanceSettings() +{ + return InstanceSettings::get(); +} + +function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id) { + + $server = Server::find($server_id)->where('team_id', $team_id)->first(); + if (!$server) { + return; + } + $uuid = new Cuid2(); + $cloneCommand = "git clone --no-checkout -b $branch $repository ."; + $workdir = rtrim($base_directory, '/'); + $fileList = collect([".$workdir/coolify.json"]); + $commands = collect([ + "rm -rf /tmp/{$uuid}", + "mkdir -p /tmp/{$uuid}", + "cd /tmp/{$uuid}", + $cloneCommand, + 'git sparse-checkout init --cone', + "git sparse-checkout set {$fileList->implode(' ')}", + 'git read-tree -mu HEAD', + "cat .$workdir/coolify.json", + 'rm -rf /tmp/{$uuid}', + ]); + try { + return instant_remote_process($commands, $server); + } catch (\Exception $e) { + // continue + } +} + +function loggy($message = null, array $context = []) +{ + if (!isDev()) { + return; + } + if (function_exists('ray') && config('app.debug')) { + ray($message, $context); + } + if (is_null($message)) { + return app('log'); + } + + return app('log')->debug($message, $context); +} diff --git a/composer.json b/composer.json index e8b46105d..b17c3bf4e 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "guzzlehttp/guzzle": "^7.5.0", "laravel/fortify": "^v1.16.0", "laravel/framework": "^v11", - "laravel/horizon": "^5.27.1", + "laravel/horizon": "^5.29.1", + "laravel/pail": "^1.1", "laravel/prompts": "^0.1.6", "laravel/sanctum": "^v4.0", "laravel/socialite": "^v5.14.0", @@ -48,6 +49,7 @@ "zircote/swagger-php": "^4.10" }, "require-dev": { + "barryvdh/laravel-debugbar": "^3.13", "fakerphp/faker": "^v1.21.0", "laravel/dusk": "^v8.0", "laravel/pint": "^1.16", @@ -84,7 +86,11 @@ "@php artisan vendor:publish --tag=laravel-assets --ansi --force", "Illuminate\\Foundation\\ComposerScripts::postUpdate" ], - "post-install-cmd": [], + "post-install-cmd": [ + "cp -r 'hooks/' '.git/hooks/'", + "php -r \"copy('hooks/pre-commit', '.git/hooks/pre-commit');\"", + "php -r \"chmod('.git/hooks/pre-commit', 0777);\"" + ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], diff --git a/composer.lock b/composer.lock index fffb320d3..981e723d4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "96f8146407d0e6e897ff097c5eccd3a4", + "content-hash": "943975ec232403b96a40d215253492d8", "packages": [ { "name": "amphp/amp", @@ -317,16 +317,16 @@ }, { "name": "amphp/parallel", - "version": "v2.2.9", + "version": "v2.3.0", "source": { "type": "git", "url": "https://github.com/amphp/parallel.git", - "reference": "73d293f1fc4df1bebc3c4fce1432e82dd7032238" + "reference": "9777db1460d1535bc2a843840684fb1205225b87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/parallel/zipball/73d293f1fc4df1bebc3c4fce1432e82dd7032238", - "reference": "73d293f1fc4df1bebc3c4fce1432e82dd7032238", + "url": "https://api.github.com/repos/amphp/parallel/zipball/9777db1460d1535bc2a843840684fb1205225b87", + "reference": "9777db1460d1535bc2a843840684fb1205225b87", "shasum": "" }, "require": { @@ -389,7 +389,7 @@ ], "support": { "issues": "https://github.com/amphp/parallel/issues", - "source": "https://github.com/amphp/parallel/tree/v2.2.9" + "source": "https://github.com/amphp/parallel/tree/v2.3.0" }, "funding": [ { @@ -397,7 +397,7 @@ "type": "github" } ], - "time": "2024-03-24T18:27:44+00:00" + "time": "2024-09-14T19:16:14+00:00" }, { "name": "amphp/parser", @@ -921,16 +921,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.321.9", + "version": "3.324.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "5de5099cfe0e17cb3eb2fe51de0101c99bc9442a" + "reference": "b258712f0d986e00e1143d55246b6f9e344c7184" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/5de5099cfe0e17cb3eb2fe51de0101c99bc9442a", - "reference": "5de5099cfe0e17cb3eb2fe51de0101c99bc9442a", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b258712f0d986e00e1143d55246b6f9e344c7184", + "reference": "b258712f0d986e00e1143d55246b6f9e344c7184", "shasum": "" }, "require": { @@ -1013,22 +1013,22 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.321.9" + "source": "https://github.com/aws/aws-sdk-php/tree/3.324.0" }, - "time": "2024-09-11T18:15:49+00:00" + "time": "2024-10-10T18:06:36+00:00" }, { "name": "bacon/bacon-qr-code", - "version": "v3.0.0", + "version": "v3.0.1", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "510de6eca6248d77d31b339d62437cc995e2fb41" + "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/510de6eca6248d77d31b339d62437cc995e2fb41", - "reference": "510de6eca6248d77d31b339d62437cc995e2fb41", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f", + "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f", "shasum": "" }, "require": { @@ -1067,9 +1067,9 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.0" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1" }, - "time": "2024-04-18T11:16:25+00:00" + "time": "2024-10-01T13:55:55+00:00" }, { "name": "brick/math", @@ -1518,16 +1518,16 @@ }, { "name": "doctrine/dbal", - "version": "3.9.1", + "version": "3.9.3", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "d7dc08f98cba352b2bab5d32c5e58f7e745c11a7" + "reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/d7dc08f98cba352b2bab5d32c5e58f7e745c11a7", - "reference": "d7dc08f98cba352b2bab5d32c5e58f7e745c11a7", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba", + "reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba", "shasum": "" }, "require": { @@ -1543,7 +1543,7 @@ "doctrine/coding-standard": "12.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.12.0", + "phpstan/phpstan": "1.12.6", "phpstan/phpstan-strict-rules": "^1.6", "phpunit/phpunit": "9.6.20", "psalm/plugin-phpunit": "0.18.4", @@ -1611,7 +1611,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.9.1" + "source": "https://github.com/doctrine/dbal/tree/3.9.3" }, "funding": [ { @@ -1627,7 +1627,7 @@ "type": "tidelift" } ], - "time": "2024-09-01T13:49:23+00:00" + "time": "2024-10-10T17:56:43+00:00" }, { "name": "doctrine/deprecations", @@ -1937,16 +1937,16 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.3.3", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a" + "reference": "8c784d071debd117328803d86b2097615b457500" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/adfb1f505deb6384dc8b39804c5065dd3c8c8c0a", - "reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", + "reference": "8c784d071debd117328803d86b2097615b457500", "shasum": "" }, "require": { @@ -1959,10 +1959,14 @@ "require-dev": { "phpstan/extension-installer": "^1.0", "phpstan/phpstan": "^1.0", - "phpstan/phpstan-webmozart-assert": "^1.0", "phpunit/phpunit": "^7.0|^8.0|^9.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, "autoload": { "psr-4": { "Cron\\": "src/Cron/" @@ -1986,7 +1990,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.3.3" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" }, "funding": [ { @@ -1994,7 +1998,7 @@ "type": "github" } ], - "time": "2023-08-10T19:36:49+00:00" + "time": "2024-10-09T13:47:03+00:00" }, { "name": "egulias/email-validator", @@ -2789,16 +2793,16 @@ }, { "name": "laravel/fortify", - "version": "v1.24.1", + "version": "v1.24.2", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "8158ba0960bb5f4aae509d01d74a95e16e30de20" + "reference": "42695c45087e5abb3e173725b4f1ef4956a7b47d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/8158ba0960bb5f4aae509d01d74a95e16e30de20", - "reference": "8158ba0960bb5f4aae509d01d74a95e16e30de20", + "url": "https://api.github.com/repos/laravel/fortify/zipball/42695c45087e5abb3e173725b4f1ef4956a7b47d", + "reference": "42695c45087e5abb3e173725b4f1ef4956a7b47d", "shasum": "" }, "require": { @@ -2850,20 +2854,20 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2024-09-03T10:02:14+00:00" + "time": "2024-09-16T19:20:52+00:00" }, { "name": "laravel/framework", - "version": "v11.23.2", + "version": "v11.27.2", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "d38bf0fd3a8936e1cb9ca8eb8d7304a564f790f3" + "reference": "a51d1f2b771c542324a3d9b76a98b1bbc75c0ee9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/d38bf0fd3a8936e1cb9ca8eb8d7304a564f790f3", - "reference": "d38bf0fd3a8936e1cb9ca8eb8d7304a564f790f3", + "url": "https://api.github.com/repos/laravel/framework/zipball/a51d1f2b771c542324a3d9b76a98b1bbc75c0ee9", + "reference": "a51d1f2b771c542324a3d9b76a98b1bbc75c0ee9", "shasum": "" }, "require": { @@ -2882,7 +2886,7 @@ "fruitcake/php-cors": "^1.3", "guzzlehttp/guzzle": "^7.8", "guzzlehttp/uri-template": "^1.0", - "laravel/prompts": "^0.1.18", + "laravel/prompts": "^0.1.18|^0.2.0|^0.3.0", "laravel/serializable-closure": "^1.3", "league/commonmark": "^2.2.1", "league/flysystem": "^3.8.0", @@ -2968,7 +2972,7 @@ "league/flysystem-sftp-v3": "^3.0", "mockery/mockery": "^1.6", "nyholm/psr7": "^1.2", - "orchestra/testbench-core": "^9.4.0", + "orchestra/testbench-core": "^9.5", "pda/pheanstalk": "^5.0", "phpstan/phpstan": "^1.11.5", "phpunit/phpunit": "^10.5|^11.0", @@ -3027,6 +3031,7 @@ "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", "src/Illuminate/Log/functions.php", + "src/Illuminate/Support/functions.php", "src/Illuminate/Support/helpers.php" ], "psr-4": { @@ -3058,20 +3063,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2024-09-11T21:59:23+00:00" + "time": "2024-10-09T04:17:35+00:00" }, { "name": "laravel/horizon", - "version": "v5.28.1", + "version": "v5.29.1", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "9d2c4eaeb11408384401f8a7d1b0ea4c76554f3f" + "reference": "9f482f21c23ed01c2366d1157843165165579c23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/9d2c4eaeb11408384401f8a7d1b0ea4c76554f3f", - "reference": "9d2c4eaeb11408384401f8a7d1b0ea4c76554f3f", + "url": "https://api.github.com/repos/laravel/horizon/zipball/9f482f21c23ed01c2366d1157843165165579c23", + "reference": "9f482f21c23ed01c2366d1157843165165579c23", "shasum": "" }, "require": { @@ -3135,9 +3140,86 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.28.1" + "source": "https://github.com/laravel/horizon/tree/v5.29.1" }, - "time": "2024-09-04T14:06:50+00:00" + "time": "2024-10-08T18:23:02+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "b33ad8321416fe86efed7bf398f3306c47b4871b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/b33ad8321416fe86efed7bf398f3306c47b4871b", + "reference": "b33ad8321416fe86efed7bf398f3306c47b4871b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0", + "illuminate/contracts": "^10.24|^11.0", + "illuminate/log": "^10.24|^11.0", + "illuminate/process": "^10.24|^11.0", + "illuminate/support": "^10.24|^11.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0" + }, + "require-dev": { + "laravel/pint": "^1.13", + "orchestra/testbench": "^8.12|^9.0", + "pestphp/pest": "^2.20", + "pestphp/pest-plugin-type-coverage": "^2.3", + "phpstan/phpstan": "^1.10", + "symfony/var-dumper": "^6.3|^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + }, + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2024-10-15T20:06:24+00:00" }, { "name": "laravel/prompts", @@ -3199,16 +3281,16 @@ }, { "name": "laravel/sanctum", - "version": "v4.0.2", + "version": "v4.0.3", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "9cfc0ce80cabad5334efff73ec856339e8ec1ac1" + "reference": "54aea9d13743ae8a6cdd3c28dbef128a17adecab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/9cfc0ce80cabad5334efff73ec856339e8ec1ac1", - "reference": "9cfc0ce80cabad5334efff73ec856339e8ec1ac1", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/54aea9d13743ae8a6cdd3c28dbef128a17adecab", + "reference": "54aea9d13743ae8a6cdd3c28dbef128a17adecab", "shasum": "" }, "require": { @@ -3259,20 +3341,20 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2024-04-10T19:39:58+00:00" + "time": "2024-09-27T14:55:41+00:00" }, { "name": "laravel/serializable-closure", - "version": "v1.3.4", + "version": "v1.3.5", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "61b87392d986dc49ad5ef64e75b1ff5fee24ef81" + "reference": "1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/61b87392d986dc49ad5ef64e75b1ff5fee24ef81", - "reference": "61b87392d986dc49ad5ef64e75b1ff5fee24ef81", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c", + "reference": "1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c", "shasum": "" }, "require": { @@ -3320,7 +3402,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2024-08-02T07:48:17+00:00" + "time": "2024-09-23T13:33:08+00:00" }, { "name": "laravel/socialite", @@ -3465,16 +3547,16 @@ }, { "name": "laravel/tinker", - "version": "v2.9.0", + "version": "v2.10.0", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "502e0fe3f0415d06d5db1f83a472f0f3b754bafe" + "reference": "ba4d51eb56de7711b3a37d63aa0643e99a339ae5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/502e0fe3f0415d06d5db1f83a472f0f3b754bafe", - "reference": "502e0fe3f0415d06d5db1f83a472f0f3b754bafe", + "url": "https://api.github.com/repos/laravel/tinker/zipball/ba4d51eb56de7711b3a37d63aa0643e99a339ae5", + "reference": "ba4d51eb56de7711b3a37d63aa0643e99a339ae5", "shasum": "" }, "require": { @@ -3525,9 +3607,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.9.0" + "source": "https://github.com/laravel/tinker/tree/v2.10.0" }, - "time": "2024-01-04T16:10:04+00:00" + "time": "2024-09-23T13:32:56+00:00" }, { "name": "laravel/ui", @@ -3594,38 +3676,38 @@ }, { "name": "lcobucci/jwt", - "version": "5.3.0", + "version": "5.4.0", "source": { "type": "git", "url": "https://github.com/lcobucci/jwt.git", - "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83" + "reference": "aac4fd512681fd5cb4b77d2105ab7ec700c72051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lcobucci/jwt/zipball/08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", - "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/aac4fd512681fd5cb4b77d2105ab7ec700c72051", + "reference": "aac4fd512681fd5cb4b77d2105ab7ec700c72051", "shasum": "" }, "require": { "ext-openssl": "*", "ext-sodium": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", "psr/clock": "^1.0" }, "require-dev": { - "infection/infection": "^0.27.0", - "lcobucci/clock": "^3.0", + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", "lcobucci/coding-standard": "^11.0", - "phpbench/phpbench": "^1.2.9", + "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.2", "phpstan/phpstan": "^1.10.7", "phpstan/phpstan-deprecation-rules": "^1.1.3", "phpstan/phpstan-phpunit": "^1.3.10", "phpstan/phpstan-strict-rules": "^1.5.0", - "phpunit/phpunit": "^10.2.6" + "phpunit/phpunit": "^11.1" }, "suggest": { - "lcobucci/clock": ">= 3.0" + "lcobucci/clock": ">= 3.2" }, "type": "library", "autoload": { @@ -3651,7 +3733,7 @@ ], "support": { "issues": "https://github.com/lcobucci/jwt/issues", - "source": "https://github.com/lcobucci/jwt/tree/5.3.0" + "source": "https://github.com/lcobucci/jwt/tree/5.4.0" }, "funding": [ { @@ -3663,7 +3745,7 @@ "type": "patreon" } ], - "time": "2024-04-11T23:07:54+00:00" + "time": "2024-10-08T22:06:45+00:00" }, { "name": "league/commonmark", @@ -3855,16 +3937,16 @@ }, { "name": "league/flysystem", - "version": "3.28.0", + "version": "3.29.1", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "e611adab2b1ae2e3072fa72d62c62f52c2bf1f0c" + "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/e611adab2b1ae2e3072fa72d62c62f52c2bf1f0c", - "reference": "e611adab2b1ae2e3072fa72d62c62f52c2bf1f0c", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/edc1bb7c86fab0776c3287dbd19b5fa278347319", + "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319", "shasum": "" }, "require": { @@ -3932,22 +4014,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.28.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.29.1" }, - "time": "2024-05-22T10:09:12+00:00" + "time": "2024-10-08T08:58:34+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.28.0", + "version": "3.29.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "22071ef1604bc776f5ff2468ac27a752514665c8" + "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/22071ef1604bc776f5ff2468ac27a752514665c8", - "reference": "22071ef1604bc776f5ff2468ac27a752514665c8", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/c6ff6d4606e48249b63f269eba7fabdb584e76a9", + "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9", "shasum": "" }, "require": { @@ -3987,22 +4069,22 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.28.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.29.0" }, - "time": "2024-05-06T20:05:52+00:00" + "time": "2024-08-17T13:10:48+00:00" }, { "name": "league/flysystem-local", - "version": "3.28.0", + "version": "3.29.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "13f22ea8be526ea58c2ddff9e158ef7c296e4f40" + "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/13f22ea8be526ea58c2ddff9e158ef7c296e4f40", - "reference": "13f22ea8be526ea58c2ddff9e158ef7c296e4f40", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/e0e8d52ce4b2ed154148453d321e97c8e931bd27", + "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27", "shasum": "" }, "require": { @@ -4036,22 +4118,22 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.28.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.29.0" }, - "time": "2024-05-06T20:05:52+00:00" + "time": "2024-08-09T21:24:39+00:00" }, { "name": "league/flysystem-sftp-v3", - "version": "3.28.0", + "version": "3.29.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-sftp-v3.git", - "reference": "abedadd3c64d4f0e276d6ecc796ec8194d136b41" + "reference": "ce9b209e2fbe33122c755ffc18eb4d5bd256f252" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/abedadd3c64d4f0e276d6ecc796ec8194d136b41", - "reference": "abedadd3c64d4f0e276d6ecc796ec8194d136b41", + "url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/ce9b209e2fbe33122c755ffc18eb4d5bd256f252", + "reference": "ce9b209e2fbe33122c755ffc18eb4d5bd256f252", "shasum": "" }, "require": { @@ -4085,22 +4167,22 @@ "sftp" ], "support": { - "source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.28.0" + "source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.29.0" }, - "time": "2024-05-06T20:05:52+00:00" + "time": "2024-08-14T19:35:54+00:00" }, { "name": "league/mime-type-detection", - "version": "1.15.0", + "version": "1.16.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301" + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301", - "reference": "ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", "shasum": "" }, "require": { @@ -4131,7 +4213,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.15.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" }, "funding": [ { @@ -4143,7 +4225,7 @@ "type": "tidelift" } ], - "time": "2024-01-28T23:22:08+00:00" + "time": "2024-09-21T08:32:55+00:00" }, { "name": "league/oauth1-client", @@ -4955,24 +5037,24 @@ }, { "name": "nette/schema", - "version": "v1.3.0", + "version": "v1.3.2", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "a6d3a6d1f545f01ef38e60f375d1cf1f4de98188" + "reference": "da801d52f0354f70a638673c4a0f04e16529431d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/a6d3a6d1f545f01ef38e60f375d1cf1f4de98188", - "reference": "a6d3a6d1f545f01ef38e60f375d1cf1f4de98188", + "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d", + "reference": "da801d52f0354f70a638673c4a0f04e16529431d", "shasum": "" }, "require": { "nette/utils": "^4.0", - "php": "8.1 - 8.3" + "php": "8.1 - 8.4" }, "require-dev": { - "nette/tester": "^2.4", + "nette/tester": "^2.5.2", "phpstan/phpstan-nette": "^1.0", "tracy/tracy": "^2.8" }, @@ -5011,9 +5093,9 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.0" + "source": "https://github.com/nette/schema/tree/v1.3.2" }, - "time": "2023-12-11T11:54:22+00:00" + "time": "2024-10-06T23:10:23+00:00" }, { "name": "nette/utils", @@ -5103,16 +5185,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.1.0", + "version": "v5.3.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1" + "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/683130c2ff8c2739f4822ff7ac5c873ec529abd1", - "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", + "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", "shasum": "" }, "require": { @@ -5155,9 +5237,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.1.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" }, - "time": "2024-07-01T20:03:41+00:00" + "time": "2024-10-08T18:51:32+00:00" }, { "name": "nubs/random-name-generator", @@ -5897,16 +5979,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.41", + "version": "3.0.42", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "621c73f7dcb310b61de34d1da4c4204e8ace6ceb" + "reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/621c73f7dcb310b61de34d1da4c4204e8ace6ceb", - "reference": "621c73f7dcb310b61de34d1da4c4204e8ace6ceb", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db92f1b1987b12b13f248fe76c3a52cadb67bb98", + "reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98", "shasum": "" }, "require": { @@ -5987,7 +6069,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.41" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.42" }, "funding": [ { @@ -6003,20 +6085,20 @@ "type": "tidelift" } ], - "time": "2024-08-12T00:13:54+00:00" + "time": "2024-09-16T03:06:04+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "1.30.1", + "version": "1.32.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "51b95ec8670af41009e2b2b56873bad96682413e" + "reference": "6ca22b154efdd9e3c68c56f5d94670920a1c19a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/51b95ec8670af41009e2b2b56873bad96682413e", - "reference": "51b95ec8670af41009e2b2b56873bad96682413e", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/6ca22b154efdd9e3c68c56f5d94670920a1c19a4", + "reference": "6ca22b154efdd9e3c68c56f5d94670920a1c19a4", "shasum": "" }, "require": { @@ -6048,22 +6130,22 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.30.1" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.32.0" }, - "time": "2024-09-07T20:13:05+00:00" + "time": "2024-09-26T07:23:32+00:00" }, { "name": "phpstan/phpstan", - "version": "1.12.3", + "version": "1.12.6", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "0fcbf194ab63d8159bb70d9aa3e1350051632009" + "reference": "dc4d2f145a88ea7141ae698effd64d9df46527ae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0fcbf194ab63d8159bb70d9aa3e1350051632009", - "reference": "0fcbf194ab63d8159bb70d9aa3e1350051632009", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc4d2f145a88ea7141ae698effd64d9df46527ae", + "reference": "dc4d2f145a88ea7141ae698effd64d9df46527ae", "shasum": "" }, "require": { @@ -6108,7 +6190,7 @@ "type": "github" } ], - "time": "2024-09-09T08:10:35+00:00" + "time": "2024-10-06T15:03:59+00:00" }, { "name": "pion/laravel-chunk-upload", @@ -6814,16 +6896,16 @@ }, { "name": "purplepixie/phpdns", - "version": "2.1.1", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/purplepixie/phpdns.git", - "reference": "18cd3a43fadcfd16e2789e3c78a264945f6cbfad" + "reference": "2b77de5bb218bc4e5d9c4a4a12bd18fe80a6ab4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/purplepixie/phpdns/zipball/18cd3a43fadcfd16e2789e3c78a264945f6cbfad", - "reference": "18cd3a43fadcfd16e2789e3c78a264945f6cbfad", + "url": "https://api.github.com/repos/purplepixie/phpdns/zipball/2b77de5bb218bc4e5d9c4a4a12bd18fe80a6ab4d", + "reference": "2b77de5bb218bc4e5d9c4a4a12bd18fe80a6ab4d", "shasum": "" }, "require": { @@ -6856,9 +6938,9 @@ "description": "PHP DNS Direct Query Module", "support": { "issues": "https://github.com/purplepixie/phpdns/issues", - "source": "https://github.com/purplepixie/phpdns/tree/2.1.1" + "source": "https://github.com/purplepixie/phpdns/tree/2.2.0" }, - "time": "2024-05-27T13:27:50+00:00" + "time": "2024-09-26T14:39:58+00:00" }, { "name": "pusher/pusher-php-server", @@ -7148,21 +7230,21 @@ }, { "name": "rector/rector", - "version": "1.2.5", + "version": "1.2.6", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "e98aa793ca3fcd17e893cfaf9103ac049775d339" + "reference": "6ca85da28159dbd3bb36211c5104b7bc91278e99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/e98aa793ca3fcd17e893cfaf9103ac049775d339", - "reference": "e98aa793ca3fcd17e893cfaf9103ac049775d339", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/6ca85da28159dbd3bb36211c5104b7bc91278e99", + "reference": "6ca85da28159dbd3bb36211c5104b7bc91278e99", "shasum": "" }, "require": { "php": "^7.2|^8.0", - "phpstan/phpstan": "^1.12.2" + "phpstan/phpstan": "^1.12.5" }, "conflict": { "rector/rector-doctrine": "*", @@ -7195,7 +7277,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/1.2.5" + "source": "https://github.com/rectorphp/rector/tree/1.2.6" }, "funding": [ { @@ -7203,7 +7285,7 @@ "type": "github" } ], - "time": "2024-09-08T17:43:24+00:00" + "time": "2024-10-03T08:56:44+00:00" }, { "name": "resend/resend-laravel", @@ -7494,16 +7576,16 @@ }, { "name": "sentry/sentry-laravel", - "version": "4.8.0", + "version": "4.9.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-laravel.git", - "reference": "2bbcb7e81097993cf64d5b296eaa6d396cddd5a7" + "reference": "73078e1f26d57f7a10e3bee2a2f543a02f6493c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/2bbcb7e81097993cf64d5b296eaa6d396cddd5a7", - "reference": "2bbcb7e81097993cf64d5b296eaa6d396cddd5a7", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/73078e1f26d57f7a10e3bee2a2f543a02f6493c3", + "reference": "73078e1f26d57f7a10e3bee2a2f543a02f6493c3", "shasum": "" }, "require": { @@ -7567,7 +7649,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-laravel/issues", - "source": "https://github.com/getsentry/sentry-laravel/tree/4.8.0" + "source": "https://github.com/getsentry/sentry-laravel/tree/4.9.0" }, "funding": [ { @@ -7579,7 +7661,7 @@ "type": "custom" } ], - "time": "2024-08-15T19:03:01+00:00" + "time": "2024-09-19T12:58:53+00:00" }, { "name": "socialiteproviders/manager", @@ -8580,16 +8662,16 @@ }, { "name": "symfony/console", - "version": "v7.1.4", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "1eed7af6961d763e7832e874d7f9b21c3ea9c111" + "reference": "0fa539d12b3ccf068a722bbbffa07ca7079af9ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/1eed7af6961d763e7832e874d7f9b21c3ea9c111", - "reference": "1eed7af6961d763e7832e874d7f9b21c3ea9c111", + "url": "https://api.github.com/repos/symfony/console/zipball/0fa539d12b3ccf068a722bbbffa07ca7079af9ee", + "reference": "0fa539d12b3ccf068a722bbbffa07ca7079af9ee", "shasum": "" }, "require": { @@ -8653,7 +8735,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.1.4" + "source": "https://github.com/symfony/console/tree/v7.1.5" }, "funding": [ { @@ -8669,7 +8751,7 @@ "type": "tidelift" } ], - "time": "2024-08-15T22:48:53+00:00" + "time": "2024-09-20T08:28:38+00:00" }, { "name": "symfony/css-selector", @@ -9100,16 +9182,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.1.3", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "f602d5c17d1fa02f8019ace2687d9d136b7f4a1a" + "reference": "e30ef73b1e44eea7eb37ba69600a354e553f694b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f602d5c17d1fa02f8019ace2687d9d136b7f4a1a", - "reference": "f602d5c17d1fa02f8019ace2687d9d136b7f4a1a", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/e30ef73b1e44eea7eb37ba69600a354e553f694b", + "reference": "e30ef73b1e44eea7eb37ba69600a354e553f694b", "shasum": "" }, "require": { @@ -9157,7 +9239,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.1.3" + "source": "https://github.com/symfony/http-foundation/tree/v7.1.5" }, "funding": [ { @@ -9173,20 +9255,20 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:41:01+00:00" + "time": "2024-09-20T08:28:38+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.1.4", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "6efcbd1b3f444f631c386504fc83eeca25963747" + "reference": "44204d96150a9df1fc57601ec933d23fefc2d65b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6efcbd1b3f444f631c386504fc83eeca25963747", - "reference": "6efcbd1b3f444f631c386504fc83eeca25963747", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/44204d96150a9df1fc57601ec933d23fefc2d65b", + "reference": "44204d96150a9df1fc57601ec933d23fefc2d65b", "shasum": "" }, "require": { @@ -9271,7 +9353,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.1.4" + "source": "https://github.com/symfony/http-kernel/tree/v7.1.5" }, "funding": [ { @@ -9287,20 +9369,20 @@ "type": "tidelift" } ], - "time": "2024-08-30T17:02:28+00:00" + "time": "2024-09-21T06:09:21+00:00" }, { "name": "symfony/mailer", - "version": "v7.1.2", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "8fcff0af9043c8f8a8e229437cea363e282f9aee" + "reference": "bbf21460c56f29810da3df3e206e38dfbb01e80b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/8fcff0af9043c8f8a8e229437cea363e282f9aee", - "reference": "8fcff0af9043c8f8a8e229437cea363e282f9aee", + "url": "https://api.github.com/repos/symfony/mailer/zipball/bbf21460c56f29810da3df3e206e38dfbb01e80b", + "reference": "bbf21460c56f29810da3df3e206e38dfbb01e80b", "shasum": "" }, "require": { @@ -9351,7 +9433,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.1.2" + "source": "https://github.com/symfony/mailer/tree/v7.1.5" }, "funding": [ { @@ -9367,20 +9449,20 @@ "type": "tidelift" } ], - "time": "2024-06-28T08:00:31+00:00" + "time": "2024-09-08T12:32:26+00:00" }, { "name": "symfony/mime", - "version": "v7.1.4", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "ccaa6c2503db867f472a587291e764d6a1e58758" + "reference": "711d2e167e8ce65b05aea6b258c449671cdd38ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/ccaa6c2503db867f472a587291e764d6a1e58758", - "reference": "ccaa6c2503db867f472a587291e764d6a1e58758", + "url": "https://api.github.com/repos/symfony/mime/zipball/711d2e167e8ce65b05aea6b258c449671cdd38ff", + "reference": "711d2e167e8ce65b05aea6b258c449671cdd38ff", "shasum": "" }, "require": { @@ -9435,7 +9517,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.1.4" + "source": "https://github.com/symfony/mime/tree/v7.1.5" }, "funding": [ { @@ -9451,7 +9533,7 @@ "type": "tidelift" } ], - "time": "2024-08-13T14:28:19+00:00" + "time": "2024-09-20T08:28:38+00:00" }, { "name": "symfony/options-resolver", @@ -10238,16 +10320,16 @@ }, { "name": "symfony/process", - "version": "v7.1.3", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "7f2f542c668ad6c313dc4a5e9c3321f733197eca" + "reference": "5c03ee6369281177f07f7c68252a280beccba847" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/7f2f542c668ad6c313dc4a5e9c3321f733197eca", - "reference": "7f2f542c668ad6c313dc4a5e9c3321f733197eca", + "url": "https://api.github.com/repos/symfony/process/zipball/5c03ee6369281177f07f7c68252a280beccba847", + "reference": "5c03ee6369281177f07f7c68252a280beccba847", "shasum": "" }, "require": { @@ -10279,7 +10361,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.1.3" + "source": "https://github.com/symfony/process/tree/v7.1.5" }, "funding": [ { @@ -10295,7 +10377,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:44:47+00:00" + "time": "2024-09-19T21:48:23+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -10608,16 +10690,16 @@ }, { "name": "symfony/string", - "version": "v7.1.4", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6cd670a6d968eaeb1c77c2e76091c45c56bc367b" + "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6cd670a6d968eaeb1c77c2e76091c45c56bc367b", - "reference": "6cd670a6d968eaeb1c77c2e76091c45c56bc367b", + "url": "https://api.github.com/repos/symfony/string/zipball/d66f9c343fa894ec2037cc928381df90a7ad4306", + "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306", "shasum": "" }, "require": { @@ -10675,7 +10757,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.1.4" + "source": "https://github.com/symfony/string/tree/v7.1.5" }, "funding": [ { @@ -10691,20 +10773,20 @@ "type": "tidelift" } ], - "time": "2024-08-12T09:59:40+00:00" + "time": "2024-09-20T08:28:38+00:00" }, { "name": "symfony/translation", - "version": "v7.1.3", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "8d5e50c813ba2859a6dfc99a0765c550507934a1" + "reference": "235535e3f84f3dfbdbde0208ede6ca75c3a489ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/8d5e50c813ba2859a6dfc99a0765c550507934a1", - "reference": "8d5e50c813ba2859a6dfc99a0765c550507934a1", + "url": "https://api.github.com/repos/symfony/translation/zipball/235535e3f84f3dfbdbde0208ede6ca75c3a489ea", + "reference": "235535e3f84f3dfbdbde0208ede6ca75c3a489ea", "shasum": "" }, "require": { @@ -10769,7 +10851,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.1.3" + "source": "https://github.com/symfony/translation/tree/v7.1.5" }, "funding": [ { @@ -10785,7 +10867,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:41:01+00:00" + "time": "2024-09-16T06:30:38+00:00" }, { "name": "symfony/translation-contracts", @@ -10867,16 +10949,16 @@ }, { "name": "symfony/uid", - "version": "v7.1.4", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "82177535395109075cdb45a70533aa3d7a521cdf" + "reference": "8c7bb8acb933964055215d89f9a9871df0239317" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/82177535395109075cdb45a70533aa3d7a521cdf", - "reference": "82177535395109075cdb45a70533aa3d7a521cdf", + "url": "https://api.github.com/repos/symfony/uid/zipball/8c7bb8acb933964055215d89f9a9871df0239317", + "reference": "8c7bb8acb933964055215d89f9a9871df0239317", "shasum": "" }, "require": { @@ -10921,7 +11003,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.1.4" + "source": "https://github.com/symfony/uid/tree/v7.1.5" }, "funding": [ { @@ -10937,20 +11019,20 @@ "type": "tidelift" } ], - "time": "2024-08-12T09:59:40+00:00" + "time": "2024-09-17T09:16:35+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.1.4", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "a5fa7481b199090964d6fd5dab6294d5a870c7aa" + "reference": "e20e03889539fd4e4211e14d2179226c513c010d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/a5fa7481b199090964d6fd5dab6294d5a870c7aa", - "reference": "a5fa7481b199090964d6fd5dab6294d5a870c7aa", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e20e03889539fd4e4211e14d2179226c513c010d", + "reference": "e20e03889539fd4e4211e14d2179226c513c010d", "shasum": "" }, "require": { @@ -11004,7 +11086,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.1.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.1.5" }, "funding": [ { @@ -11020,20 +11102,20 @@ "type": "tidelift" } ], - "time": "2024-08-30T16:12:47+00:00" + "time": "2024-09-16T10:07:02+00:00" }, { "name": "symfony/yaml", - "version": "v6.4.11", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "be37e7f13195e05ab84ca5269365591edd240335" + "reference": "762ee56b2649659380e0ef4d592d807bc17b7971" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/be37e7f13195e05ab84ca5269365591edd240335", - "reference": "be37e7f13195e05ab84ca5269365591edd240335", + "url": "https://api.github.com/repos/symfony/yaml/zipball/762ee56b2649659380e0ef4d592d807bc17b7971", + "reference": "762ee56b2649659380e0ef4d592d807bc17b7971", "shasum": "" }, "require": { @@ -11076,7 +11158,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.11" + "source": "https://github.com/symfony/yaml/tree/v6.4.12" }, "funding": [ { @@ -11092,7 +11174,7 @@ "type": "tidelift" } ], - "time": "2024-08-12T09:55:28+00:00" + "time": "2024-09-17T12:47:12+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -11742,16 +11824,16 @@ }, { "name": "zircote/swagger-php", - "version": "4.10.6", + "version": "4.11.0", "source": { "type": "git", "url": "https://github.com/zircote/swagger-php.git", - "reference": "e462ff5269ea0ec91070edd5d51dc7215bdea3b6" + "reference": "3b6f3800f4fd6544ada4dce180c6b69eaead7c7c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/e462ff5269ea0ec91070edd5d51dc7215bdea3b6", - "reference": "e462ff5269ea0ec91070edd5d51dc7215bdea3b6", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/3b6f3800f4fd6544ada4dce180c6b69eaead7c7c", + "reference": "3b6f3800f4fd6544ada4dce180c6b69eaead7c7c", "shasum": "" }, "require": { @@ -11765,7 +11847,7 @@ "require-dev": { "composer/package-versions-deprecated": "^1.11", "doctrine/annotations": "^1.7 || ^2.0", - "friendsofphp/php-cs-fixer": "^2.17 || ^3.47.1", + "friendsofphp/php-cs-fixer": "^2.17 || 3.62.0", "phpstan/phpstan": "^1.6", "phpunit/phpunit": ">=8", "vimeo/psalm": "^4.23" @@ -11817,12 +11899,96 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/4.10.6" + "source": "https://github.com/zircote/swagger-php/tree/4.11.0" }, - "time": "2024-07-26T03:04:43+00:00" + "time": "2024-10-09T03:11:12+00:00" } ], "packages-dev": [ + { + "name": "barryvdh/laravel-debugbar", + "version": "v3.14.3", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-debugbar.git", + "reference": "c0bee7c08ae2429e4a9ed2bc75679b012db6e3bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/c0bee7c08ae2429e4a9ed2bc75679b012db6e3bd", + "reference": "c0bee7c08ae2429e4a9ed2bc75679b012db6e3bd", + "shasum": "" + }, + "require": { + "illuminate/routing": "^9|^10|^11", + "illuminate/session": "^9|^10|^11", + "illuminate/support": "^9|^10|^11", + "maximebf/debugbar": "~1.23.0", + "php": "^8.0", + "symfony/finder": "^6|^7" + }, + "require-dev": { + "mockery/mockery": "^1.3.3", + "orchestra/testbench-dusk": "^5|^6|^7|^8|^9", + "phpunit/phpunit": "^9.6|^10.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.14-dev" + }, + "laravel": { + "providers": [ + "Barryvdh\\Debugbar\\ServiceProvider" + ], + "aliases": { + "Debugbar": "Barryvdh\\Debugbar\\Facades\\Debugbar" + } + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Barryvdh\\Debugbar\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "PHP Debugbar integration for Laravel", + "keywords": [ + "debug", + "debugbar", + "laravel", + "profiler", + "webprofiler" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-debugbar/issues", + "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.14.3" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2024-10-02T09:17:49+00:00" + }, { "name": "brianium/paratest", "version": "v7.4.3", @@ -12043,26 +12209,26 @@ }, { "name": "filp/whoops", - "version": "2.15.4", + "version": "2.16.0", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "a139776fa3f5985a50b509f2a02ff0f709d2a546" + "reference": "befcdc0e5dce67252aa6322d82424be928214fa2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/a139776fa3f5985a50b509f2a02ff0f709d2a546", - "reference": "a139776fa3f5985a50b509f2a02ff0f709d2a546", + "url": "https://api.github.com/repos/filp/whoops/zipball/befcdc0e5dce67252aa6322d82424be928214fa2", + "reference": "befcdc0e5dce67252aa6322d82424be928214fa2", "shasum": "" }, "require": { - "php": "^5.5.9 || ^7.0 || ^8.0", + "php": "^7.1 || ^8.0", "psr/log": "^1.0.1 || ^2.0 || ^3.0" }, "require-dev": { - "mockery/mockery": "^0.9 || ^1.0", - "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.3", - "symfony/var-dumper": "^2.6 || ^3.0 || ^4.0 || ^5.0" + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" }, "suggest": { "symfony/var-dumper": "Pretty print complex values better with var-dumper available", @@ -12102,7 +12268,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.15.4" + "source": "https://github.com/filp/whoops/tree/2.16.0" }, "funding": [ { @@ -12110,7 +12276,7 @@ "type": "github" } ], - "time": "2023-11-03T12:00:00+00:00" + "time": "2024-09-25T12:00:00+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -12165,16 +12331,16 @@ }, { "name": "laravel/dusk", - "version": "v8.2.5", + "version": "v8.2.8", "source": { "type": "git", "url": "https://github.com/laravel/dusk.git", - "reference": "e641800393ce4ad39f0a47133f51aae67ceb01ad" + "reference": "5bff1e8dd87ec653a2202475377152e5d14fde40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/e641800393ce4ad39f0a47133f51aae67ceb01ad", - "reference": "e641800393ce4ad39f0a47133f51aae67ceb01ad", + "url": "https://api.github.com/repos/laravel/dusk/zipball/5bff1e8dd87ec653a2202475377152e5d14fde40", + "reference": "5bff1e8dd87ec653a2202475377152e5d14fde40", "shasum": "" }, "require": { @@ -12231,22 +12397,22 @@ ], "support": { "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.2.5" + "source": "https://github.com/laravel/dusk/tree/v8.2.8" }, - "time": "2024-08-26T12:34:33+00:00" + "time": "2024-10-04T14:02:20+00:00" }, { "name": "laravel/pint", - "version": "v1.17.3", + "version": "v1.18.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "9d77be916e145864f10788bb94531d03e1f7b482" + "reference": "35c00c05ec43e6b46d295efc0f4386ceb30d50d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/9d77be916e145864f10788bb94531d03e1f7b482", - "reference": "9d77be916e145864f10788bb94531d03e1f7b482", + "url": "https://api.github.com/repos/laravel/pint/zipball/35c00c05ec43e6b46d295efc0f4386ceb30d50d9", + "reference": "35c00c05ec43e6b46d295efc0f4386ceb30d50d9", "shasum": "" }, "require": { @@ -12299,7 +12465,75 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2024-09-03T15:00:28+00:00" + "time": "2024-09-24T17:22:50+00:00" + }, + { + "name": "maximebf/debugbar", + "version": "v1.23.2", + "source": { + "type": "git", + "url": "https://github.com/maximebf/php-debugbar.git", + "reference": "689720d724c771ac4add859056744b7b3f2406da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/689720d724c771ac4add859056744b7b3f2406da", + "reference": "689720d724c771ac4add859056744b7b3f2406da", + "shasum": "" + }, + "require": { + "php": "^7.2|^8", + "psr/log": "^1|^2|^3", + "symfony/var-dumper": "^4|^5|^6|^7" + }, + "require-dev": { + "dbrekelmans/bdi": "^1", + "phpunit/phpunit": "^8|^9", + "symfony/panther": "^1|^2.1", + "twig/twig": "^1.38|^2.7|^3.0" + }, + "suggest": { + "kriswallsmith/assetic": "The best way to manage assets", + "monolog/monolog": "Log using Monolog", + "predis/predis": "Redis storage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.23-dev" + } + }, + "autoload": { + "psr-4": { + "DebugBar\\": "src/DebugBar/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maxime Bouroumeau-Fuseau", + "email": "maxime.bouroumeau@gmail.com", + "homepage": "http://maximebf.com" + }, + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "Debug bar in the browser for php application", + "homepage": "https://github.com/maximebf/php-debugbar", + "keywords": [ + "debug", + "debugbar" + ], + "support": { + "issues": "https://github.com/maximebf/php-debugbar/issues", + "source": "https://github.com/maximebf/php-debugbar/tree/v1.23.2" + }, + "time": "2024-09-16T11:23:09+00:00" }, { "name": "mockery/mockery", @@ -14740,16 +14974,16 @@ }, { "name": "symfony/http-client", - "version": "v6.4.11", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "4c92046bb788648ff1098cc66da69aa7eac8cb65" + "reference": "fbebfcce21084d3e91ea987ae5bdd8c71ff0fd56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/4c92046bb788648ff1098cc66da69aa7eac8cb65", - "reference": "4c92046bb788648ff1098cc66da69aa7eac8cb65", + "url": "https://api.github.com/repos/symfony/http-client/zipball/fbebfcce21084d3e91ea987ae5bdd8c71ff0fd56", + "reference": "fbebfcce21084d3e91ea987ae5bdd8c71ff0fd56", "shasum": "" }, "require": { @@ -14813,7 +15047,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.4.11" + "source": "https://github.com/symfony/http-client/tree/v6.4.12" }, "funding": [ { @@ -14829,7 +15063,7 @@ "type": "tidelift" } ], - "time": "2024-08-26T06:30:21+00:00" + "time": "2024-09-20T08:21:33+00:00" }, { "name": "symfony/http-client-contracts", diff --git a/config/clockwork.php b/config/clockwork.php deleted file mode 100644 index ce880464a..000000000 --- a/config/clockwork.php +++ /dev/null @@ -1,424 +0,0 @@ - env('CLOCKWORK_ENABLE', null), - - /* - |------------------------------------------------------------------------------------------------------------------ - | Features - |------------------------------------------------------------------------------------------------------------------ - | - | You can enable or disable various Clockwork features here. Some features have additional settings (eg. slow query - | threshold for database queries). - | - */ - - 'features' => [ - - // Cache usage stats and cache queries including results - 'cache' => [ - 'enabled' => env('CLOCKWORK_CACHE_ENABLED', true), - - // Collect cache queries - 'collect_queries' => env('CLOCKWORK_CACHE_QUERIES', true), - - // Collect values from cache queries (high performance impact with a very high number of queries) - 'collect_values' => env('CLOCKWORK_CACHE_COLLECT_VALUES', false) - ], - - // Database usage stats and queries - 'database' => [ - 'enabled' => env('CLOCKWORK_DATABASE_ENABLED', true), - - // Collect database queries (high performance impact with a very high number of queries) - 'collect_queries' => env('CLOCKWORK_DATABASE_COLLECT_QUERIES', true), - - // Collect details of models updates (high performance impact with a lot of model updates) - 'collect_models_actions' => env('CLOCKWORK_DATABASE_COLLECT_MODELS_ACTIONS', true), - - // Collect details of retrieved models (very high performance impact with a lot of models retrieved) - 'collect_models_retrieved' => env('CLOCKWORK_DATABASE_COLLECT_MODELS_RETRIEVED', false), - - // Query execution time threshold in milliseconds after which the query will be marked as slow - 'slow_threshold' => env('CLOCKWORK_DATABASE_SLOW_THRESHOLD'), - - // Collect only slow database queries - 'slow_only' => env('CLOCKWORK_DATABASE_SLOW_ONLY', false), - - // Detect and report duplicate queries - 'detect_duplicate_queries' => env('CLOCKWORK_DATABASE_DETECT_DUPLICATE_QUERIES', false) - ], - - // Dispatched events - 'events' => [ - 'enabled' => env('CLOCKWORK_EVENTS_ENABLED', true), - - // Ignored events (framework events are ignored by default) - 'ignored_events' => [ - // App\Events\UserRegistered::class, - // 'user.registered' - ], - ], - - // Laravel log (you can still log directly to Clockwork with laravel log disabled) - 'log' => [ - 'enabled' => env('CLOCKWORK_LOG_ENABLED', true) - ], - - // Sent notifications - 'notifications' => [ - 'enabled' => env('CLOCKWORK_NOTIFICATIONS_ENABLED', true), - ], - - // Performance metrics - 'performance' => [ - // Allow collecting of client metrics. Requires separate clockwork-browser npm package. - 'client_metrics' => env('CLOCKWORK_PERFORMANCE_CLIENT_METRICS', true) - ], - - // Dispatched queue jobs - 'queue' => [ - 'enabled' => env('CLOCKWORK_QUEUE_ENABLED', true) - ], - - // Redis commands - 'redis' => [ - 'enabled' => env('CLOCKWORK_REDIS_ENABLED', true) - ], - - // Routes list - 'routes' => [ - 'enabled' => env('CLOCKWORK_ROUTES_ENABLED', false), - - // Collect only routes from particular namespaces (only application routes by default) - 'only_namespaces' => [ 'App' ] - ], - - // Rendered views - 'views' => [ - 'enabled' => env('CLOCKWORK_VIEWS_ENABLED', true), - - // Collect views including view data (high performance impact with a high number of views) - 'collect_data' => env('CLOCKWORK_VIEWS_COLLECT_DATA', false), - - // Use Twig profiler instead of Laravel events for apps using laravel-twigbridge (more precise, but does - // not support collecting view data) - 'use_twig_profiler' => env('CLOCKWORK_VIEWS_USE_TWIG_PROFILER', false) - ] - - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Enable web UI - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork comes with a web UI accessible via http://your.app/clockwork. Here you can enable or disable this - | feature. You can also set a custom path for the web UI. - | - */ - - 'web' => env('CLOCKWORK_WEB', true), - - /* - |------------------------------------------------------------------------------------------------------------------ - | Enable toolbar - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork can show a toolbar with basic metrics on all responses. Here you can enable or disable this feature. - | Requires a separate clockwork-browser npm library. - | For installation instructions see https://underground.works/clockwork/#docs-viewing-data - | - */ - - 'toolbar' => env('CLOCKWORK_TOOLBAR', true), - - /* - |------------------------------------------------------------------------------------------------------------------ - | HTTP requests collection - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork collects data about HTTP requests to your app. Here you can choose which requests should be collected. - | - */ - - 'requests' => [ - // With on-demand mode enabled, Clockwork will only profile requests when the browser extension is open or you - // manually pass a "clockwork-profile" cookie or get/post data key. - // Optionally you can specify a "secret" that has to be passed as the value to enable profiling. - 'on_demand' => env('CLOCKWORK_REQUESTS_ON_DEMAND', false), - - // Collect only errors (requests with HTTP 4xx and 5xx responses) - 'errors_only' => env('CLOCKWORK_REQUESTS_ERRORS_ONLY', false), - - // Response time threshold in milliseconds after which the request will be marked as slow - 'slow_threshold' => env('CLOCKWORK_REQUESTS_SLOW_THRESHOLD'), - - // Collect only slow requests - 'slow_only' => env('CLOCKWORK_REQUESTS_SLOW_ONLY', false), - - // Sample the collected requests (e.g. set to 100 to collect only 1 in 100 requests) - 'sample' => env('CLOCKWORK_REQUESTS_SAMPLE', false), - - // List of URIs that should not be collected - 'except' => [ - '/horizon/.*', // Laravel Horizon requests - '/telescope/.*', // Laravel Telescope requests - '/_tt/.*', // Laravel Telescope toolbar - '/_debugbar/.*', // Laravel DebugBar requests - ], - - // List of URIs that should be collected, any other URI will not be collected if not empty - 'only' => [ - // '/api/.*' - ], - - // Don't collect OPTIONS requests, mostly used in the CSRF pre-flight requests and are rarely of interest - 'except_preflight' => env('CLOCKWORK_REQUESTS_EXCEPT_PREFLIGHT', true) - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Artisan commands collection - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork can collect data about executed artisan commands. Here you can enable and configure which commands - | should be collected. - | - */ - - 'artisan' => [ - // Enable or disable collection of executed Artisan commands - 'collect' => env('CLOCKWORK_ARTISAN_COLLECT', false), - - // List of commands that should not be collected (built-in commands are not collected by default) - 'except' => [ - // 'inspire' - ], - - // List of commands that should be collected, any other command will not be collected if not empty - 'only' => [ - // 'inspire' - ], - - // Enable or disable collection of command output - 'collect_output' => env('CLOCKWORK_ARTISAN_COLLECT_OUTPUT', false), - - // Enable or disable collection of built-in Laravel commands - 'except_laravel_commands' => env('CLOCKWORK_ARTISAN_EXCEPT_LARAVEL_COMMANDS', true) - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Queue jobs collection - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork can collect data about executed queue jobs. Here you can enable and configure which queue jobs should - | be collected. - | - */ - - 'queue' => [ - // Enable or disable collection of executed queue jobs - 'collect' => env('CLOCKWORK_QUEUE_COLLECT', false), - - // List of queue jobs that should not be collected - 'except' => [ - // App\Jobs\ExpensiveJob::class - ], - - // List of queue jobs that should be collected, any other queue job will not be collected if not empty - 'only' => [ - // App\Jobs\BuggyJob::class - ] - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Tests collection - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork can collect data about executed tests. Here you can enable and configure which tests should be - | collected. - | - */ - - 'tests' => [ - // Enable or disable collection of ran tests - 'collect' => env('CLOCKWORK_TESTS_COLLECT', false), - - // List of tests that should not be collected - 'except' => [ - // Tests\Unit\ExampleTest::class - ] - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Enable data collection when Clockwork is disabled - |------------------------------------------------------------------------------------------------------------------ - | - | You can enable this setting to collect data even when Clockwork is disabled, e.g. for future analysis. - | - */ - - 'collect_data_always' => env('CLOCKWORK_COLLECT_DATA_ALWAYS', false), - - /* - |------------------------------------------------------------------------------------------------------------------ - | Metadata storage - |------------------------------------------------------------------------------------------------------------------ - | - | Configure how is the metadata collected by Clockwork stored. Three options are available: - | - files - A simple fast storage implementation storing data in one-per-request files. - | - sql - Stores requests in a sql database. Supports MySQL, PostgreSQL and SQLite. Requires PDO. - | - redis - Stores requests in redis. Requires phpredis. - */ - - 'storage' => env('CLOCKWORK_STORAGE', 'files'), - - // Path where the Clockwork metadata is stored - 'storage_files_path' => env('CLOCKWORK_STORAGE_FILES_PATH', storage_path('clockwork')), - - // Compress the metadata files using gzip, trading a little bit of performance for lower disk usage - 'storage_files_compress' => env('CLOCKWORK_STORAGE_FILES_COMPRESS', false), - - // SQL database to use, can be a name of database configured in database.php or a path to a SQLite file - 'storage_sql_database' => env('CLOCKWORK_STORAGE_SQL_DATABASE', storage_path('clockwork.sqlite')), - - // SQL table name to use, the table is automatically created and updated when needed - 'storage_sql_table' => env('CLOCKWORK_STORAGE_SQL_TABLE', 'clockwork'), - - // Redis connection, name of redis connection or cluster configured in database.php - 'storage_redis' => env('CLOCKWORK_STORAGE_REDIS', 'default'), - - // Redis prefix for Clockwork keys ("clockwork" if not set) - 'storage_redis_prefix' => env('CLOCKWORK_STORAGE_REDIS_PREFIX', 'clockwork'), - - // Maximum lifetime of collected metadata in minutes, older requests will automatically be deleted, false to disable - 'storage_expiration' => env('CLOCKWORK_STORAGE_EXPIRATION', 60 * 24 * 7), - - /* - |------------------------------------------------------------------------------------------------------------------ - | Authentication - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork can be configured to require authentication before allowing access to the collected data. This might be - | useful when the application is publicly accessible. Setting to true will enable a simple authentication with a - | pre-configured password. You can also pass a class name of a custom implementation. - | - */ - - 'authentication' => env('CLOCKWORK_AUTHENTICATION', false), - - // Password for the simple authentication - 'authentication_password' => env('CLOCKWORK_AUTHENTICATION_PASSWORD', 'VerySecretPassword'), - - /* - |------------------------------------------------------------------------------------------------------------------ - | Stack traces collection - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork can collect stack traces for log messages and certain data like database queries. Here you can set - | whether to collect stack traces, limit the number of collected frames and set further configuration. Collecting - | long stack traces considerably increases metadata size. - | - */ - - 'stack_traces' => [ - // Enable or disable collecting of stack traces - 'enabled' => env('CLOCKWORK_STACK_TRACES_ENABLED', true), - - // Limit the number of frames to be collected - 'limit' => env('CLOCKWORK_STACK_TRACES_LIMIT', 10), - - // List of vendor names to skip when determining caller, common vendors are automatically added - 'skip_vendors' => [ - // 'phpunit' - ], - - // List of namespaces to skip when determining caller - 'skip_namespaces' => [ - // 'Laravel' - ], - - // List of class names to skip when determining caller - 'skip_classes' => [ - // App\CustomLog::class - ] - - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Serialization - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork serializes the collected data to json for storage and transfer. Here you can configure certain aspects - | of serialization. Serialization has a large effect on the cpu time and memory usage. - | - */ - - // Maximum depth of serialized multi-level arrays and objects - 'serialization_depth' => env('CLOCKWORK_SERIALIZATION_DEPTH', 10), - - // A list of classes that will never be serialized (e.g. a common service container class) - 'serialization_blackbox' => [ - \Illuminate\Container\Container::class, - \Illuminate\Foundation\Application::class, - \Laravel\Lumen\Application::class - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Register helpers - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork comes with a "clock" global helper function. You can use this helper to quickly log something and to - | access the Clockwork instance. - | - */ - - 'register_helpers' => env('CLOCKWORK_REGISTER_HELPERS', true), - - /* - |------------------------------------------------------------------------------------------------------------------ - | Send headers for AJAX request - |------------------------------------------------------------------------------------------------------------------ - | - | When trying to collect data, the AJAX method can sometimes fail if it is missing required headers. For example, an - | API might require a version number using Accept headers to route the HTTP request to the correct codebase. - | - */ - - 'headers' => [ - // 'Accept' => 'application/vnd.com.whatever.v1+json', - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Server timing - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork supports the W3C Server Timing specification, which allows for collecting a simple performance metrics - | in a cross-browser way. E.g. in Chrome, your app, database and timeline event timings will be shown in the Dev - | Tools network tab. This setting specifies the max number of timeline events that will be sent. Setting to false - | will disable the feature. - | - */ - - 'server_timing' => env('CLOCKWORK_SERVER_TIMING', 10) - -]; diff --git a/config/constants.php b/config/constants.php index 906ef3ba2..5792b358c 100644 --- a/config/constants.php +++ b/config/constants.php @@ -6,9 +6,8 @@ return [ 'contact' => 'https://coolify.io/docs/contact', ], 'ssh' => [ - // Using MUX - 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true), true), - 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', '1h'), + 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)), + 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600), 'connection_timeout' => 10, 'server_interval' => 20, 'command_timeout' => 7200, diff --git a/config/debugbar.php b/config/debugbar.php new file mode 100644 index 000000000..eae406ba7 --- /dev/null +++ b/config/debugbar.php @@ -0,0 +1,325 @@ + env('DEBUGBAR_ENABLED', null), + 'except' => [ + 'telescope*', + 'horizon*', + ], + + /* + |-------------------------------------------------------------------------- + | Storage settings + |-------------------------------------------------------------------------- + | + | DebugBar stores data for session/ajax requests. + | You can disable this, so the debugbar stores data in headers/session, + | but this can cause problems with large data collectors. + | By default, file storage (in the storage folder) is used. Redis and PDO + | can also be used. For PDO, run the package migrations first. + | + | Warning: Enabling storage.open will allow everyone to access previous + | request, do not enable open storage in publicly available environments! + | Specify a callback if you want to limit based on IP or authentication. + | Leaving it to null will allow localhost only. + */ + 'storage' => [ + 'enabled' => true, + 'open' => env('DEBUGBAR_OPEN_STORAGE'), // bool/callback. + 'driver' => 'file', // redis, file, pdo, socket, custom + 'path' => storage_path('debugbar'), // For file driver + 'connection' => null, // Leave null for default connection (Redis/PDO) + 'provider' => '', // Instance of StorageInterface for custom driver + 'hostname' => '127.0.0.1', // Hostname to use with the "socket" driver + 'port' => 2304, // Port to use with the "socket" driver + ], + + /* + |-------------------------------------------------------------------------- + | Editor + |-------------------------------------------------------------------------- + | + | Choose your preferred editor to use when clicking file name. + | + | Supported: "phpstorm", "vscode", "vscode-insiders", "vscode-remote", + | "vscode-insiders-remote", "vscodium", "textmate", "emacs", + | "sublime", "atom", "nova", "macvim", "idea", "netbeans", + | "xdebug", "espresso" + | + */ + + 'editor' => env('DEBUGBAR_EDITOR') ?: env('IGNITION_EDITOR', 'phpstorm'), + + /* + |-------------------------------------------------------------------------- + | Remote Path Mapping + |-------------------------------------------------------------------------- + | + | If you are using a remote dev server, like Laravel Homestead, Docker, or + | even a remote VPS, it will be necessary to specify your path mapping. + | + | Leaving one, or both of these, empty or null will not trigger the remote + | URL changes and Debugbar will treat your editor links as local files. + | + | "remote_sites_path" is an absolute base path for your sites or projects + | in Homestead, Vagrant, Docker, or another remote development server. + | + | Example value: "/home/vagrant/Code" + | + | "local_sites_path" is an absolute base path for your sites or projects + | on your local computer where your IDE or code editor is running on. + | + | Example values: "/Users//Code", "C:\Users\\Documents\Code" + | + */ + + 'remote_sites_path' => env('DEBUGBAR_REMOTE_SITES_PATH'), + 'local_sites_path' => env('DEBUGBAR_LOCAL_SITES_PATH', env('IGNITION_LOCAL_SITES_PATH')), + + /* + |-------------------------------------------------------------------------- + | Vendors + |-------------------------------------------------------------------------- + | + | Vendor files are included by default, but can be set to false. + | This can also be set to 'js' or 'css', to only include javascript or css vendor files. + | Vendor files are for css: font-awesome (including fonts) and highlight.js (css files) + | and for js: jquery and highlight.js + | So if you want syntax highlighting, set it to true. + | jQuery is set to not conflict with existing jQuery scripts. + | + */ + + 'include_vendors' => true, + + /* + |-------------------------------------------------------------------------- + | Capture Ajax Requests + |-------------------------------------------------------------------------- + | + | The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors), + | you can use this option to disable sending the data through the headers. + | + | Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools. + | + | Note for your request to be identified as ajax requests they must either send the header + | X-Requested-With with the value XMLHttpRequest (most JS libraries send this), or have application/json as a Accept header. + | + | By default `ajax_handler_auto_show` is set to true allowing ajax requests to be shown automatically in the Debugbar. + | Changing `ajax_handler_auto_show` to false will prevent the Debugbar from reloading. + */ + + 'capture_ajax' => true, + 'add_ajax_timing' => false, + 'ajax_handler_auto_show' => true, + 'ajax_handler_enable_tab' => true, + + /* + |-------------------------------------------------------------------------- + | Custom Error Handler for Deprecated warnings + |-------------------------------------------------------------------------- + | + | When enabled, the Debugbar shows deprecated warnings for Symfony components + | in the Messages tab. + | + */ + 'error_handler' => false, + + /* + |-------------------------------------------------------------------------- + | Clockwork integration + |-------------------------------------------------------------------------- + | + | The Debugbar can emulate the Clockwork headers, so you can use the Chrome + | Extension, without the server-side code. It uses Debugbar collectors instead. + | + */ + 'clockwork' => false, + + /* + |-------------------------------------------------------------------------- + | DataCollectors + |-------------------------------------------------------------------------- + | + | Enable/disable DataCollectors + | + */ + + 'collectors' => [ + 'phpinfo' => true, // Php version + 'messages' => true, // Messages + 'time' => true, // Time Datalogger + 'memory' => true, // Memory usage + 'exceptions' => true, // Exception displayer + 'log' => true, // Logs from Monolog (merged in messages if enabled) + 'db' => true, // Show database (PDO) queries and bindings + 'views' => true, // Views with their data + 'route' => true, // Current route information + 'auth' => false, // Display Laravel authentication status + 'gate' => true, // Display Laravel Gate checks + 'session' => true, // Display session data + 'symfony_request' => true, // Only one can be enabled.. + 'mail' => true, // Catch mail messages + 'laravel' => false, // Laravel version and environment + 'events' => false, // All events fired + 'default_request' => false, // Regular or special Symfony request logger + 'logs' => false, // Add the latest log messages + 'files' => false, // Show the included files + 'config' => false, // Display config settings + 'cache' => false, // Display cache events + 'models' => true, // Display models + 'livewire' => true, // Display Livewire (when available) + 'jobs' => false, // Display dispatched jobs + ], + + /* + |-------------------------------------------------------------------------- + | Extra options + |-------------------------------------------------------------------------- + | + | Configure some DataCollectors + | + */ + + 'options' => [ + 'time' => [ + 'memory_usage' => false, // Calculated by subtracting memory start and end, it may be inaccurate + ], + 'messages' => [ + 'trace' => true, // Trace the origin of the debug message + ], + 'memory' => [ + 'reset_peak' => false, // run memory_reset_peak_usage before collecting + 'with_baseline' => false, // Set boot memory usage as memory peak baseline + 'precision' => 0, // Memory rounding precision + ], + 'auth' => [ + 'show_name' => true, // Also show the users name/email in the debugbar + 'show_guards' => true, // Show the guards that are used + ], + 'db' => [ + 'with_params' => true, // Render SQL with the parameters substituted + 'backtrace' => true, // Use a backtrace to find the origin of the query in your files. + 'backtrace_exclude_paths' => [], // Paths to exclude from backtrace. (in addition to defaults) + 'timeline' => false, // Add the queries to the timeline + 'duration_background' => true, // Show shaded background on each query relative to how long it took to execute. + 'explain' => [ // Show EXPLAIN output on queries + 'enabled' => false, + 'types' => ['SELECT'], // Deprecated setting, is always only SELECT + ], + 'hints' => false, // Show hints for common mistakes + 'show_copy' => false, // Show copy button next to the query, + 'slow_threshold' => false, // Only track queries that last longer than this time in ms + 'memory_usage' => false, // Show queries memory usage + 'soft_limit' => 100, // After the soft limit, no parameters/backtrace are captured + 'hard_limit' => 500, // After the hard limit, queries are ignored + ], + 'mail' => [ + 'timeline' => false, // Add mails to the timeline + 'show_body' => true, + ], + 'views' => [ + 'timeline' => false, // Add the views to the timeline (Experimental) + 'data' => false, //true for all data, 'keys' for only names, false for no parameters. + 'group' => 50, // Group duplicate views. Pass value to auto-group, or true/false to force + 'exclude_paths' => [ // Add the paths which you don't want to appear in the views + 'vendor/filament', // Exclude Filament components by default + ], + ], + 'route' => [ + 'label' => true, // show complete route on bar + ], + 'session' => [ + 'hiddens' => [], // hides sensitive values using array paths + ], + 'symfony_request' => [ + 'hiddens' => [], // hides sensitive values using array paths, example: request_request.password + ], + 'events' => [ + 'data' => false, // collect events data, listeners + ], + 'logs' => [ + 'file' => null, + ], + 'cache' => [ + 'values' => true, // collect cache values + ], + ], + + /* + |-------------------------------------------------------------------------- + | Inject Debugbar in Response + |-------------------------------------------------------------------------- + | + | Usually, the debugbar is added just before , by listening to the + | Response after the App is done. If you disable this, you have to add them + | in your template yourself. See http://phpdebugbar.com/docs/rendering.html + | + */ + + 'inject' => true, + + /* + |-------------------------------------------------------------------------- + | DebugBar route prefix + |-------------------------------------------------------------------------- + | + | Sometimes you want to set route prefix to be used by DebugBar to load + | its resources from. Usually the need comes from misconfigured web server or + | from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97 + | + */ + 'route_prefix' => '_debugbar', + + /* + |-------------------------------------------------------------------------- + | DebugBar route middleware + |-------------------------------------------------------------------------- + | + | Additional middleware to run on the Debugbar routes + */ + 'route_middleware' => [], + + /* + |-------------------------------------------------------------------------- + | DebugBar route domain + |-------------------------------------------------------------------------- + | + | By default DebugBar route served from the same domain that request served. + | To override default domain, specify it as a non-empty value. + */ + 'route_domain' => null, + + /* + |-------------------------------------------------------------------------- + | DebugBar theme + |-------------------------------------------------------------------------- + | + | Switches between light and dark theme. If set to auto it will respect system preferences + | Possible values: auto, light, dark + */ + 'theme' => env('DEBUGBAR_THEME', 'auto'), + + /* + |-------------------------------------------------------------------------- + | Backtrace stack limit + |-------------------------------------------------------------------------- + | + | By default, the DebugBar limits the number of frames returned by the 'debug_backtrace()' function. + | If you need larger stacktraces, you can increase this number. Setting it to 0 will result in no limit. + */ + 'debug_backtrace_limit' => 50, +]; diff --git a/config/sentry.php b/config/sentry.php index bae81659a..e8b6ab098 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -7,7 +7,8 @@ return [ // The release version of your application // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) - 'release' => '4.0.0-beta.336', + 'release' => '4.0.0-beta.361', + // When left empty or `null` the Laravel environment will be used 'environment' => config('app.env'), diff --git a/config/version.php b/config/version.php index bab78d59c..0e83ff40e 100644 --- a/config/version.php +++ b/config/version.php @@ -1,3 +1,3 @@ schemalessAttributes('smtp'); }); - $instance_setting = InstanceSettings::get(); + $instance_setting = instanceSettings(); $instance_setting->smtp = [ 'enabled' => $instance_setting->smtp_enabled, 'from_address' => $instance_setting->smtp_from_address, diff --git a/database/migrations/2024_06_18_105948_move_server_metrics.php b/database/migrations/2024_06_18_105948_move_server_metrics.php index 26a1d1684..a6bccd16a 100644 --- a/database/migrations/2024_06_18_105948_move_server_metrics.php +++ b/database/migrations/2024_06_18_105948_move_server_metrics.php @@ -18,7 +18,7 @@ return new class extends Migration $table->boolean('is_metrics_enabled')->default(false); $table->integer('metrics_refresh_rate_seconds')->default(5); $table->integer('metrics_history_days')->default(30); - $table->string('metrics_token')->default(generateSentinelToken()); + $table->string('metrics_token')->nullable(); }); } diff --git a/database/migrations/2024_07_18_123458_add_force_cleanup_server.php b/database/migrations/2024_07_18_123458_add_force_cleanup_server.php index a33665bd0..ea3695b3f 100644 --- a/database/migrations/2024_07_18_123458_add_force_cleanup_server.php +++ b/database/migrations/2024_07_18_123458_add_force_cleanup_server.php @@ -12,7 +12,7 @@ return new class extends Migration public function up(): void { Schema::table('server_settings', function (Blueprint $table) { - $table->boolean('is_force_cleanup_enabled')->default(false)->after('is_sentinel_enabled'); + $table->boolean('is_force_cleanup_enabled')->default(false); }); } diff --git a/database/migrations/2024_09_08_130756_update_server_settings_default_timezone.php b/database/migrations/2024_09_08_130756_update_server_settings_default_timezone.php index db1322e62..109bc40ef 100644 --- a/database/migrations/2024_09_08_130756_update_server_settings_default_timezone.php +++ b/database/migrations/2024_09_08_130756_update_server_settings_default_timezone.php @@ -2,8 +2,8 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; class UpdateServerSettingsDefaultTimezone extends Migration { diff --git a/database/migrations/2024_09_16_111428_encrypt_existing_private_keys.php b/database/migrations/2024_09_16_111428_encrypt_existing_private_keys.php new file mode 100644 index 000000000..19274ad9b --- /dev/null +++ b/database/migrations/2024_09_16_111428_encrypt_existing_private_keys.php @@ -0,0 +1,25 @@ +chunkById(100, function ($keys) { + foreach ($keys as $key) { + DB::table('private_keys') + ->where('id', $key->id) + ->update(['private_key' => Crypt::encryptString($key->private_key)]); + } + }); + } catch (\Exception $e) { + echo 'Encrypting private keys failed.'; + echo $e->getMessage(); + } + + } +} diff --git a/database/migrations/2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table.php b/database/migrations/2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table.php new file mode 100644 index 000000000..dfce5682a --- /dev/null +++ b/database/migrations/2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table.php @@ -0,0 +1,39 @@ +string('fingerprint')->after('private_key')->nullable(); + }); + + try { + DB::table('private_keys')->chunkById(100, function ($keys) { + foreach ($keys as $key) { + $fingerprint = PrivateKey::generateFingerprint($key->private_key); + if ($fingerprint) { + $key->fingerprint = $fingerprint; + $key->save(); + } + } + }); + } catch (\Exception $e) { + echo 'Generating fingerprints failed.'; + echo $e->getMessage(); + } + } + + public function down() + { + Schema::table('private_keys', function (Blueprint $table) { + $table->dropColumn('fingerprint'); + }); + } +} diff --git a/database/migrations/2024_09_22_165240_add_advanced_options_to_cleanup_options_to_servers_settings_table.php b/database/migrations/2024_09_22_165240_add_advanced_options_to_cleanup_options_to_servers_settings_table.php new file mode 100644 index 000000000..b3c58afe3 --- /dev/null +++ b/database/migrations/2024_09_22_165240_add_advanced_options_to_cleanup_options_to_servers_settings_table.php @@ -0,0 +1,24 @@ +boolean('delete_unused_volumes')->default(false); + $table->boolean('delete_unused_networks')->default(false); + }); + } + + public function down() + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('delete_unused_volumes'); + $table->dropColumn('delete_unused_networks'); + }); + } +}; diff --git a/database/migrations/2024_09_26_083441_disable_api_by_default.php b/database/migrations/2024_09_26_083441_disable_api_by_default.php new file mode 100644 index 000000000..d0803f751 --- /dev/null +++ b/database/migrations/2024_09_26_083441_disable_api_by_default.php @@ -0,0 +1,18 @@ +boolean('is_api_enabled')->default(false)->change(); + }); + } +}; diff --git a/database/migrations/2024_10_03_095427_add_dump_all_to_standalone_postgresqls.php b/database/migrations/2024_10_03_095427_add_dump_all_to_standalone_postgresqls.php new file mode 100644 index 000000000..b1f301bd3 --- /dev/null +++ b/database/migrations/2024_10_03_095427_add_dump_all_to_standalone_postgresqls.php @@ -0,0 +1,28 @@ +boolean('dump_all')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('scheduled_database_backups', function (Blueprint $table) { + $table->dropColumn('dump_all'); + }); + } +}; diff --git a/database/migrations/2024_10_10_081444_remove_constraint_from_service_applications_fqdn.php b/database/migrations/2024_10_10_081444_remove_constraint_from_service_applications_fqdn.php new file mode 100644 index 000000000..feb144de6 --- /dev/null +++ b/database/migrations/2024_10_10_081444_remove_constraint_from_service_applications_fqdn.php @@ -0,0 +1,34 @@ +dropUnique(['fqdn']); + }); + Schema::table('applications', function (Blueprint $table) { + $table->dropUnique(['fqdn']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('service_applications', function (Blueprint $table) { + $table->unique('fqdn'); + }); + Schema::table('applications', function (Blueprint $table) { + $table->unique('fqdn'); + }); + } +}; diff --git a/database/migrations/2024_10_11_114331_add_required_env_variables.php b/database/migrations/2024_10_11_114331_add_required_env_variables.php new file mode 100644 index 000000000..4fde0c2bb --- /dev/null +++ b/database/migrations/2024_10_11_114331_add_required_env_variables.php @@ -0,0 +1,28 @@ +boolean('is_required')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('is_required'); + }); + } +}; diff --git a/database/migrations/2024_10_14_090416_update_metrics_token_in_server_settings.php b/database/migrations/2024_10_14_090416_update_metrics_token_in_server_settings.php new file mode 100644 index 000000000..d5c38501f --- /dev/null +++ b/database/migrations/2024_10_14_090416_update_metrics_token_in_server_settings.php @@ -0,0 +1,54 @@ +dropColumn('metrics_token'); + $table->dropColumn('metrics_refresh_rate_seconds'); + $table->dropColumn('metrics_history_days'); + $table->dropColumn('is_server_api_enabled'); + + $table->boolean('is_sentinel_enabled')->default(false); + $table->text('sentinel_token')->nullable(); + $table->integer('sentinel_metrics_refresh_rate_seconds')->default(10); + $table->integer('sentinel_metrics_history_days')->default(7); + $table->integer('sentinel_push_interval_seconds')->default(60); + $table->string('sentinel_custom_url')->nullable(); + }); + Schema::table('servers', function (Blueprint $table) { + $table->dateTime('sentinel_updated_at')->default(now()); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->string('metrics_token')->nullable(); + $table->integer('metrics_refresh_rate_seconds')->default(5); + $table->integer('metrics_history_days')->default(30); + $table->boolean('is_server_api_enabled')->default(false); + + $table->dropColumn('is_sentinel_enabled'); + $table->dropColumn('sentinel_token'); + $table->dropColumn('sentinel_metrics_refresh_rate_seconds'); + $table->dropColumn('sentinel_metrics_history_days'); + $table->dropColumn('sentinel_push_interval_seconds'); + $table->dropColumn('sentinel_custom_url'); + }); + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('sentinel_updated_at'); + }); + } +}; diff --git a/database/migrations/2024_10_17_093722_add_soft_delete_to_servers.php b/database/migrations/2024_10_17_093722_add_soft_delete_to_servers.php new file mode 100644 index 000000000..7a7f28e24 --- /dev/null +++ b/database/migrations/2024_10_17_093722_add_soft_delete_to_servers.php @@ -0,0 +1,28 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/seeders/ApplicationPreviewSeeder.php b/database/seeders/ApplicationPreviewSeeder.php deleted file mode 100644 index 764939073..000000000 --- a/database/seeders/ApplicationPreviewSeeder.php +++ /dev/null @@ -1,20 +0,0 @@ - $application_1->id, - // 'pull_request_id' => 1 - // ]); - } -} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b3fac350f..cec05c8fe 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,26 +13,20 @@ class DatabaseSeeder extends Seeder UserSeeder::class, TeamSeeder::class, PrivateKeySeeder::class, + PopulateSshKeysDirectorySeeder::class, ServerSeeder::class, ServerSettingSeeder::class, ProjectSeeder::class, - ProjectSettingSeeder::class, - EnvironmentSeeder::class, StandaloneDockerSeeder::class, - SwarmDockerSeeder::class, - KubernetesSeeder::class, GithubAppSeeder::class, GitlabAppSeeder::class, ApplicationSeeder::class, ApplicationSettingsSeeder::class, - ApplicationPreviewSeeder::class, - EnvironmentVariableSeeder::class, LocalPersistentVolumeSeeder::class, S3StorageSeeder::class, StandalonePostgresqlSeeder::class, - ScheduledDatabaseBackupSeeder::class, - ScheduledDatabaseBackupExecutionSeeder::class, OauthSettingSeeder::class, + SentinelSeeder::class, ]); } } diff --git a/database/seeders/EnvironmentSeeder.php b/database/seeders/EnvironmentSeeder.php deleted file mode 100644 index 1c6d562a9..000000000 --- a/database/seeders/EnvironmentSeeder.php +++ /dev/null @@ -1,13 +0,0 @@ - 'NODE_ENV', - // 'value' => 'production', - // 'is_build_time' => true, - // 'application_id' => 1, - // ]); - } -} diff --git a/database/seeders/GitSeeder.php b/database/seeders/GitSeeder.php deleted file mode 100644 index c8dc3ab6d..000000000 --- a/database/seeders/GitSeeder.php +++ /dev/null @@ -1,26 +0,0 @@ - 'https://api.github.com', - // 'html_url' => 'https://github.com', - // 'is_public' => false, - // 'private_key_id' => $private_key_1->id, - // 'project_id' => $project->id, - // ]); - } -} diff --git a/database/seeders/GithubAppSeeder.php b/database/seeders/GithubAppSeeder.php index 4aa5ec753..2ece7a05b 100644 --- a/database/seeders/GithubAppSeeder.php +++ b/database/seeders/GithubAppSeeder.php @@ -31,7 +31,7 @@ class GithubAppSeeder extends Seeder 'client_id' => 'Iv1.220e564d2b0abd8c', 'client_secret' => '116d1d80289f378410dd70ab4e4b81dd8d2c52b6', 'webhook_secret' => '326a47b49054f03288f800d81247ec9414d0abf3', - 'private_key_id' => 1, + 'private_key_id' => 2, 'team_id' => 0, ]); } diff --git a/database/seeders/GitlabAppSeeder.php b/database/seeders/GitlabAppSeeder.php index af63f2ed7..ec2b7ec5e 100644 --- a/database/seeders/GitlabAppSeeder.php +++ b/database/seeders/GitlabAppSeeder.php @@ -20,19 +20,5 @@ class GitlabAppSeeder extends Seeder 'is_public' => true, 'team_id' => 0, ]); - GitlabApp::create([ - 'id' => 2, - 'name' => 'coolify-laravel-development-private-gitlab', - 'api_url' => 'https://gitlab.com/api/v4', - 'html_url' => 'https://gitlab.com', - 'app_id' => 1234, - 'app_secret' => '1234', - 'oauth_id' => 1234, - 'deploy_key_id' => '1234', - 'public_key' => 'dfjasiourj', - 'webhook_token' => '4u3928u4y392', - 'private_key_id' => 2, - 'team_id' => 0, - ]); } } diff --git a/database/seeders/InstanceSettingsSeeder.php b/database/seeders/InstanceSettingsSeeder.php index c3182a2dd..35fc8506b 100644 --- a/database/seeders/InstanceSettingsSeeder.php +++ b/database/seeders/InstanceSettingsSeeder.php @@ -27,14 +27,14 @@ class InstanceSettingsSeeder extends Seeder $ipv4 = Process::run('curl -4s https://ifconfig.io')->output(); $ipv4 = trim($ipv4); $ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP); - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); if (is_null($settings->public_ipv4) && $ipv4) { $settings->update(['public_ipv4' => $ipv4]); } $ipv6 = Process::run('curl -6s https://ifconfig.io')->output(); $ipv6 = trim($ipv6); $ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP); - $settings = \App\Models\InstanceSettings::get(); + $settings = instanceSettings(); if (is_null($settings->public_ipv6) && $ipv6) { $settings->update(['public_ipv6' => $ipv6]); } diff --git a/database/seeders/KubernetesSeeder.php b/database/seeders/KubernetesSeeder.php deleted file mode 100644 index f6a852e05..000000000 --- a/database/seeders/KubernetesSeeder.php +++ /dev/null @@ -1,16 +0,0 @@ -deleteDirectory(''); + Storage::disk('ssh-keys')->makeDirectory(''); + Storage::disk('ssh-mux')->deleteDirectory(''); + Storage::disk('ssh-mux')->makeDirectory(''); + + PrivateKey::chunk(100, function ($keys) { + foreach ($keys as $key) { + $key->storeInFileSystem(); + } + }); + + if (isDev()) { + $user = env('PUID').':'.env('PGID'); + Process::run("chown -R $user ".storage_path('app/ssh/keys')); + Process::run("chown -R $user ".storage_path('app/ssh/mux')); + } else { + Process::run('chown -R 9999:root '.storage_path('app/ssh/keys')); + Process::run('chown -R 9999:root '.storage_path('app/ssh/mux')); + } + } catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + ray($e->getMessage()); + } + } +} diff --git a/database/seeders/PrivateKeySeeder.php b/database/seeders/PrivateKeySeeder.php index 8a70cf56d..6b44d0867 100644 --- a/database/seeders/PrivateKeySeeder.php +++ b/database/seeders/PrivateKeySeeder.php @@ -13,9 +13,8 @@ class PrivateKeySeeder extends Seeder public function run(): void { PrivateKey::create([ - 'id' => 0, 'team_id' => 0, - 'name' => 'Testing-host', + 'name' => 'Testing Host Key', 'description' => 'This is a test docker container', 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW @@ -25,10 +24,9 @@ AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== -----END OPENSSH PRIVATE KEY----- ', - ]); + PrivateKey::create([ - 'id' => 1, 'team_id' => 0, 'name' => 'development-github-app', 'description' => 'This is the key for using the development GitHub app', @@ -61,12 +59,5 @@ a1C8EDKapCw5hAhizEFOUQKOygL8Ipn+tmEUkORYdZ8Q8cWFCv9nIw== -----END RSA PRIVATE KEY-----', 'is_git_related' => true, ]); - PrivateKey::create([ - 'id' => 2, - 'team_id' => 0, - 'name' => 'development-gitlab-app', - 'description' => 'This is the key for using the development Gitlab app', - 'private_key' => 'asdf', - ]); } } diff --git a/database/seeders/ProductionSeeder.php b/database/seeders/ProductionSeeder.php index 799dd0440..90b9d46ff 100644 --- a/database/seeders/ProductionSeeder.php +++ b/database/seeders/ProductionSeeder.php @@ -64,32 +64,8 @@ class ProductionSeeder extends Seeder 'team_id' => 0, ]); } - - if (! isCloud() && config('coolify.is_windows_docker_desktop') == false) { - echo "Checking localhost key.\n"; - // Save SSH Keys for the Coolify Host - $coolify_key_name = 'id.root@host.docker.internal'; - $coolify_key = Storage::disk('ssh-keys')->get("{$coolify_key_name}"); - - if ($coolify_key) { - PrivateKey::updateOrCreate( - [ - 'id' => 0, - 'team_id' => 0, - ], - [ - 'name' => 'localhost\'s key', - 'description' => 'The private key for the Coolify host machine (localhost).', - 'private_key' => $coolify_key, - ] - ); - } else { - echo "No SSH key found for the Coolify host machine (localhost).\n"; - echo "Please generate one and save it in /data/coolify/ssh/keys/{$coolify_key_name}\n"; - echo "Then try to install again.\n"; - exit(1); - } - // Add Coolify host (localhost) as Server if it doesn't exist + // Add Coolify host (localhost) as Server if it doesn't exist + if (! isCloud()) { if (Server::find(0) == null) { $server_details = [ 'id' => 0, @@ -123,6 +99,35 @@ class ProductionSeeder extends Seeder ]); } } + + if (! isCloud() && config('coolify.is_windows_docker_desktop') == false) { + $coolify_key_name = '@host.docker.internal'; + $ssh_keys_directory = Storage::disk('ssh-keys')->files(); + $coolify_key = collect($ssh_keys_directory)->firstWhere(fn ($item) => str($item)->contains($coolify_key_name)); + + $server = Server::find(0); + $found = $server->privateKey; + if (! $found) { + if ($coolify_key) { + $user = str($coolify_key)->before('@')->after('id.'); + $coolify_key = Storage::disk('ssh-keys')->get($coolify_key); + PrivateKey::create([ + 'id' => 0, + 'team_id' => 0, + 'name' => 'localhost\'s key', + 'description' => 'The private key for the Coolify host machine (localhost).', + 'private_key' => $coolify_key, + ]); + $server->update(['user' => $user]); + echo "SSH key found for the Coolify host machine (localhost).\n"; + } else { + echo "No SSH key found for the Coolify host machine (localhost).\n"; + echo "Please read the following documentation (point 3) to fix it: https://coolify.io/docs/knowledge-base/server/openssh/\n"; + echo "Your localhost connection won't work until then."; + } + } + + } if (config('coolify.is_windows_docker_desktop')) { PrivateKey::updateOrCreate( [ @@ -179,8 +184,9 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== get_public_ips(); - $oauth_settings_seeder = new OauthSettingSeeder; - $oauth_settings_seeder->run(); + $this->call(OauthSettingSeeder::class); + $this->call(PopulateSshKeysDirectorySeeder::class); + $this->call(SentinelSeeder::class); } } diff --git a/database/seeders/ProjectSettingSeeder.php b/database/seeders/ProjectSettingSeeder.php deleted file mode 100644 index 2a8cdfdb4..000000000 --- a/database/seeders/ProjectSettingSeeder.php +++ /dev/null @@ -1,15 +0,0 @@ -settings->wildcard_domain = 'wildcard.example.com'; - // $first_project->settings->save(); - } -} diff --git a/database/seeders/ScheduledDatabaseBackupExecutionSeeder.php b/database/seeders/ScheduledDatabaseBackupExecutionSeeder.php deleted file mode 100644 index 7e4c33764..000000000 --- a/database/seeders/ScheduledDatabaseBackupExecutionSeeder.php +++ /dev/null @@ -1,28 +0,0 @@ - 'success', - // 'message' => 'Backup created successfully.', - // 'size' => '10243467789556', - // 'scheduled_database_backup_id' => 1, - // ]); - // ScheduledDatabaseBackupExecution::create([ - // 'status' => 'failed', - // 'message' => 'Backup failed.', - // 'size' => '10243456', - // 'scheduled_database_backup_id' => 1, - // ]); - } -} diff --git a/database/seeders/ScheduledDatabaseBackupSeeder.php b/database/seeders/ScheduledDatabaseBackupSeeder.php deleted file mode 100644 index fefbada0d..000000000 --- a/database/seeders/ScheduledDatabaseBackupSeeder.php +++ /dev/null @@ -1,33 +0,0 @@ - true, - // 'frequency' => '* * * * *', - // 'number_of_backups_locally' => 2, - // 'database_id' => 1, - // 'database_type' => 'App\Models\StandalonePostgresql', - // 's3_storage_id' => 1, - // 'team_id' => 0, - // ]); - // ScheduledDatabaseBackup::create([ - // 'enabled' => true, - // 'frequency' => '* * * * *', - // 'number_of_backups_locally' => 3, - // 'database_id' => 1, - // 'database_type' => 'App\Models\StandalonePostgresql', - // 'team_id' => 0, - // ]); - } -} diff --git a/database/seeders/SentinelSeeder.php b/database/seeders/SentinelSeeder.php new file mode 100644 index 000000000..117ba6782 --- /dev/null +++ b/database/seeders/SentinelSeeder.php @@ -0,0 +1,31 @@ +settings->sentinel_token)->isEmpty()) { + $server->settings->generateSentinelToken(); + } + if (str($server->settings->sentinel_custom_url)->isEmpty()) { + $url = $server->settings->generateSentinelUrl(); + if (str($url)->isEmpty()) { + $server->settings->is_sentinel_enabled = false; + $server->settings->save(); + } + } + } catch (\Throwable $e) { + loggy("Error: {$e->getMessage()}\n"); + } + } + }); + } +} diff --git a/database/seeders/ServerSeeder.php b/database/seeders/ServerSeeder.php index 12594bcb9..d32843107 100644 --- a/database/seeders/ServerSeeder.php +++ b/database/seeders/ServerSeeder.php @@ -2,6 +2,8 @@ namespace Database\Seeders; +use App\Enums\ProxyStatus; +use App\Enums\ProxyTypes; use App\Models\Server; use Illuminate\Database\Seeder; @@ -15,7 +17,11 @@ class ServerSeeder extends Seeder 'description' => 'This is a test docker container in development mode', 'ip' => 'coolify-testing-host', 'team_id' => 0, - 'private_key_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], ]); } } diff --git a/database/seeders/ServiceApplicationSeeder.php b/database/seeders/ServiceApplicationSeeder.php deleted file mode 100644 index 04648f83c..000000000 --- a/database/seeders/ServiceApplicationSeeder.php +++ /dev/null @@ -1,16 +0,0 @@ - 'Swarm Docker 1', - // 'server_id' => 1, - // ]); - } -} diff --git a/database/seeders/WaitlistSeeder.php b/database/seeders/WaitlistSeeder.php deleted file mode 100644 index de6837c60..000000000 --- a/database/seeders/WaitlistSeeder.php +++ /dev/null @@ -1,16 +0,0 @@ - >(while read line; do echo "$(timestamp) [TERMINAL] $line"; done) 2>&1 & +TERMINAL_PID=$! + +# Start the Soketi process in the background with logging +node /app/bin/server.js start > >(while read line; do echo "$(timestamp) [SOKETI] $line"; done) 2>&1 & +SOKETI_PID=$! + +# Function to forward signals to child processes +forward_signal() { + kill -$1 $TERMINAL_PID $SOKETI_PID +} + +# Forward SIGTERM to child processes +trap 'forward_signal TERM' TERM + +# Wait for any process to exit +wait -n + +# Exit with status of process that exited first +exit $? diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js new file mode 100755 index 000000000..6633204b2 --- /dev/null +++ b/docker/coolify-realtime/terminal-server.js @@ -0,0 +1,239 @@ +import { WebSocketServer } from 'ws'; +import http from 'http'; +import pty from 'node-pty'; +import axios from 'axios'; +import cookie from 'cookie'; +import 'dotenv/config' + +const server = http.createServer((req, res) => { + if (req.url === '/ready') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('OK'); + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + } +}); + +const verifyClient = async (info, callback) => { + const cookies = cookie.parse(info.req.headers.cookie || ''); + // const origin = new URL(info.origin); + // const protocol = origin.protocol; + const xsrfToken = cookies['XSRF-TOKEN']; + + // Generate session cookie name based on APP_NAME + const appName = process.env.APP_NAME || 'laravel'; + const sessionCookieName = `${appName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}_session`; + const laravelSession = cookies[sessionCookieName]; + + // Verify presence of required tokens + if (!laravelSession || !xsrfToken) { + return callback(false, 401, 'Unauthorized: Missing required tokens'); + } + + try { + // Authenticate with Laravel backend + const response = await axios.post(`http://coolify/terminal/auth`, null, { + headers: { + 'Cookie': `${sessionCookieName}=${laravelSession}`, + 'X-XSRF-TOKEN': xsrfToken + }, + }); + + if (response.status === 200) { + // Authentication successful + callback(true); + } else { + callback(false, 401, 'Unauthorized: Invalid credentials'); + } + } catch (error) { + console.error('Authentication error:', error.message); + callback(false, 500, 'Internal Server Error'); + } +}; + + +const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); +const userSessions = new Map(); + +wss.on('connection', (ws) => { + const userId = generateUserId(); + const userSession = { ws, userId, ptyProcess: null, isActive: false }; + userSessions.set(userId, userSession); + + ws.on('message', (message) => { + handleMessage(userSession, message); + + }); + ws.on('error', (err) => handleError(err, userId)); + ws.on('close', () => handleClose(userId)); + +}); + +const messageHandlers = { + message: (session, data) => session.ptyProcess.write(data), + resize: (session, { cols, rows }) => { + cols = cols > 0 ? cols : 80; + rows = rows > 0 ? rows : 30; + session.ptyProcess.resize(cols, rows) + }, + pause: (session) => session.ptyProcess.pause(), + resume: (session) => session.ptyProcess.resume(), + checkActive: (session, data) => { + if (data === 'force' && session.isActive) { + killPtyProcess(session.userId); + } else { + session.ws.send(session.isActive); + } + }, + command: (session, data) => handleCommand(session.ws, data, session.userId) +}; + +function handleMessage(userSession, message) { + const parsed = parseMessage(message); + if (!parsed) return; + + Object.entries(parsed).forEach(([key, value]) => { + const handler = messageHandlers[key]; + if (handler && (userSession.isActive || key === 'checkActive' || key === 'command')) { + handler(userSession, value); + } + }); +} + +function parseMessage(message) { + try { + return JSON.parse(message); + } catch (e) { + console.error('Failed to parse message:', e); + return null; + } +} + +async function handleCommand(ws, command, userId) { + const userSession = userSessions.get(userId); + if (userSession && userSession.isActive) { + const result = await killPtyProcess(userId); + if (!result) { + // if terminal is still active, even after we tried to kill it, dont continue and show error + ws.send('unprocessable'); + return; + } + } + + const commandString = command[0].split('\n').join(' '); + const timeout = extractTimeout(commandString); + const sshArgs = extractSshArgs(commandString); + const hereDocContent = extractHereDocContent(commandString); + const options = { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: {}, + }; + + // NOTE: - Initiates a process within the Terminal container + // Establishes an SSH connection to root@coolify with RequestTTY enabled + // Executes the 'docker exec' command to connect to a specific container + const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); + + userSession.ptyProcess = ptyProcess; + userSession.isActive = true; + + ws.send('pty-ready'); + + ptyProcess.onData((data) => { + ws.send(data); + }); + + // when parent closes + ptyProcess.onExit(({ exitCode, signal }) => { + console.error(`Process exited with code ${exitCode} and signal ${signal}`); + ws.send('pty-exited'); + userSession.isActive = false; + + }); + + if (timeout) { + setTimeout(async () => { + await killPtyProcess(userId); + }, timeout * 1000); + } +} + +async function handleError(err, userId) { + console.error('WebSocket error:', err); + await killPtyProcess(userId); +} + +async function handleClose(userId) { + await killPtyProcess(userId); + userSessions.delete(userId); +} + +async function killPtyProcess(userId) { + const session = userSessions.get(userId); + if (!session?.ptyProcess) return false; + + return new Promise((resolve) => { + // Loop to ensure terminal is killed before continuing + let killAttempts = 0; + const maxAttempts = 5; + + const attemptKill = () => { + killAttempts++; + + // session.ptyProcess.kill() wont work here because of https://github.com/moby/moby/issues/9098 + // patch with https://github.com/moby/moby/issues/9098#issuecomment-189743947 + session.ptyProcess.write('set +o history\nkill -TERM -$$ && exit\nset -o history\n'); + + setTimeout(() => { + if (!session.isActive || !session.ptyProcess) { + resolve(true); + return; + } + + if (killAttempts < maxAttempts) { + attemptKill(); + } else { + resolve(false); + } + }, 500); + }; + + attemptKill(); + }); +} + +function generateUserId() { + return Math.random().toString(36).substring(2, 11); +} + +function extractTimeout(commandString) { + const timeoutMatch = commandString.match(/timeout (\d+)/); + return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; +} + +function extractSshArgs(commandString) { + const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); + let sshArgs = sshCommandMatch ? sshCommandMatch[1].split(' ') : []; + sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg); + if (!sshArgs.includes('RequestTTY=yes')) { + sshArgs.push('-o', 'RequestTTY=yes'); + } + return sshArgs; +} + +function extractHereDocContent(commandString) { + const delimiterMatch = commandString.match(/<< (\S+)/); + const delimiter = delimiterMatch ? delimiterMatch[1] : null; + const escapedDelimiter = delimiter.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); + const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`); + const hereDocMatch = commandString.match(hereDocRegex); + return hereDocMatch ? hereDocMatch[1] : ''; +} + +server.listen(6002, () => { + console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!'); +}); diff --git a/docker/prod/etc/s6-overlay/s6-rc.d/init-script/up b/docker/prod/etc/s6-overlay/s6-rc.d/init-script/up index ea960df95..3b252b782 100644 --- a/docker/prod/etc/s6-overlay/s6-rc.d/init-script/up +++ b/docker/prod/etc/s6-overlay/s6-rc.d/init-script/up @@ -1,3 +1,3 @@ #!/command/execlineb -P s6-setuidgid webuser -php /var/www/html/artisan app:init --full-cleanup +php /var/www/html/artisan app:init diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100644 index 000000000..69a5a9d41 --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,21 @@ +#!/bin/sh +# Detect whether /dev/tty is available & functional +if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then + exec < /dev/tty +fi + +# Get list of stashed PHP files +stashed_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.php') + +# If there are no stashed PHP files, exit early +if [ -z "$stashed_files" ]; then + exit 0 +fi + +# Set files variable to only include stashed PHP files +files="$stashed_files" + +$(pwd)/vendor/bin/pint $files -q +if [ $? -eq 0 ]; then + git add $files +fi diff --git a/lang/ar.json b/lang/ar.json index c5ec96c8d..4b9afbe99 100644 --- a/lang/ar.json +++ b/lang/ar.json @@ -26,5 +26,12 @@ "input.code": "الرمز لمرة واحدة", "input.recovery_code": "رمز الاسترداد", "button.save": "حفظ", - "repository.url": "أمثلة
للمستودعات العامة، استخدم https://....
للمستودعات الخاصة، استخدم git@....

سيتم تحديد الفرع main لـ https://github.com/coollabsio/coolify-examples
سيتم تحديد الفرع nodejs-fastify لـ https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify
سيتم تحديد الفرع main لـ https://gitea.com/sedlav/expressjs.git
سيتم تحديد الفرع main لـ https://gitlab.com/andrasbacsai/nodejs-example.git." + "repository.url": "أمثلة
للمستودعات العامة، استخدم https://....
للمستودعات الخاصة، استخدم git@....

سيتم تحديد الفرع main لـ https://github.com/coollabsio/coolify-examples
سيتم تحديد الفرع nodejs-fastify لـ https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify
سيتم تحديد الفرع main لـ https://gitea.com/sedlav/expressjs.git
سيتم تحديد الفرع main لـ https://gitlab.com/andrasbacsai/nodejs-example.git.", + "service.stop": "سيتم إيقاف هذه الخدمة.", + "resource.docker_cleanup": "قم بتشغيل Docker Cleanup (قم بإزالة الصور غير المستخدمة وذاكرة التخزين المؤقت للمنشئ).", + "resource.non_persistent": "سيتم حذف جميع البيانات غير الدائمة.", + "resource.delete_volumes": "حذف جميع المجلدات والملفات المرتبطة بهذا المورد بشكل دائم.", + "resource.delete_connected_networks": "حذف جميع الشبكات غير المحددة مسبقًا والمرتبطة بهذا المورد بشكل دائم.", + "resource.delete_configurations": "حذف جميع ملفات التعريف من الخادم بشكل دائم.", + "database.delete_backups_locally": "حذف كافة النسخ الاحتياطية نهائيًا من التخزين المحلي." } diff --git a/lang/en.json b/lang/en.json index 461a96e9a..fa69c7035 100644 --- a/lang/en.json +++ b/lang/en.json @@ -26,5 +26,12 @@ "input.code": "One-time code", "input.recovery_code": "Recovery code", "button.save": "Save", - "repository.url": "Examples
For Public repositories, use https://....
For Private repositories, use git@....

https://github.com/coollabsio/coolify-examples main branch will be selected
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify branch will be selected.
https://gitea.com/sedlav/expressjs.git main branch will be selected.
https://gitlab.com/andrasbacsai/nodejs-example.git main branch will be selected." + "repository.url": "Examples
For Public repositories, use https://....
For Private repositories, use git@....

https://github.com/coollabsio/coolify-examples main branch will be selected
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify branch will be selected.
https://gitea.com/sedlav/expressjs.git main branch will be selected.
https://gitlab.com/andrasbacsai/nodejs-example.git main branch will be selected.", + "service.stop": "This service will be stopped.", + "resource.docker_cleanup": "Run Docker Cleanup (remove unused images and builder cache).", + "resource.non_persistent": "All non-persistent data will be deleted.", + "resource.delete_volumes": "Permanently delete all volumes associated with this resource.", + "resource.delete_connected_networks": "Permanently delete all non-predefined networks associated with this resource.", + "resource.delete_configurations": "Permanently delete all configuration files from the server.", + "database.delete_backups_locally": "All backups will be permanently deleted from local storage." } diff --git a/lang/fr.json b/lang/fr.json index ae7fa0a03..dbd5a1bf7 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1,30 +1,37 @@ { - "auth.login": "Connexion", - "auth.login.azure": "Connexion avec Microsoft", - "auth.login.bitbucket": "Connexion avec Bitbucket", - "auth.login.github": "Connexion avec GitHub", - "auth.login.gitlab": "Connexion avec Gitlab", - "auth.login.google": "Connexion avec Google", - "auth.already_registered": "Déjà enregistré ?", - "auth.confirm_password": "Confirmer le mot de passe", - "auth.forgot_password": "Mot de passe oublié", - "auth.forgot_password_send_email": "Envoyer l'email de réinitialisation de mot de passe", - "auth.register_now": "S'enregistrer", - "auth.logout": "Déconnexion", - "auth.register": "S'enregistrer", - "auth.registration_disabled": "L'enregistrement est désactivé. Merci de contacter l'administateur.", - "auth.reset_password": "Réinitialiser le mot de passe", - "auth.failed": "Aucune correspondance n'a été trouvé pour les informations d'identification renseignées.", - "auth.failed.callback": "Erreur lors du processus de retour de la plateforme de connexion.", - "auth.failed.password": "Le mot de passe renseigné est incorrect.", - "auth.failed.email": "Aucun utilisateur avec cette adresse email n'a été trouvé.", - "auth.throttle": "Trop de tentatives de connexion. Merci de réessayer dans :seconds secondes.", - "input.name": "Nom", - "input.email": "Email", - "input.password": "Mot de passe", - "input.password.again": "Mot de passe identique", - "input.code": "Code à usage unique", - "input.recovery_code": "Code de récupération", - "button.save": "Sauvegarder", - "repository.url": "Exemples
Pour les dépôts publiques, utilisez https://....
Pour les dépôts privés, utilisez git@....

https://github.com/coollabsio/coolify-examples main sera la branche selectionnée
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify sera la branche selectionnée.
https://gitea.com/sedlav/expressjs.git main sera la branche selectionnée.
https://gitlab.com/andrasbacsai/nodejs-example.git main sera la branche selectionnée." + "auth.login": "Connexion", + "auth.login.azure": "Connexion avec Microsoft", + "auth.login.bitbucket": "Connexion avec Bitbucket", + "auth.login.github": "Connexion avec GitHub", + "auth.login.gitlab": "Connexion avec Gitlab", + "auth.login.google": "Connexion avec Google", + "auth.already_registered": "Déjà enregistré ?", + "auth.confirm_password": "Confirmer le mot de passe", + "auth.forgot_password": "Mot de passe oublié", + "auth.forgot_password_send_email": "Envoyer l'email de réinitialisation de mot de passe", + "auth.register_now": "S'enregistrer", + "auth.logout": "Déconnexion", + "auth.register": "S'enregistrer", + "auth.registration_disabled": "L'enregistrement est désactivé. Merci de contacter l'administrateur.", + "auth.reset_password": "Réinitialiser le mot de passe", + "auth.failed": "Aucune correspondance n'a été trouvée pour les informations d'identification renseignées.", + "auth.failed.callback": "Erreur lors du processus de retour de la plateforme de connexion.", + "auth.failed.password": "Le mot de passe renseigné est incorrect.", + "auth.failed.email": "Aucun utilisateur avec cette adresse email n'a été trouvé.", + "auth.throttle": "Trop de tentatives de connexion. Merci de réessayer dans :seconds secondes.", + "input.name": "Nom", + "input.email": "Email", + "input.password": "Mot de passe", + "input.password.again": "Mot de passe identique", + "input.code": "Code à usage unique", + "input.recovery_code": "Code de récupération", + "button.save": "Sauvegarder", + "repository.url": "Exemples
Pour les dépôts publiques, utilisez https://....
Pour les dépôts privés, utilisez git@....

https://github.com/coollabsio/coolify-examples main sera la branche selectionnée
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify sera la branche selectionnée.
https://gitea.com/sedlav/expressjs.git main sera la branche selectionnée.
https://gitlab.com/andrasbacsai/nodejs-example.git main sera la branche selectionnée.", + "service.stop": "Ce service sera arrêté.", + "resource.docker_cleanup": "Exécuter le nettoyage Docker (supprimer les images inutilisées et le cache du builder).", + "resource.non_persistent": "Toutes les données non persistantes seront supprimées.", + "resource.delete_volumes": "Supprimer définitivement tous les volumes associés à cette ressource.", + "resource.delete_connected_networks": "Supprimer définitivement tous les réseaux non-prédéfinis associés à cette ressource.", + "resource.delete_configurations": "Supprimer définitivement tous les fichiers de configuration du serveur.", + "database.delete_backups_locally": "Toutes les sauvegardes seront définitivement supprimées du stockage local." } diff --git a/lang/ro.json b/lang/ro.json new file mode 100644 index 000000000..db1aa85db --- /dev/null +++ b/lang/ro.json @@ -0,0 +1,37 @@ +{ + "auth.login": "Autentificare", + "auth.login.azure": "Autentificare prin Microsoft", + "auth.login.bitbucket": "Autentificare prin Bitbucket", + "auth.login.github": "Autentificare prin GitHub", + "auth.login.gitlab": "Autentificare prin Gitlab", + "auth.login.google": "Autentificare prin Google", + "auth.already_registered": "Sunteți deja înregistrat?", + "auth.confirm_password": "Confirmați parola", + "auth.forgot_password": "Ați uitat parola", + "auth.forgot_password_send_email": "Trimiteți e-mail-ul pentru resetarea parolei", + "auth.register_now": "Înregistrare", + "auth.logout": "Deconectare", + "auth.register": "Înregistrare", + "auth.registration_disabled": "Înregistrarea este dezactivată. Vă rugăm să contactați administratorul site-ului.", + "auth.reset_password": "Resetare parolă", + "auth.failed": "Autentificare nereușită. Vă rugăm să verificați datele introduse.", + "auth.failed.callback": "A apărut o eroare în timpul autentificării cu furnizorul extern.", + "auth.failed.password": "Parola furnizată este incorectă.", + "auth.failed.email": "Nu putem găsi un utilizator cu această adresă de e-mail.", + "auth.throttle": "Prea multe încercări de autentificare. Vă rugăm să încercați din nou în :seconds secunde.", + "input.name": "Nume", + "input.email": "E-mail", + "input.password": "Parolă", + "input.password.again": "Repetați parola", + "input.code": "Cod de unică folosință", + "input.recovery_code": "Cod de recuperare", + "button.save": "Salvare", + "repository.url": "Exemple
Pentru depozite publice, utilizați https://....
Pentru depozite private, utilizați git@....

https://github.com/coollabsio/coolify-examples va fi selectată ramura main
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify va fi selectată ramura nodejs-fastify.
https://gitea.com/sedlav/expressjs.git va fi selectată ramura main.
https://gitlab.com/andrasbacsai/nodejs-example.git va fi selectată ramura main.", + "service.stop": "Acest serviciu va fi oprit.", + "resource.docker_cleanup": "Executați curățarea Docker (eliminați imaginile neutilizate și memoria cache a constructorului).", + "resource.non_persistent": "Toate datele nepersistente vor fi șterse.", + "resource.delete_volumes": "Ștergeți definitiv toate volumele asociate cu această resursă.", + "resource.delete_connected_networks": "Ștergeți definitiv toate rețelele non-predefinite asociate cu această resursă.", + "resource.delete_configurations": "Ștergeți definitiv toate fișierele de configurare de pe server.", + "database.delete_backups_locally": "Toate copiile de rezervă vor fi șterse definitiv din stocarea locală." +} diff --git a/openapi.yaml b/openapi.yaml index ce0503e1f..0963857c9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -236,6 +236,10 @@ paths: watch_paths: type: string description: 'The watch paths.' + use_build_server: + type: boolean + nullable: true + description: 'Use build server.' type: object responses: '200': @@ -457,6 +461,10 @@ paths: watch_paths: type: string description: 'The watch paths.' + use_build_server: + type: boolean + nullable: true + description: 'Use build server.' type: object responses: '200': @@ -678,6 +686,10 @@ paths: watch_paths: type: string description: 'The watch paths.' + use_build_server: + type: boolean + nullable: true + description: 'Use build server.' type: object responses: '200': @@ -850,6 +862,10 @@ paths: instant_deploy: type: boolean description: 'The flag to indicate if the application should be deployed instantly.' + use_build_server: + type: boolean + nullable: true + description: 'Use build server.' type: object responses: '200': @@ -1013,6 +1029,10 @@ paths: instant_deploy: type: boolean description: 'The flag to indicate if the application should be deployed instantly.' + use_build_server: + type: boolean + nullable: true + description: 'Use build server.' type: object responses: '200': @@ -1067,6 +1087,10 @@ paths: instant_deploy: type: boolean description: 'The flag to indicate if the application should be deployed instantly.' + use_build_server: + type: boolean + nullable: true + description: 'Use build server.' type: object responses: '200': @@ -1126,9 +1150,33 @@ paths: type: string format: uuid - - name: cleanup + name: delete_configurations in: query - description: 'Delete configurations and volumes.' + description: 'Delete configurations.' + required: false + schema: + type: boolean + default: true + - + name: delete_volumes + in: query + description: 'Delete volumes.' + required: false + schema: + type: boolean + default: true + - + name: docker_cleanup + in: query + description: 'Run docker cleanup.' + required: false + schema: + type: boolean + default: true + - + name: delete_connected_networks + in: query + description: 'Delete connected networks.' required: false schema: type: boolean @@ -1351,6 +1399,10 @@ paths: watch_paths: type: string description: 'The watch paths.' + use_build_server: + type: boolean + nullable: true + description: 'Use build server.' type: object responses: '200': @@ -1738,6 +1790,52 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/execute': + post: + tags: + - Applications + summary: 'Execute Command' + description: "Execute a command on the application's current container." + operationId: execute-command-application + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + format: uuid + requestBody: + description: 'Command to execute.' + required: true + content: + application/json: + schema: + properties: + command: + type: string + description: 'Command to execute.' + type: object + responses: + '200': + description: "Execute a command on the application's current container." + content: + application/json: + schema: + properties: + message: { type: string, example: 'Command executed.' } + response: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /databases: get: tags: @@ -1809,9 +1907,33 @@ paths: type: string format: uuid - - name: cleanup + name: delete_configurations in: query - description: 'Delete configurations and volumes.' + description: 'Delete configurations.' + required: false + schema: + type: boolean + default: true + - + name: delete_volumes + in: query + description: 'Delete volumes.' + required: false + schema: + type: boolean + default: true + - + name: docker_cleanup + in: query + description: 'Run docker cleanup.' + required: false + schema: + type: boolean + default: true + - + name: delete_connected_networks + in: query + description: 'Delete connected networks.' required: false schema: type: boolean @@ -3812,6 +3934,38 @@ paths: required: true schema: type: string + - + name: delete_configurations + in: query + description: 'Delete configurations.' + required: false + schema: + type: boolean + default: true + - + name: delete_volumes + in: query + description: 'Delete volumes.' + required: false + schema: + type: boolean + default: true + - + name: docker_cleanup + in: query + description: 'Run docker cleanup.' + required: false + schema: + type: boolean + default: true + - + name: delete_connected_networks + in: query + description: 'Delete connected networks.' + required: false + schema: + type: boolean + default: true responses: '200': description: 'Delete a service by UUID' @@ -4769,6 +4923,10 @@ components: type: boolean swarm_cluster: type: string + delete_unused_volumes: + type: boolean + delete_unused_networks: + type: boolean type: object ServerSetting: description: 'Server Settings model' @@ -4801,7 +4959,7 @@ components: type: boolean is_reachable: type: boolean - is_server_api_enabled: + is_sentinel_enabled: type: boolean is_swarm_manager: type: boolean @@ -4823,11 +4981,11 @@ components: type: string logdrain_newrelic_license_key: type: string - metrics_history_days: + sentinel_metrics_refresh_rate_seconds: type: integer - metrics_refresh_rate_seconds: + sentinel_metrics_history_days: type: integer - metrics_token: + sentinel_token: type: string docker_cleanup_frequency: type: string diff --git a/other/nightly/.env.development.example b/other/nightly/.env.development.example deleted file mode 100644 index f9bcd361a..000000000 --- a/other/nightly/.env.development.example +++ /dev/null @@ -1,31 +0,0 @@ -# Coolify Configuration -APP_ENV=local -APP_NAME="Coolify Development" -APP_ID=development -APP_KEY= -APP_URL=http://localhost -APP_PORT=8000 -APP_DEBUG=true -MUX_ENABLED=false - -# Enable Laravel Telescope for debugging -TELESCOPE_ENABLED=false - -# Selenium Driver URL for Dusk -DUSK_DRIVER_URL=http://selenium:4444 - -# PostgreSQL Database Configuration -DB_DATABASE=coolify -DB_USERNAME=coolify -DB_PASSWORD=password -DB_HOST=host.docker.internal -DB_PORT=5432 - -#Set custom ray port -RAY_PORT= - -# Special Keys for Andras -# For cache purging -BUNNY_API_KEY= -# For asset uploads -BUNNY_STORAGE_API_KEY= diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index b26cd5746..b15a109c3 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -46,8 +46,12 @@ services: - PUSHER_APP_ID - PUSHER_APP_KEY - PUSHER_APP_SECRET + - TERMINAL_PROTOCOL + - TERMINAL_HOST + - TERMINAL_PORT - AUTOUPDATE - SELF_HOSTED + - SSH_MUX_ENABLED - SSH_MUX_PERSIST_TIME - FEEDBACK_DISCORD_WEBHOOK - WAITLIST @@ -109,18 +113,24 @@ services: retries: 10 timeout: 2s soketi: + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.3' ports: - "${SOKETI_PORT:-6001}:6001" + - "6002:6002" + volumes: + - /data/coolify/ssh:/var/www/html/storage/app/ssh environment: + APP_NAME: "${APP_NAME:-Coolify}" SOKETI_DEBUG: "${SOKETI_DEBUG:-false}" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" healthcheck: - test: wget -qO- http://127.0.0.1:6001/ready || exit 1 + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s retries: 10 timeout: 2s + volumes: coolify-db: name: coolify-db diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index a1ee1aeea..ef2de82e9 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -45,7 +45,7 @@ services: - PUSHER_APP_SECRET - AUTOUPDATE=true - SELF_HOSTED=true - - MUX_ENABLED=false + - SSH_MUX_ENABLED=false - IS_WINDOWS_DOCKER_DESKTOP=true ports: - "${APP_PORT:-8000}:80" @@ -103,7 +103,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'quay.io/soketi/soketi:1.6-16-alpine' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.0' pull_policy: always container_name: coolify-realtime restart: always @@ -111,16 +111,21 @@ services: - .env ports: - "${SOKETI_PORT:-6001}:6001" + - "6002:6002" + volumes: + - ./ssh:/var/www/html/storage/app/ssh environment: + APP_NAME: "${APP_NAME:-Coolify}" SOKETI_DEBUG: "${SOKETI_DEBUG:-false}" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" healthcheck: - test: wget -qO- http://localhost:6001/ready || exit 1 + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] interval: 5s retries: 10 timeout: 2s + volumes: coolify-db: name: coolify-db diff --git a/other/nightly/docker-compose.yml b/other/nightly/docker-compose.yml index 930c0a6b9..68d0f0744 100644 --- a/other/nightly/docker-compose.yml +++ b/other/nightly/docker-compose.yml @@ -24,8 +24,9 @@ services: networks: - coolify soketi: - image: 'quay.io/soketi/soketi:1.6-16-alpine' container_name: coolify-realtime + extra_hosts: + - 'host.docker.internal:host-gateway' restart: always networks: - coolify diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 23c2efc6f..04faf50ea 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -5,11 +5,32 @@ set -e # Exit immediately if a command exits with a non-zero status ## $1 could be empty, so we need to disable this check #set -u # Treat unset variables as an error and exit set -o pipefail # Cause a pipeline to return the status of the last command that exited with a non-zero status - -VERSION="1.4" -DOCKER_VERSION="26.0" - CDN="https://cdn.coollabs.io/coolify-nightly" +DATE=$(date +"%Y%m%d-%H%M%S") + +VERSION="1.6" +DOCKER_VERSION="26.0" +# TODO: Ask for a user +CURRENT_USER=$USER + +mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,metrics,logs} +mkdir -p /data/coolify/ssh/{keys,mux} +mkdir -p /data/coolify/proxy/dynamic + +chown -R 9999:root /data/coolify +chmod -R 700 /data/coolify + +INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log" + +exec > >(tee -a $INSTALLATION_LOG_WITH_DATE) 2>&1 + +getAJoke() { + JOKES=$(curl -s --max-time 2 "https://v2.jokeapi.dev/joke/Programming?blacklistFlags=nsfw,religious,political,racist,sexist,explicit&format=txt&type=single" || true) + if [ "$JOKES" != "" ]; then + echo -e " - Until then, here's a joke for you:\n" + echo -e "$JOKES\n" + fi +} OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') ENV_FILE="/data/coolify/source/.env" @@ -18,6 +39,11 @@ if [ "$OS_TYPE" = "manjaro" ] || [ "$OS_TYPE" = "manjaro-arm" ]; then OS_TYPE="arch" fi +# Check if the OS is Asahi Linux, if so, change it to fedora +if [ "$OS_TYPE" = "fedora-asahi-remix" ]; then + OS_TYPE="fedora" +fi + # Check if the OS is popOS, if so, change it to ubuntu if [ "$OS_TYPE" = "pop" ]; then OS_TYPE="ubuntu" @@ -46,12 +72,16 @@ fi LATEST_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $2}' | tr -d ',') LATEST_HELPER_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $6}' | tr -d ',') +LATEST_REALTIME_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $8}' | tr -d ',') if [ -z "$LATEST_HELPER_VERSION" ]; then LATEST_HELPER_VERSION=latest fi -DATE=$(date +"%Y%m%d-%H%M%S") +if [ -z "$LATEST_REALTIME_VERSION" ]; then + LATEST_REALTIME_VERSION=latest +fi + if [ $EUID != 0 ]; then echo "Please run as root" @@ -73,18 +103,29 @@ if [ "$1" != "" ]; then LATEST_VERSION="${LATEST_VERSION#v}" fi -echo -e "-------------" -echo -e "Welcome to Coolify v4 beta installer!" -echo -e "This script will install everything for you." +echo -e "\033[0;35m" +cat << "EOF" + _____ _ _ __ + / ____| | (_)/ _| + | | ___ ___ | |_| |_ _ _ + | | / _ \ / _ \| | | _| | | | + | |___| (_) | (_) | | | | | |_| | + \_____\___/ \___/|_|_|_| \__, | + __/ | + |___/ +EOF +echo -e "\033[0m" +echo -e "Welcome to Coolify Installer!" +echo -e "This script will install everything for you. Sit back and relax." echo -e "Source code: https://github.com/coollabsio/coolify/blob/main/scripts/install.sh\n" -echo -e "-------------" - -echo "OS: $OS_TYPE $OS_VERSION" -echo "Coolify version: $LATEST_VERSION" -echo "Helper version: $LATEST_HELPER_VERSION" - -echo -e "-------------" -echo "Installing required packages..." +echo -e "---------------------------------------------" +echo "| Operating System | $OS_TYPE $OS_VERSION" +echo "| Docker | $DOCKER_VERSION" +echo "| Coolify | $LATEST_VERSION" +echo "| Helper | $LATEST_HELPER_VERSION" +echo "| Realtime | $LATEST_REALTIME_VERSION" +echo -e "---------------------------------------------\n" +echo -e "1. Installing required packages (curl, wget, git, jq). " case "$OS_TYPE" in arch) @@ -122,24 +163,26 @@ sles | opensuse-leap | opensuse-tumbleweed) ;; esac + + +echo -e "2. Check OpenSSH server configuration. " + # Detect OpenSSH server SSH_DETECTED=false if [ -x "$(command -v systemctl)" ]; then if systemctl status sshd >/dev/null 2>&1; then - echo "OpenSSH server is installed." + echo " - OpenSSH server is installed." SSH_DETECTED=true - fi - if systemctl status ssh >/dev/null 2>&1; then - echo "OpenSSH server is installed." + elif systemctl status ssh >/dev/null 2>&1; then + echo " - OpenSSH server is installed." SSH_DETECTED=true fi elif [ -x "$(command -v service)" ]; then if service sshd status >/dev/null 2>&1; then - echo "OpenSSH server is installed." + echo " - OpenSSH server is installed." SSH_DETECTED=true - fi - if service ssh status >/dev/null 2>&1; then - echo "OpenSSH server is installed." + elif service ssh status >/dev/null 2>&1; then + echo " - OpenSSH server is installed." SSH_DETECTED=true fi fi @@ -151,104 +194,91 @@ if [ "$SSH_DETECTED" = "false" ]; then fi # Detect SSH PermitRootLogin -SSH_PERMIT_ROOT_LOGIN=false -SSH_PERMIT_ROOT_LOGIN_CONFIG=$(grep "^PermitRootLogin" /etc/ssh/sshd_config | awk '{print $2}') || SSH_PERMIT_ROOT_LOGIN_CONFIG="N/A (commented out or not found at all)" -if [ "$SSH_PERMIT_ROOT_LOGIN_CONFIG" = "prohibit-password" ] || [ "$SSH_PERMIT_ROOT_LOGIN_CONFIG" = "yes" ] || [ "$SSH_PERMIT_ROOT_LOGIN_CONFIG" = "without-password" ]; then - echo "PermitRootLogin is enabled." - SSH_PERMIT_ROOT_LOGIN=true -fi - -if [ "$SSH_PERMIT_ROOT_LOGIN" != "true" ]; then - echo "###############################################################################" - echo "WARNING: PermitRootLogin is not enabled in /etc/ssh/sshd_config." - echo -e "It is set to $SSH_PERMIT_ROOT_LOGIN_CONFIG. Should be prohibit-password, yes or without-password.\n" - echo -e "Please make sure it is set, otherwise Coolify cannot connect to the host system. \n" - echo "###############################################################################" +SSH_PERMIT_ROOT_LOGIN=$(sshd -T | grep -i "permitrootlogin" | awk '{print $2}') || true +if [ "$SSH_PERMIT_ROOT_LOGIN" = "yes" ] || [ "$SSH_PERMIT_ROOT_LOGIN" = "without-password" ] || [ "$SSH_PERMIT_ROOT_LOGIN" = "prohibit-password" ]; then + echo " - SSH PermitRootLogin is enabled." +else + echo " - SSH PermitRootLogin is disabled." + echo " If you have problems with SSH, please read this: https://coolify.io/docs/knowledge-base/server/openssh" fi # Detect if docker is installed via snap if [ -x "$(command -v snap)" ]; then - if snap list | grep -q docker; then - echo "Docker is installed via snap." - echo "Please note that Coolify does not support Docker installed via snap." - echo "Please remove Docker with snap (snap remove docker) and reexecute this script." + SNAP_DOCKER_INSTALLED=$(snap list docker >/dev/null 2>&1 && echo "true" || echo "false") + if [ "$SNAP_DOCKER_INSTALLED" = "true" ]; then + echo " - Docker is installed via snap." + echo " Please note that Coolify does not support Docker installed via snap." + echo " Please remove Docker with snap (snap remove docker) and reexecute this script." exit 1 fi fi +echo -e "3. Check Docker Installation. " if ! [ -x "$(command -v docker)" ]; then + echo " - Docker is not installed. Installing Docker. It may take a while." + getAJoke case "$OS_TYPE" in "almalinux") - dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo - dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 + dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 if ! [ -x "$(command -v docker)" ]; then - echo "Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." exit 1 fi - systemctl start docker - systemctl enable docker + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 ;; "alpine") - apk add docker docker-cli-compose - rc-update add docker default - service docker start - if [ -x "$(command -v docker)" ]; then - echo "Docker installed successfully." - else - echo "Failed to install Docker with apk. Try to install it manually." - echo "Please visit https://wiki.alpinelinux.org/wiki/Docker for more information." - exit + apk add docker docker-cli-compose >/dev/null 2>&1 + rc-update add docker default >/dev/null 2>&1 + service docker start >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Failed to install Docker with apk. Try to install it manually." + echo " Please visit https://wiki.alpinelinux.org/wiki/Docker for more information." + exit 1 fi ;; "arch") - pacman -Sy docker docker-compose --noconfirm - systemctl enable docker.service - if [ -x "$(command -v docker)" ]; then - echo "Docker installed successfully." - else - echo "Failed to install Docker with pacman. Try to install it manually." - echo "Please visit https://wiki.archlinux.org/title/docker for more information." - exit + pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1 + systemctl enable docker.service >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Failed to install Docker with pacman. Try to install it manually." + echo " Please visit https://wiki.archlinux.org/title/docker for more information." + exit 1 fi ;; "amzn") - dnf install docker -y + dnf install docker -y >/dev/null 2>&1 DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker} - mkdir -p $DOCKER_CONFIG/cli-plugins - curl -L https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) -o $DOCKER_CONFIG/cli-plugins/docker-compose - chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose - systemctl start docker - systemctl enable docker - if [ -x "$(command -v docker)" ]; then - echo "Docker installed successfully." - else - echo "Failed to install Docker with dnf. Try to install it manually." - echo "Please visit https://www.cyberciti.biz/faq/how-to-install-docker-on-amazon-linux-2/ for more information." - exit + mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1 + curl -sL https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 + chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Failed to install Docker with dnf. Try to install it manually." + echo " Please visit https://www.cyberciti.biz/faq/how-to-install-docker-on-amazon-linux-2/ for more information." + exit 1 fi ;; *) - # Automated Docker installation - curl https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh - if [ -x "$(command -v docker)" ]; then - echo "Docker installed successfully." - else - echo "Docker installation failed with Rancher script. Trying with official script." - curl https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} - if [ -x "$(command -v docker)" ]; then - echo "Docker installed successfully." - else - echo "Docker installation failed with official script." - echo "Maybe your OS is not supported?" - echo "Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Docker installation failed." + echo " Maybe your OS is not supported?" + echo " - Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." exit 1 fi fi esac + echo " - Docker installed successfully." +else + echo " - Docker is installed." fi -echo -e "-------------" -echo -e "Check Docker Configuration..." +echo -e "4. Check Docker Configuration. " mkdir -p /etc/docker # shellcheck disable=SC2015 test -s /etc/docker/daemon.json && cp /etc/docker/daemon.json /etc/docker/daemon.json.original-"$DATE" || cat >/etc/docker/daemon.json </dev/null 2>&1; then - echo "Using systemctl to restart Docker..." + echo " - Using systemctl to restart Docker." systemctl restart docker if [ $? -eq 0 ]; then - echo "Docker restarted successfully using systemctl." + echo " - Docker restarted successfully using systemctl." else - echo "Failed to restart Docker using systemctl." + echo " - Failed to restart Docker using systemctl." return 1 fi # Check if service command is available elif command -v service >/dev/null 2>&1; then - echo "Using service command to restart Docker..." + echo " - Using service command to restart Docker." service docker restart if [ $? -eq 0 ]; then - echo "Docker restarted successfully using service." + echo " - Docker restarted successfully using service." else - echo "Failed to restart Docker using service." + echo " - Failed to restart Docker using service." return 1 fi # If neither systemctl nor service is available else - echo "Neither systemctl nor service command is available on this system." + echo " - Neither systemctl nor service command is available on this system." return 1 fi } @@ -312,40 +341,30 @@ restart_docker_service() { if [ -s /etc/docker/daemon.json.original-"$DATE" ]; then DIFF=$(diff <(jq --sort-keys . /etc/docker/daemon.json) <(jq --sort-keys . /etc/docker/daemon.json.original-"$DATE")) if [ "$DIFF" != "" ]; then - echo "Docker configuration updated, restart docker daemon..." + echo " - Docker configuration updated, restart docker daemon..." restart_docker_service else - echo "Docker configuration is up to date." + echo " - Docker configuration is up to date." fi else - echo "Docker configuration updated, restart docker daemon..." + echo " - Docker configuration updated, restart docker daemon..." restart_docker_service fi -echo -e "-------------" - -mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,metrics,logs} -mkdir -p /data/coolify/ssh/{keys,mux} -mkdir -p /data/coolify/proxy/dynamic - -chown -R 9999:root /data/coolify -chmod -R 700 /data/coolify - -echo "Downloading required files from CDN..." +echo -e "5. Download required files from CDN. " curl -fsSL $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml curl -fsSL $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production curl -fsSL $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh +echo -e "6. Make backup of .env to .env-$DATE" + # Copy .env.example if .env does not exist if [ -f $ENV_FILE ]; then - echo "File exists: $ENV_FILE" - cat $ENV_FILE - echo "Copying .env to .env-$DATE" cp $ENV_FILE $ENV_FILE-$DATE else - echo "File does not exist: $ENV_FILE" - echo "Copying .env.production to .env-$DATE" + echo " - File does not exist: $ENV_FILE" + echo " - Copying .env.production to .env-$DATE" cp /data/coolify/source/.env.production $ENV_FILE-$DATE # Generate a secure APP_ID and APP_KEY sed -i "s|^APP_ID=.*|APP_ID=$(openssl rand -hex 16)|" "$ENV_FILE-$DATE" @@ -366,6 +385,7 @@ else fi # Merge .env and .env.production. New values will be added to .env +echo -e "7. Propagating .env with new values - if necessary." awk -F '=' '!seen[$1]++' "$ENV_FILE-$DATE" /data/coolify/source/.env.production > $ENV_FILE if [ "$AUTOUPDATE" = "false" ]; then @@ -375,33 +395,52 @@ if [ "$AUTOUPDATE" = "false" ]; then sed -i "s|AUTOUPDATE=.*|AUTOUPDATE=false|g" /data/coolify/source/.env fi fi - -# Generate an ssh key (ed25519) at /data/coolify/ssh/keys/id.root@host.docker.internal -if [ ! -f /data/coolify/ssh/keys/id.root@host.docker.internal ]; then - ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.root@host.docker.internal -q -N "" -C root@coolify - chown 9999 /data/coolify/ssh/keys/id.root@host.docker.internal -fi - -addSshKey() { - cat /data/coolify/ssh/keys/id.root@host.docker.internal.pub >>~/.ssh/authorized_keys - chmod 600 ~/.ssh/authorized_keys -} - +echo -e "8. Checking for SSH key for localhost access." if [ ! -f ~/.ssh/authorized_keys ]; then mkdir -p ~/.ssh chmod 700 ~/.ssh touch ~/.ssh/authorized_keys - addSshKey + chmod 600 ~/.ssh/authorized_keys fi -if ! grep -qw "root@coolify" ~/.ssh/authorized_keys; then - addSshKey +set +e +IF_COOLIFY_VOLUME_EXISTS=$(docker volume ls | grep coolify-db | wc -l) +set -e + +if [ "$IF_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then + echo " - Generating SSH key." + ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify + chown 9999 /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal + sed -i "/coolify/d" ~/.ssh/authorized_keys + cat /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub >> ~/.ssh/authorized_keys + rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub fi -bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" +chown -R 9999:root /data/coolify +chmod -R 700 /data/coolify + +echo -e "9. Installing Coolify ($LATEST_VERSION)" +echo -e " - It could take a while based on your server's performance, network speed, stars, etc." +echo -e " - Please wait." +getAJoke + +bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" >/dev/null 2>&1 +echo " - Coolify installed successfully." rm -f $ENV_FILE-$DATE -echo "Waiting for 20 seconds for Coolify to be ready..." + +echo " - Waiting for 20 seconds for Coolify (database migrations) to be ready." +getAJoke sleep 20 -echo "Please visit http://$(curl -4s https://ifconfig.io):8000 to get started." -echo -e "\nCongratulations! Your Coolify instance is ready to use.\n" +echo -e "\033[0;35m + ____ _ _ _ _ _ + / ___|___ _ __ __ _ _ __ __ _| |_ _ _| | __ _| |_(_) ___ _ __ ___| | + | | / _ \| '_ \ / _\` | '__/ _\` | __| | | | |/ _\` | __| |/ _ \| '_ \/ __| | + | |__| (_) | | | | (_| | | | (_| | |_| |_| | | (_| | |_| | (_) | | | \__ \_| + \____\___/|_| |_|\__, |_| \__,_|\__|\__,_|_|\__,_|\__|_|\___/|_| |_|___(_) + |___/ +\033[0m" +echo -e "\nYour instance is ready to use." +echo -e "Please visit http://$(curl -4s https://ifconfig.io):8000 to get started.\n" +echo -e "WARNING: We recommend you to backup your /data/coolify/source/.env file to a safe location, outside of this server." +cp /data/coolify/source/.env /data/coolify/source/.env.backup diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 45295a510..9aa3a5f9a 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -12,7 +12,6 @@ curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production # Merge .env and .env.production. New values will be added to .env awk -F '=' '!seen[$1]++' /data/coolify/source/.env /data/coolify/source/.env.production > /data/coolify/source/.env.tmp && mv /data/coolify/source/.env.tmp /data/coolify/source/.env - # Check if PUSHER_APP_ID or PUSHER_APP_KEY or PUSHER_APP_SECRET is empty in /data/coolify/source/.env if grep -q "PUSHER_APP_ID=$" /data/coolify/source/.env; then sed -i "s|PUSHER_APP_ID=.*|PUSHER_APP_ID=$(openssl rand -hex 32)|g" /data/coolify/source/.env diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 7bb400bfd..c04a3dee6 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,13 +1,16 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.330" + "version": "4.0.0-beta.354" }, "nightly": { - "version": "4.0.0-beta.331" + "version": "4.0.0-beta.355" }, "helper": { - "version": "1.0.0" + "version": "1.0.2" + }, + "realtime": { + "version": "1.0.3" } } } diff --git a/package-lock.json b/package-lock.json index ff77563b0..adb1dc65a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,15 @@ "dependencies": { "@tailwindcss/forms": "0.5.7", "@tailwindcss/typography": "0.5.13", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "alpinejs": "3.14.0", + "cookie": "^0.7.0", + "dotenv": "^16.4.5", "ioredis": "5.4.1", - "tailwindcss-scrollbar": "0.1.0" + "node-pty": "^1.0.0", + "tailwindcss-scrollbar": "0.1.0", + "ws": "^8.17.0" }, "devDependencies": { "@vitejs/plugin-vue": "4.5.1", @@ -20,7 +26,7 @@ "postcss": "8.4.38", "pusher-js": "8.4.0-rc2", "tailwindcss": "3.4.4", - "vite": "4.5.3", + "vite": "4.5.5", "vue": "3.4.29" } }, @@ -692,6 +698,19 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==" }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==" + }, "node_modules/alpinejs": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.0.tgz", @@ -940,6 +959,14 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/cookie": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.0.tgz", + "integrity": "sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1000,6 +1027,18 @@ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.692", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.692.tgz", @@ -1475,6 +1514,11 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", + "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==" + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -1492,6 +1536,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-pty": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", + "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "hasInstallScript": true, + "dependencies": { + "nan": "^2.17.0" + } + }, "node_modules/node-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -1806,9 +1859,9 @@ } }, "node_modules/rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", + "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", "dev": true, "bin": { "rollup": "dist/bin/rollup" @@ -2028,9 +2081,9 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/vite": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", - "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz", + "integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==", "dev": true, "dependencies": { "esbuild": "^0.18.10", @@ -2124,6 +2177,26 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", diff --git a/package.json b/package.json index cacef3e06..29f8f1a37 100644 --- a/package.json +++ b/package.json @@ -14,14 +14,20 @@ "postcss": "8.4.38", "pusher-js": "8.4.0-rc2", "tailwindcss": "3.4.4", - "vite": "4.5.3", + "vite": "4.5.5", "vue": "3.4.29" }, "dependencies": { "@tailwindcss/forms": "0.5.7", "@tailwindcss/typography": "0.5.13", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "alpinejs": "3.14.0", + "cookie": "^0.7.0", + "dotenv": "^16.4.5", "ioredis": "5.4.1", - "tailwindcss-scrollbar": "0.1.0" + "node-pty": "^1.0.0", + "tailwindcss-scrollbar": "0.1.0", + "ws": "^8.17.0" } } \ No newline at end of file diff --git a/public/svgs/affine.svg b/public/svgs/affine.svg new file mode 100644 index 000000000..d8063e920 --- /dev/null +++ b/public/svgs/affine.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/anythingllm.svg b/public/svgs/anythingllm.svg new file mode 100644 index 000000000..1c25f8711 --- /dev/null +++ b/public/svgs/anythingllm.svg @@ -0,0 +1,166 @@ + + + + + + + diff --git a/public/svgs/argilla.png b/public/svgs/argilla.png new file mode 100644 index 000000000..3ead32785 Binary files /dev/null and b/public/svgs/argilla.png differ diff --git a/public/svgs/audiobookshelf.svg b/public/svgs/audiobookshelf.svg new file mode 100644 index 000000000..d641b765b --- /dev/null +++ b/public/svgs/audiobookshelf.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/svgs/azimutt.png b/public/svgs/azimutt.png new file mode 100644 index 000000000..ef69062cd Binary files /dev/null and b/public/svgs/azimutt.png differ diff --git a/public/svgs/bitcoin.svg b/public/svgs/bitcoin.svg new file mode 100644 index 000000000..2b75c99bc --- /dev/null +++ b/public/svgs/bitcoin.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/public/svgs/bookstack.png b/public/svgs/bookstack.png new file mode 100644 index 000000000..d10b3ca43 Binary files /dev/null and b/public/svgs/bookstack.png differ diff --git a/public/svgs/budge.png b/public/svgs/budge.png new file mode 100644 index 000000000..b44606c64 Binary files /dev/null and b/public/svgs/budge.png differ diff --git a/public/svgs/calcom.svg b/public/svgs/calcom.svg new file mode 100644 index 000000000..446b16655 --- /dev/null +++ b/public/svgs/calcom.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/svgs/castopod.svg b/public/svgs/castopod.svg new file mode 100644 index 000000000..c73008400 --- /dev/null +++ b/public/svgs/castopod.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/svgs/clickhouse.svg b/public/svgs/clickhouse.svg new file mode 100644 index 000000000..d536536de --- /dev/null +++ b/public/svgs/clickhouse.svg @@ -0,0 +1 @@ + diff --git a/public/svgs/cloudbeaver.svg b/public/svgs/cloudbeaver.svg new file mode 100644 index 000000000..4a7634766 --- /dev/null +++ b/public/svgs/cloudbeaver.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/svgs/coolify.png b/public/svgs/coolify.png new file mode 100644 index 000000000..fa01fec05 Binary files /dev/null and b/public/svgs/coolify.png differ diff --git a/public/svgs/cryptgeon.png b/public/svgs/cryptgeon.png new file mode 100644 index 000000000..be121cfd0 Binary files /dev/null and b/public/svgs/cryptgeon.png differ diff --git a/public/svgs/dify.png b/public/svgs/dify.png new file mode 100644 index 000000000..326acf789 Binary files /dev/null and b/public/svgs/dify.png differ diff --git a/public/svgs/dozzle.svg b/public/svgs/dozzle.svg new file mode 100644 index 000000000..bf88e8729 --- /dev/null +++ b/public/svgs/dozzle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/svgs/easyappointments.png b/public/svgs/easyappointments.png new file mode 100644 index 000000000..8f00d3353 Binary files /dev/null and b/public/svgs/easyappointments.png differ diff --git a/public/svgs/edgedb.svg b/public/svgs/edgedb.svg new file mode 100644 index 000000000..a906f7f7e --- /dev/null +++ b/public/svgs/edgedb.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/svgs/flowise.png b/public/svgs/flowise.png new file mode 100644 index 000000000..6b0be0d2a Binary files /dev/null and b/public/svgs/flowise.png differ diff --git a/public/svgs/forgejo.svg b/public/svgs/forgejo.svg new file mode 100644 index 000000000..804b05e28 --- /dev/null +++ b/public/svgs/forgejo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/freshrss.png b/public/svgs/freshrss.png new file mode 100644 index 000000000..d1a75118f Binary files /dev/null and b/public/svgs/freshrss.png differ diff --git a/public/svgs/getoutline.jpeg b/public/svgs/getoutline.jpeg new file mode 100644 index 000000000..422e402f7 Binary files /dev/null and b/public/svgs/getoutline.jpeg differ diff --git a/public/svgs/heyform.svg b/public/svgs/heyform.svg new file mode 100644 index 000000000..ff29ca654 --- /dev/null +++ b/public/svgs/heyform.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/svgs/homarr.svg b/public/svgs/homarr.svg new file mode 100644 index 000000000..4d4289604 --- /dev/null +++ b/public/svgs/homarr.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/svgs/homebox.svg b/public/svgs/homebox.svg new file mode 100644 index 000000000..08670bbb9 --- /dev/null +++ b/public/svgs/homebox.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/svgs/immich.svg b/public/svgs/immich.svg new file mode 100644 index 000000000..9d844a772 --- /dev/null +++ b/public/svgs/immich.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/infisical.png b/public/svgs/infisical.png new file mode 100644 index 000000000..48eddae78 Binary files /dev/null and b/public/svgs/infisical.png differ diff --git a/public/svgs/it-tools.svg b/public/svgs/it-tools.svg new file mode 100644 index 000000000..3de5ecff7 --- /dev/null +++ b/public/svgs/it-tools.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/svgs/joplin.png b/public/svgs/joplin.png new file mode 100644 index 000000000..d17a1d2c1 Binary files /dev/null and b/public/svgs/joplin.png differ diff --git a/public/svgs/keycloak.svg b/public/svgs/keycloak.svg new file mode 100644 index 000000000..849ac2759 --- /dev/null +++ b/public/svgs/keycloak.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/svgs/kimai.svg b/public/svgs/kimai.svg new file mode 100644 index 000000000..35b146972 --- /dev/null +++ b/public/svgs/kimai.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/svgs/labelstudio.png b/public/svgs/labelstudio.png new file mode 100644 index 000000000..afa5160b9 Binary files /dev/null and b/public/svgs/labelstudio.png differ diff --git a/public/svgs/langfuse.png b/public/svgs/langfuse.png new file mode 100644 index 000000000..8dec0fe4a Binary files /dev/null and b/public/svgs/langfuse.png differ diff --git a/public/svgs/libreoffice.svg b/public/svgs/libreoffice.svg new file mode 100644 index 000000000..227471bef --- /dev/null +++ b/public/svgs/libreoffice.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/libretranslate.svg b/public/svgs/libretranslate.svg new file mode 100644 index 000000000..103d47d60 --- /dev/null +++ b/public/svgs/libretranslate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/litellm.svg b/public/svgs/litellm.svg new file mode 100644 index 000000000..01830c3f6 --- /dev/null +++ b/public/svgs/litellm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/litequeen.svg b/public/svgs/litequeen.svg new file mode 100644 index 000000000..aa0b8e038 --- /dev/null +++ b/public/svgs/litequeen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/mailpit.svg b/public/svgs/mailpit.svg new file mode 100644 index 000000000..e569e71cc --- /dev/null +++ b/public/svgs/mailpit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/svgs/mattermost.svg b/public/svgs/mattermost.svg new file mode 100644 index 000000000..b01d38eb7 --- /dev/null +++ b/public/svgs/mattermost.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/svgs/mautic.svg b/public/svgs/mautic.svg new file mode 100644 index 000000000..b528f72ef --- /dev/null +++ b/public/svgs/mautic.svg @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/public/svgs/mindsdb.svg b/public/svgs/mindsdb.svg new file mode 100644 index 000000000..53799dd1c --- /dev/null +++ b/public/svgs/mindsdb.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/svgs/mixpost.svg b/public/svgs/mixpost.svg new file mode 100644 index 000000000..bd915e77a --- /dev/null +++ b/public/svgs/mixpost.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/svgs/nitropage.svg b/public/svgs/nitropage.svg new file mode 100644 index 000000000..67b2df17f --- /dev/null +++ b/public/svgs/nitropage.svg @@ -0,0 +1,8 @@ + + + NP + + + + + diff --git a/public/svgs/ntfy.svg b/public/svgs/ntfy.svg new file mode 100644 index 000000000..9e5b5136f --- /dev/null +++ b/public/svgs/ntfy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/ollama.svg b/public/svgs/ollama.svg new file mode 100644 index 000000000..3df9a9fba --- /dev/null +++ b/public/svgs/ollama.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/svgs/onedev.svg b/public/svgs/onedev.svg new file mode 100644 index 000000000..fb9c9c060 --- /dev/null +++ b/public/svgs/onedev.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/organizr.png b/public/svgs/organizr.png new file mode 100644 index 000000000..92541ea72 Binary files /dev/null and b/public/svgs/organizr.png differ diff --git a/public/svgs/osticket.png b/public/svgs/osticket.png new file mode 100644 index 000000000..65885b71b Binary files /dev/null and b/public/svgs/osticket.png differ diff --git a/public/svgs/owncloud.svg b/public/svgs/owncloud.svg new file mode 100644 index 000000000..83631e3f5 --- /dev/null +++ b/public/svgs/owncloud.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/paperless.svg b/public/svgs/paperless.svg new file mode 100644 index 000000000..347b1e759 --- /dev/null +++ b/public/svgs/paperless.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/public/svgs/peppermint.png b/public/svgs/peppermint.png new file mode 100644 index 000000000..38db83de0 Binary files /dev/null and b/public/svgs/peppermint.png differ diff --git a/public/svgs/postgresql.svg b/public/svgs/postgresql.svg new file mode 100644 index 000000000..1ff223856 --- /dev/null +++ b/public/svgs/postgresql.svg @@ -0,0 +1 @@ + diff --git a/public/svgs/prefect.png b/public/svgs/prefect.png new file mode 100644 index 000000000..2f87ec0d7 Binary files /dev/null and b/public/svgs/prefect.png differ diff --git a/public/svgs/qbittorrent.svg b/public/svgs/qbittorrent.svg new file mode 100644 index 000000000..69d8cf62a --- /dev/null +++ b/public/svgs/qbittorrent.svg @@ -0,0 +1,16 @@ + + + qbittorrent-new-light + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/svgs/qdrant.png b/public/svgs/qdrant.png new file mode 100644 index 000000000..ecb2a56d5 Binary files /dev/null and b/public/svgs/qdrant.png differ diff --git a/public/svgs/searxng.svg b/public/svgs/searxng.svg new file mode 100644 index 000000000..b94fe3728 --- /dev/null +++ b/public/svgs/searxng.svg @@ -0,0 +1,56 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/public/svgs/soketi.jpeg b/public/svgs/soketi.jpeg new file mode 100644 index 000000000..00c8d82cd Binary files /dev/null and b/public/svgs/soketi.jpeg differ diff --git a/public/svgs/strapi.svg b/public/svgs/strapi.svg new file mode 100644 index 000000000..d1a71abc2 --- /dev/null +++ b/public/svgs/strapi.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/public/svgs/supertokens.svg b/public/svgs/supertokens.svg new file mode 100644 index 000000000..30b435bd0 --- /dev/null +++ b/public/svgs/supertokens.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/svgs/tolgee.svg b/public/svgs/tolgee.svg new file mode 100644 index 000000000..0f216e0c6 --- /dev/null +++ b/public/svgs/tolgee.svg @@ -0,0 +1,5 @@ + + + + diff --git a/public/svgs/traccar.png b/public/svgs/traccar.png new file mode 100644 index 000000000..c747aea05 Binary files /dev/null and b/public/svgs/traccar.png differ diff --git a/public/svgs/transmission.svg b/public/svgs/transmission.svg new file mode 100644 index 000000000..9a11f77f4 --- /dev/null +++ b/public/svgs/transmission.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/unsend.svg b/public/svgs/unsend.svg new file mode 100644 index 000000000..f5ff6fabc --- /dev/null +++ b/public/svgs/unsend.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/svgs/unstructured.png b/public/svgs/unstructured.png new file mode 100644 index 000000000..a6ec855b6 Binary files /dev/null and b/public/svgs/unstructured.png differ diff --git a/public/svgs/vvveb.svg b/public/svgs/vvveb.svg new file mode 100644 index 000000000..2b66b3087 --- /dev/null +++ b/public/svgs/vvveb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svgs/weaviate.png b/public/svgs/weaviate.png new file mode 100644 index 000000000..134294253 Binary files /dev/null and b/public/svgs/weaviate.png differ diff --git a/public/svgs/zep.png b/public/svgs/zep.png new file mode 100644 index 000000000..7d51b32dc Binary files /dev/null and b/public/svgs/zep.png differ diff --git a/public/svgs/zipline.png b/public/svgs/zipline.png new file mode 100644 index 000000000..2b8f6972d Binary files /dev/null and b/public/svgs/zipline.png differ diff --git a/resources/js/app.js b/resources/js/app.js index befec919e..613b80069 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -4,3 +4,14 @@ // const app = createApp({}); // app.component("magic-bar", MagicBar); // app.mount("#vue"); + +import { initializeTerminalComponent } from './terminal.js'; + +['livewire:navigated', 'alpine:init'].forEach((event) => { + document.addEventListener(event, () => { + // tree-shaking + if (document.getElementById('terminal-container')) { + initializeTerminalComponent() + } + }); +}); diff --git a/resources/js/components/MagicBar.vue b/resources/js/components/MagicBar.vue index 611ec0891..22af9dff4 100644 --- a/resources/js/components/MagicBar.vue +++ b/resources/js/components/MagicBar.vue @@ -390,7 +390,7 @@ const magicActions = [{ }, { id: 19, - name: 'Goto: Command Center', + name: 'Goto: Terminal', icon: 'goto', sequence: ['main', 'redirect'] }, @@ -653,7 +653,7 @@ async function redirect() { targetUrl.pathname = `/settings` break; case 19: - targetUrl.pathname = `/command-center` + targetUrl.pathname = `/terminal` break; case 20: targetUrl.pathname = `/team/notifications` diff --git a/resources/js/terminal.js b/resources/js/terminal.js new file mode 100644 index 000000000..59c9a79a8 --- /dev/null +++ b/resources/js/terminal.js @@ -0,0 +1,239 @@ +import { Terminal } from '@xterm/xterm'; +import '@xterm/xterm/css/xterm.css'; +import { FitAddon } from '@xterm/addon-fit'; + +export function initializeTerminalComponent() { + function terminalData() { + return { + fullscreen: false, + terminalActive: false, + message: '(connection closed)', + term: null, + fitAddon: null, + socket: null, + commandBuffer: '', + pendingWrites: 0, + paused: false, + MAX_PENDING_WRITES: 5, + keepAliveInterval: null, + reconnectInterval: null, + + init() { + this.setupTerminal(); + this.initializeWebSocket(); + this.setupTerminalEventListeners(); + + this.$wire.on('send-back-command', (command) => { + this.socket.send(JSON.stringify({ + command: command + })); + }); + + this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000); + + this.$watch('terminalActive', (active) => { + if (!active && this.keepAliveInterval) { + clearInterval(this.keepAliveInterval); + } + this.$nextTick(() => { + if (active) { + this.$refs.terminalWrapper.style.display = 'block'; + this.resizeTerminal(); + } else { + this.$refs.terminalWrapper.style.display = 'none'; + } + }); + }); + + ['livewire:navigated', 'beforeunload'].forEach((event) => { + document.addEventListener(event, () => { + this.checkIfProcessIsRunningAndKillIt(); + clearInterval(this.keepAliveInterval); + if (this.reconnectInterval) { + clearInterval(this.reconnectInterval); + } + }, { once: true }); + }); + + window.onresize = () => { + this.resizeTerminal() + }; + + }, + + setupTerminal() { + const terminalElement = document.getElementById('terminal'); + if (terminalElement) { + this.term = new Terminal({ + cols: 80, + rows: 30, + fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"', + cursorBlink: true, + }); + this.fitAddon = new FitAddon(); + this.term.loadAddon(this.fitAddon); + } + }, + + initializeWebSocket() { + if (!this.socket || this.socket.readyState === WebSocket.CLOSED) { + const predefined = window.terminalConfig + const connectionString = { + protocol: window.location.protocol === 'https:' ? 'wss' : 'ws', + host: window.location.hostname, + port: ":6002", + path: '/terminal/ws' + } + if (!window.location.port) { + connectionString.port = '' + } + if (predefined.host) { + connectionString.host = predefined.host + } + if (predefined.port) { + connectionString.port = `:${predefined.port}` + } + if (predefined.protocol) { + connectionString.protocol = predefined.protocol + } + + const url = + `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}` + this.socket = new WebSocket(url); + + this.socket.onmessage = this.handleSocketMessage.bind(this); + this.socket.onerror = (e) => { + console.error('WebSocket error:', e); + }; + this.socket.onclose = () => { + console.log('WebSocket connection closed'); + this.reconnect(); + }; + } + }, + + reconnect() { + if (this.reconnectInterval) { + clearInterval(this.reconnectInterval); + } + this.reconnectInterval = setInterval(() => { + console.log('Attempting to reconnect...'); + this.initializeWebSocket(); + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + console.log('Reconnected successfully'); + clearInterval(this.reconnectInterval); + this.reconnectInterval = null; + window.location.reload(); + } + }, 2000); + }, + + handleSocketMessage(event) { + this.message = '(connection closed)'; + if (event.data === 'pty-ready') { + if (!this.term._initialized) { + this.term.open(document.getElementById('terminal')); + this.term._initialized = true; + } else { + this.term.reset(); + } + this.terminalActive = true; + this.term.focus(); + document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded'); + this.resizeTerminal(); + } else if (event.data === 'unprocessable') { + if (this.term) this.term.reset(); + this.terminalActive = false; + this.message = '(sorry, something went wrong, please try again)'; + } else if (event.data === 'pty-exited') { + this.terminalActive = false; + this.term.reset(); + this.commandBuffer = ''; + } else { + this.pendingWrites++; + this.term.write(event.data, this.flowControlCallback.bind(this)); + } + }, + + flowControlCallback() { + this.pendingWrites--; + if (this.pendingWrites > this.MAX_PENDING_WRITES && !this.paused) { + this.paused = true; + this.socket.send(JSON.stringify({ pause: true })); + return; + } + if (this.pendingWrites <= this.MAX_PENDING_WRITES && this.paused) { + this.paused = false; + this.socket.send(JSON.stringify({ resume: true })); + return; + } + }, + + setupTerminalEventListeners() { + if (!this.term) return; + + this.term.onData((data) => { + this.socket.send(JSON.stringify({ message: data })); + if (data === '\r') { + this.commandBuffer = ''; + } else { + this.commandBuffer += data; + } + }); + + // Copy and paste functionality + this.term.attachCustomKeyEventHandler((arg) => { + if (arg.ctrlKey && arg.code === "KeyV" && arg.type === "keydown") { + return false; + } + + if (arg.ctrlKey && arg.code === "KeyC" && arg.type === "keydown") { + const selection = this.term.getSelection(); + if (selection) { + navigator.clipboard.writeText(selection); + return false; + } + } + return true; + }); + }, + + keepAlive() { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ ping: true })); + } + }, + + checkIfProcessIsRunningAndKillIt() { + if (this.socket && this.socket.readyState == WebSocket.OPEN) { + this.socket.send(JSON.stringify({ checkActive: 'force' })); + } + }, + + makeFullscreen() { + this.fullscreen = !this.fullscreen; + this.$nextTick(() => { + this.resizeTerminal(); + }); + }, + + resizeTerminal() { + if (!this.terminalActive || !this.term || !this.fitAddon) return; + + this.fitAddon.fit(); + const height = this.$refs.terminalWrapper.clientHeight; + const width = this.$refs.terminalWrapper.clientWidth; + const rows = Math.floor(height / this.term._core._renderService._charSizeService.height) - 1; + const cols = Math.floor(width / this.term._core._renderService._charSizeService.width) - 1; + const termWidth = cols; + const termHeight = rows; + this.term.resize(termWidth, termHeight); + this.socket.send(JSON.stringify({ + resize: { cols: termWidth, rows: termHeight } + })); + }, + }; + } + + window.Alpine.data('terminalData', terminalData); +} diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php index 8d491a218..287f2f170 100644 --- a/resources/views/auth/confirm-password.blade.php +++ b/resources/views/auth/confirm-password.blade.php @@ -8,7 +8,7 @@
@csrf - + {{ __('auth.confirm_password') }} @if ($errors->any()) diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php index 54d57a302..f66b460be 100644 --- a/resources/views/auth/forgot-password.blade.php +++ b/resources/views/auth/forgot-password.blade.php @@ -12,7 +12,7 @@ @if (is_transactional_emails_active())
@csrf - + {{ __('auth.forgot_password_send_email') }} @else diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 1af8a0cd1..7d615885f 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -10,7 +10,7 @@ @csrf @env('local') + required label="{{ __('input.email') }}" /> @@ -20,7 +20,7 @@ @else + label="{{ __('input.email') }}" /> diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php index 2e1a63d84..cc13989b8 100644 --- a/resources/views/auth/reset-password.blade.php +++ b/resources/views/auth/reset-password.blade.php @@ -16,7 +16,7 @@ label="{{ __('input.email') }}" />
+ label="{{ __('input.password') }}" />
diff --git a/resources/views/auth/two-factor-challenge.blade.php b/resources/views/auth/two-factor-challenge.blade.php index 6761992a9..9288ff16a 100644 --- a/resources/views/auth/two-factor-challenge.blade.php +++ b/resources/views/auth/two-factor-challenge.blade.php @@ -9,7 +9,7 @@
@csrf
- +
Enter diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php index f3e3d5c9e..fed6ad77f 100644 --- a/resources/views/components/forms/checkbox.blade.php +++ b/resources/views/components/forms/checkbox.blade.php @@ -1,16 +1,35 @@ -
- +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'disabled' => false, + 'instantSave' => false, + 'value' => null, + 'hideLabel' => false, + 'fullWidth' => false, +]) + +
$fullWidth, +])> + @if (!$hideLabel) + + @endif merge(['class' => $defaultClass]) }} @if ($instantSave) wire:loading.attr="disabled" wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}' diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index 04b4a41c6..fb206fac4 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -3,7 +3,7 @@ 'w-full' => !$isMultiline, ])> @if ($label) -