diff --git a/.env.development.example b/.env.development.example index d02b8ba59..9f594765d 100644 --- a/.env.development.example +++ b/.env.development.example @@ -27,12 +27,6 @@ DB_PORT=5432 # DB_WRITE_PASSWORD= # DB_STICKY=true -# Ray Configuration -# Set to true to enable Ray -RAY_ENABLED=false -# Set custom ray port -# RAY_PORT= - # Enable Laravel Telescope for debugging TELESCOPE_ENABLED=false diff --git a/.env.production b/.env.production index fe3c8370e..cda3214d2 100644 --- a/.env.production +++ b/.env.production @@ -15,4 +15,4 @@ ROOT_USERNAME= ROOT_USER_EMAIL= ROOT_USER_PASSWORD= -REGISTRY_URL=ghcr.io +REGISTRY_URL=docker.io diff --git a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml index f0c77577e..d5106ab75 100644 --- a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml +++ b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml @@ -1,7 +1,7 @@ name: 🐞 Bug Report description: "File a new bug report." title: "[Bug]: " -labels: ["🐛 Bug", "🔍 Triage"] +labels: ["🔍 Triage"] body: - type: markdown attributes: @@ -11,10 +11,22 @@ body: - type: textarea attributes: - label: Error Message and Logs + label: Description and Error Message 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: Expected Behavior + description: Please describe what you expected to happen instead of the issue. Be as detailed as possible. + value: | + 1. + 2. + 3. + 4. + validations: + required: true - type: textarea attributes: @@ -37,7 +49,7 @@ body: 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" + placeholder: "v4.1.2" validations: required: true @@ -55,6 +67,12 @@ body: 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: Screenshots / Visuals + description: If possible, provide screenshots, screen recordings, or diagrams to help illustrate the issue. + placeholder: "Attach images or provide links to recordings demonstrating the problem." - type: textarea attributes: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85fceb28f..53ba6c6a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,289 +1,231 @@ # Contributing to Coolify +We’re happy that you’re interested in contributing to Coolify! -> "First, thanks for considering contributing to my project. It really means a lot!" - [@andrasbacsai](https://github.com/andrasbacsai) +There are many ways to help: +- Answer questions in GitHub Discussions or Discord +- Report reproducible bugs +- Submit pull requests to fix issues +- Add new one-click services +- Improve documentation -You can ask for guidance anytime on our [Discord server](https://coollabs.io/discord) in the `#contribute` channel. +Coolify is a PaaS used by 400,000+ people worldwide and maintained by two active maintainers. Contributions are welcome — but **alignment matters more than quantity**. -To understand the tech stack, please refer to the [Tech Stack](TECH_STACK.md) document. - -## Table of Contents - -1. [Setup Development Environment](#1-setup-development-environment) -2. [Verify Installation](#2-verify-installation-optional) -3. [Fork and Setup Local Repository](#3-fork-and-setup-local-repository) -4. [Set up Environment Variables](#4-set-up-environment-variables) -5. [Start Coolify](#5-start-coolify) -6. [Start Development](#6-start-development) -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 - -Follow the steps below for your operating system: - -
-Windows - -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?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/?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?ref=coolify) - -
- -
-MacOS - -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?ref=coolify) - - Docker Desktop: - - 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?ref=coolify) - -
- -
-Linux - -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/?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/?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?ref=coolify) - -
- -## 2. Verify Installation (Optional) - -After installing Docker (or Orbstack) and Spin, verify the installation: - -1. Open a terminal or command prompt -2. Run the following commands: - ```bash - docker --version - spin --version - ``` - You should see version information for both Docker and Spin. - -## 3. Fork and Setup Local Repository - -1. Fork the [Coolify](https://github.com/coollabsio/coolify) repository to your GitHub account. - -2. Install a code editor on your machine (choose one): - - | Editor | Platform | Download Link | - |--------|----------|---------------| - | 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/?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` - -4. Open the cloned Coolify Repository in your chosen code editor. - -## 4. Set up Environment Variables - -1. In the Code Editor, locate the `.env.development.example` file in the root directory of your local Coolify repository. -2. Duplicate the `.env.development.example` file and rename the copy to `.env`. -3. Open the new `.env` file and review its contents. Adjust any environment variables as needed for your development setup. -4. If you encounter errors during database migrations, update the database connection settings in your `.env` file. Use the IP address or hostname of your PostgreSQL database container. You can find this information by running `docker ps` after executing `spin up`. -5. Save the changes to your `.env` file. - -## 5. Start Coolify - -1. Open a terminal in the local Coolify directory. -2. Run the following command in the terminal (leave that terminal open): - ```bash - spin up - ``` - -> [!NOTE] -> You may see some errors, but don't worry; this is expected. - -3. If you encounter permission errors, especially on macOS, use: - ```bash - sudo spin up - ``` - -> [!NOTE] -> If you change environment variables afterwards or anything seems broken, press Ctrl + C to stop the process and run `spin up` again. - -## 6. Start Development - -1. Access your Coolify instance: - - URL: `http://localhost:8000` - - Login: `test@example.com` - - Password: `password` - -2. Additional development tools: - - | Tool | URL | Note | - |------|-----|------| - | Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user | - | Mailpit (email catcher) | `http://localhost:8025` | | - | Telescope (debugging tool) | `http://localhost:8000/telescope` | Disabled by default | - -> [!NOTE] -> To enable Telescope, add the following to your `.env` file: -> ```env -> TELESCOPE_ENABLED=true -> ``` - -## 7. Create a Pull Request +This guide explains **what kind of contributions are likely to be accepted** and how to submit them properly. Following it saves time for both you and the maintainers. > [!IMPORTANT] -> Please read the [Pull Request Guidelines](#pull-request-guidelines) carefully before creating your PR. +> These guidelines may feel stricter than in many open-source projects. That is intentional. +> Clear structure and boundaries prevent maintainer burnout and keep the project sustainable long-term. -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 `next` branch as the compare branch. - - Click "Create pull request". +## High-Level Expectations +- Coolify has a clear product direction. +- Ownership and decisions are centralized. +- Review capacity is limited. +- Not every contribution will be accepted — even if technically correct. -3. Filling out the PR details: - - Give your PR a descriptive title. - - Use the Pull Request Template provided and fill in the details. +This is normal for a two-maintainer project. -> [!IMPORTANT] -> Always set the base branch for your PR to the `next` branch of the Coolify repository, not the `v4.x` branch. -4. Submit your PR: - - Review your changes one last time. - - Click "Create pull request" to submit. +## State of the Project +Coolify is currently at v4. While v4 is stable, it has some limitations, including: +- Limited scaling support +- A more complex user experience +- Other smaller issues that need refinement -> [!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. +These limitations will be addressed in Coolify v5, which is in the planning stage. Because of this, major features, architectural changes, or significant UI changes will not be accepted for v4 at this stage. -After submission, maintainers will review your PR and may request changes or provide feedback. +We welcome contributions that help stabilize v4 for a bug free experience. -#### Pull Request Guidelines -To maintain high-quality contributions and efficient review process: -- **Target Branch**: Always target the `next` branch, never `v4.x` or any other branch. PRs targeting incorrect branches will be closed without review. -- **Descriptive Titles**: Use clear, concise PR titles that describe the change (e.g., "fix: one click postgresql database stuck in restart loop" instead of "Fix database"). -- **PR Descriptions**: Provide detailed, meaningful descriptions. Avoid generic or AI-generated fluff. Include: - - What the change does - - Why it's needed - - How to test it - - Any breaking changes - - Screenshot or video recording of your changes working without any issues - - Links to related issues -- **Link to Issues**: All PRs must link to an existing GitHub issue. If no issue exists, create one first. Unrelated PRs may be closed. -- **Single Responsibility**: Each PR should address one issue or feature. Do not bundle unrelated changes. -- **Draft Mode**: Use draft PRs for work-in-progress. Convert to ready-for-review only when complete and tested. -- **Review Readiness**: Ensure your PR is ready for review within a reasonable timeframe (max 7 days in draft). Stale drafts may be closed. -- **Current Focus**: We are currently prioritizing stability and bug fixes over new features. PRs adding new features may not be reviewed, or may be closed without review to maintain focus. -- **Language Translations**: Coolify currently supports only English. Pull requests for new language translations will not be accepted. Multi-language support may be considered in the next major version (v5). -- **AI Usage Policy**: We are not against AI tools—we use them ourselves. However, AI discourse is mandatory: You must fully understand the changes in your PR and be able to explain them clearly. Many PRs using AI lack this understanding, leading to untested or incorrect submissions. If you use AI, ensure you can articulate what the code does, why it was changed, and how it was tested. -#### Review Process -- **Response Time**: Maintainers will review PRs promptly, but complex changes may take time. Be patient and responsive to feedback. -- **Revisions**: Address all review comments. Unresolved feedback may lead to PR closure. -- **Merge Criteria**: PRs are merged only after: - - All tests pass (including CI) - - Code review approval -- **Closing PRs**: PRs may be closed for: - - Inactivity (>7 days without response) - - Failure to meet guidelines - - Duplicate or superseded work - - Security or quality concerns +## What Makes a Strong Contribution +The following types of contributions are most likely to be accepted: #### Code Quality and Testing All contributions must adhere to the highest standards of code quality and testing: -- **Testing Required**: Every PR must include steps to test your changes. Untested code will not be reviewed or merged. -- **Local Verification**: Ensure your changes work in the development environment. Test all affected features thoroughly. -- **Code Standards**: Follow the existing code style, conventions, and patterns in the codebase. -- **No AI-Generated Code**: Do not submit code generated by AI tools without fully understanding and verifying it. AI-generated submissions that are untested or incorrect will be rejected immediately. +If your change is small and obvious (typo fix, small bug, minor docs update), you may open a pull request directly. -## Development Notes +If you are fixing a bug in `file.yaml`, do not: +- Reformat unrelated files +- Refactor unrelated code +- Fix style issues elsewhere +- Combine multiple unrelated changes -When working on Coolify, keep the following in mind: +Even “improvements” increase review complexity. -1. **Database Migrations**: After switching branches or making changes to the database structure, always run migrations: - ```bash - docker exec -it coolify php artisan migrate - ``` +**One pull request = one logical change.** -2. **Resetting Development Setup**: To reset your development setup to a clean database with default values: - ```bash - docker exec -it coolify php artisan migrate:fresh --seed - ``` +If you want to refactor or clean up code, discuss it first and submit it separately. -3. **Troubleshooting**: If you encounter unexpected behavior, ensure your database is up-to-date with the latest migrations and if possible reset the development setup to eliminate any environment-specific issues. -> [!IMPORTANT] -> Forgetting to migrate the database can cause problems, so make it a habit to run migrations after pulling changes or switching branches. +## Discussion Is Required for Larger Changes +For anything beyond a small fix, you must discuss it before opening a pull request. -## Resetting Development Environment +This includes: +- New features +- UI/UX changes +- Changes to default behavior +- Refactors or cleanup work +- Performance rewrites +- Architectural changes +- Changes touching many files -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`): +Discussion happens in GitHub Discussions: https://github.com/coollabsio/coolify/discussions/categories/general -1. Stop all running containers `ctrl + c`. +Pull requests introducing major changes without prior discussion will be closed without review. -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 - ``` +This ensures alignment before significant work is done. -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 - ``` +## What This Project Is Not +To set clear expectations: +- Coolify is not optimized for first-time open-source contributors +- We do not provide beginner-focused mentorship issues +- Large unsolicited changes are unlikely to be accepted +- Broad refactors or style rewrites are not helpful +- Low-effort AI-generated pull requests will be closed -5. Start Coolify again: - ```bash - spin up - ``` +AI usage is allowed. However, contributors must fully understand what their changes do and why. -6. Run database migrations and seeders: - ```bash - docker exec -it coolify php artisan migrate:fresh --seed - ``` +Clear expectations help everyone use their time effectively. -After completing these steps, you'll have a fresh development setup. -> [!IMPORTANT] -> 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. +# Ways to Contribute +## 1. Support Contributions +We use Discord for most support requests and GitHub Discussions for help. -## Additional Contribution Guidelines +### Requesting Support +If you need help: +- Provide complete and detailed information +- Include logs, screenshots, and steps to reproduce +- Be respectful — support is voluntary -### Contributing a New Service +Do not ping people for attention. They respond when available. -To add a new service to Coolify, please refer to our documentation: -[Adding a New Service](https://coolify.io/docs/get-started/contribute/service) +### Providing Support +If you help others: +- Verify your information before sharing +- Be patient and respectful +- Remember that not everyone has the same experience level -### Contributing to Documentation -To contribute to the Coolify documentation, please refer to this guide: -[Contributing to the Coolify Documentation](https://github.com/coollabsio/documentation-coolify/blob/main/readme.md) +## 2. Bug Report Contributions +Create a GitHub issue **only** if: +- The bug is reproducible +- You have confirmed no existing issue already covers it + +For questions or general help, use GitHub Discussions or the Discord support channel. + +Bug reports must include: +- Clear reproduction steps +- Expected result +- Actual result + +Incomplete reports and reports generated using AI may be closed. + + +## 3. Code Contributions +Maintainers may close pull requests at their discretion, without explanation. + +### Issue Requirement +Every pull request should reference and close an Issue or Discussion. + +If none exists, create one first. + +Pull requests without linked issue or discussions may not be reviewed and can be closed at any time. + + +## Commit Message Format +All commits must start with an action and category: +- `fix(ui):` — UI-related fixes +- `feat(api):` — API-related changes +- `feat(service):` — One-click service changes + +Examples: +- `fix(api): version endpoint returns wrong data` +- `feat(service): add supabase` + +Use the commit description only for concise context. + +Walls of text listing every change in description will be rejected. + + +## Pull Request Title Format +Pull request titles follow the same format: +- `fix(ui):` +- `feat(api):` +- `feat(service):` + +Examples: +- `fix(api): version endpoint returns wrong data` +- `feat(service): add supabase` + + +## AI Usage Disclosure +If AI tools were used at any stage, mention it in the pull request description. + +AI is allowed. + +However: +- You must understand every change +- You must verify correctness +- You must ensure it follows project patterns + +AI-generated pull requests without clear understanding will be closed. + + +## Test Before Submitting +Before submitting a pull request: +- Manually test your changes thoroughly +- Verify they work in a clean environment +- Provide detailed testing steps in the PR description + +If maintainers cannot reproduce working behavior, the PR will be closed without further review. + + +## Submitting a Pull Request +- GitHub will auto-populate the PR template +- The contributor agreement in PR description must remain intact +- Pull requests without the contributor agreement will be closed +- All pull requests must target the `next` branch +- PRs targeting other branches will be closed without review + + +## FAQ +**Q: Should I ask before fixing a typo or a small bug?** +A: No, small, obvious fixes like typos or narrowly-scoped bug fixes can be submitted as a PR directly. + +**Q: I have an idea for a new feature.** +A: Awesome! Discuss it first in GitHub Discussions or Discord. **Do not** open a PR for new features without prior alignment. + +**Q: My PR was closed without detailed feedback.** +A: This usually means it didn’t align with the project’s direction, required more review bandwidth than available, or targeted major changes not allowed in v4. + +**Q: Can I work on an open issue?** +A: Comment on the issue first to confirm it’s still relevant and that no one else is actively working on it. For anything beyond a small fix, discuss your approach before implementing. + +**Q: I noticed code that could be cleaned up while working on my change.** +A: Focus only on your stated goal. Cleanups or refactors should be submitted as separate PRs after discussion. + +**Q: Can I use AI to help with my PR?** +A: Yes, AI-assisted contributions are allowed. But you must fully understand and verify the changes. PRs that appear to be generated by AI without context understanding will be closed. + +**Q: My PR was closed without review. Can I submit a new one?** +A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effort. It usually means the PR didn’t match the project goals or guidelines. Address these issues first — repeating the same approach may hurt your standing with maintainers. + + +# Development Guides +## Local Development +To build and run Coolify locally, see: [Development](./DEVELOPMENT.md) + +### macOS Development with Lima +Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS. + +After creating and starting a Lima VM, run the normal local development commands from inside the VM as described in [Development](./DEVELOPMENT.md). + +## Adding a New Service +To add a new one-click service, follow: https://coolify.io/docs/get-started/contribute/service + +## Contributing to Documentation +To contribute to documentation, see: https://coolify.io/docs/get-started/contribute/documentation diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 000000000..69f2dc760 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,212 @@ +# Contributing to Coolify +> "First, thanks for considering contributing to my project. It really means a lot!" - [@andrasbacsai](https://github.com/andrasbacsai) + +You can ask for guidance anytime on our [Discord server](https://coollabs.io/discord) in the `#contribute` channel. + +To understand the tech stack, please refer to the [Tech Stack](TECH_STACK.md) document. + + +## Table of Contents +1. [Setup Development Environment](#1-setup-development-environment) +2. [Verify Installation](#2-verify-installation-optional) +3. [Fork and Setup Local Repository](#3-fork-and-setup-local-repository) +4. [Set up Environment Variables](#4-set-up-environment-variables) +5. [Start Coolify](#5-start-coolify) +6. [Start Development](#6-start-development) +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 +Follow the steps below for your operating system: + +
+Windows + +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?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/?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?ref=coolify) + +
+ +
+MacOS + +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?ref=coolify) + - Docker Desktop: + - 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?ref=coolify) + +
+ +
+Linux + +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/?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/?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?ref=coolify) + +
+ + +## 2. Verify Installation (Optional) +After installing Docker (or Orbstack) and Spin, verify the installation: + +1. Open a terminal or command prompt +2. Run the following commands: + ```bash + docker --version + spin --version + ``` + You should see version information for both Docker and Spin. + + +## 3. Fork and Setup Local Repository +1. Fork the [Coolify](https://github.com/coollabsio/coolify) repository to your GitHub account. + +2. Install a code editor on your machine (choose one): + + | Editor | Platform | Download Link | + |--------|----------|---------------| + | 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) | Windows/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/?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` + +4. Open the cloned Coolify Repository in your chosen code editor. + + +## 4. Set up Environment Variables +1. In the Code Editor, locate the `.env.development.example` file in the root directory of your local Coolify repository. +2. Duplicate the `.env.development.example` file and rename the copy to `.env`. +3. Open the new `.env` file and review its contents. Adjust any environment variables as needed for your development setup. +4. If you encounter errors during database migrations, update the database connection settings in your `.env` file. Use the IP address or hostname of your PostgreSQL database container. You can find this information by running `docker ps` after executing `spin up`. +5. Save the changes to your `.env` file. + + +## 5. Start Coolify +1. Open a terminal in the local Coolify directory. +2. Run the following command in the terminal (leave that terminal open): + ```bash + spin up + ``` + +> [!NOTE] +> You may see some errors, but don't worry; this is expected. + +3. If you encounter permission errors, especially on macOS, use: + ```bash + sudo spin up + ``` + +> [!NOTE] +> If you change environment variables afterwards or anything seems broken, press Ctrl + C to stop the process and run `spin up` again. + + +## 6. Start Development +1. Access your Coolify instance: + - URL: `http://localhost:8000` + - Login: `test@example.com` + - Password: `password` + +2. Additional development tools: + + | Tool | URL | Note | + |------|-----|------| + | Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user | + | Mailpit (email catcher) | `http://localhost:8025` | | + | Telescope (debugging tool) | `http://localhost:8000/telescope` | Disabled by default | + +> [!NOTE] +> To enable Telescope, add the following to your `.env` file: +> ```env +> TELESCOPE_ENABLED=true +> ``` + + +## Development Notes +When working on Coolify, keep the following in mind: + +1. **Database Migrations**: After switching branches or making changes to the database structure, always run migrations: + ```bash + docker exec -it coolify php artisan migrate + ``` + +2. **Resetting Development Setup**: To reset your development setup to a clean database with default values: + ```bash + docker exec -it coolify php artisan migrate:fresh --seed + ``` + +3. **Troubleshooting**: If you encounter unexpected behavior, ensure your database is up-to-date with the latest migrations and if possible reset the development setup to eliminate any environment-specific issues. + +> [!IMPORTANT] +> Forgetting to migrate the database can cause problems, so make it a habit to run migrations after pulling changes or switching branches. + + +## Resetting Development Environment +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`): + +1. Stop all running containers `ctrl + c`. + +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 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 Development Guidelines +### Adding a New Service +To add a new service to Coolify, please refer to our documentation: [Adding a New Service](https://coolify.io/docs/get-started/contribute/service) + +### Development for Documentation +To contribute to the Coolify documentation, please refer to this guide: [Contributing to the Coolify Documentation](https://coolify.io/docs/get-started/contribute/documentation) \ No newline at end of file diff --git a/README.md b/README.md index b387d87e8..ee4028d6a 100644 --- a/README.md +++ b/README.md @@ -57,106 +57,95 @@ ## Donations ### Huge Sponsors -* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality -* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API -* [Seibert Group](https://seibert.link/coolifysoftware?ref=coolify.io) - Boost productivity company-wide with AI agents like Claude Code -* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs -* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control +* [Context.dev](https://www.context.dev/) - Web scraping API for AI agents +* [SerpAPI](https://serpapi.com) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API. +* [MVPS](https://www.mvps.net) - Cheap VPS servers at the highest possible quality +* [ScreenshotOne](https://screenshotone.com) - Screenshot API for devs +* [PrivateAlps](https://privatealps.net) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control +* [Seibert Group](https://seibert.link/coolifysoftware) - Boost productivity company-wide with AI agents like Claude Code +* [Contabo](https://contabo.com/en/coolify-vps/) - Cloud VPS & dedicated servers at unbeatable prices ### Big Sponsors -* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions! -* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services -* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions -* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner -* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform -* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers -* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale -* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half -* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor -* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform -* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers -* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design -* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. -* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform -* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions -* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer +* [Cloudways](https://www.cloudways.com/en/?id=2125302) - Managed cloud hosting platform by DigitalOcean +* [ByteBase](https://www.bytebase.com) - Database CI/CD and Security at Scale +* [Ramnode](https://ramnode.com/) - High Performance Cloud VPS Hosting +* [23M](https://23m.com) - Your experts for high-availability hosting solutions! +* [Macarne](https://macarne.com) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity * [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions -* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions -* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers -* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency -* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions -* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers -* [LumaDock](https://lumadock.com/vps-hosting/coolify?utm_source=coolify&utm_medium=sponsorship&utm_campaign=coolify_oss_sponsor_2026&utm_content=github_readme) - Fast and reliable virtual server hosting -* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity -* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity -* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions -* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang -* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting -* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers -* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase -* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data -* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers -* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform -* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform -* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform -* [VPSDime](https://vpsdime.com?ref=coolify.io) - Affordable high-performance VPS hosting solutions - +* [Logto](https://logto.io) - The better identity infrastructure for developers +* [Supadata](https://supadata.ai/) - Scrape YouTube, web, and files. Get AI-ready, clean data for your next project. +* [Tolgee](https://tolgee.io) - The open source localization platform +* [Best Consultant](https://bc.direct) - Your trusted technology consulting partner +* [ArcJet](https://arcjet.com) - Advanced web security and performance solutions +* [SupaGuide](https://supa.guide) - Your comprehensive guide to Supabase +* [CodeRabbit](https://coderabbit.ai) - Cut Code Review Time & Bugs in Half +* [Convex](https://convex.link/coolify.io) - Convex is the open-source reactive database for web app developers. +* [GoldenVM](https://billing.goldenvm.com) - Premium virtual machine hosting solutions +* [Comit International](https://comit.international) - New York Times award–winning contractor! +* [Compai](https://www.trycomp.ai) - The open source compliance automation platform that does everything you need to get compliant, fast. Open source alternative to Drata & Vanta. +* [Tigris](https://www.tigrisdata.com) - Modern S3 Alternative +* [Blacksmith](https://blacksmith.sh) - Infrastructure automation platform +* [JobsCollider](https://jobscollider.com/remote-jobs) - 30,000+ remote jobs for developers +* [Darweb](https://darweb.nl/?ref=coolify.io&utm_source=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions for eCommerce. +* [Hostinger](https://www.hostinger.com/vps/coolify-hosting) - Web hosting and VPS solutions +* [Mobb](https://vibe.mobb.ai/) - Secure Your AI-Generated Code to Unlock Dev Productivity +* [Ubicloud](https://www.ubicloud.com) - Open source cloud infrastructure platform +* [PFGLabs](https://pfglabs.com) - Build Real Projects with Golang +* [JuxtDigital](https://juxtdigital.com) - Digital PR & AI Authority Building Agency +* [SaasyKit](https://saasykit.com) - Complete SaaS starter kit for developers +* [American Cloud](https://americancloud.com) - US-based cloud infrastructure services +* [LiquidWeb](https://liquidweb.com) - Premium managed hosting solutions +* [Greptile](https://www.greptile.com) - The AI Code Reviewer +* [VPSDime](https://vpsdime.com/) - Cheap VPS Hosting - 4GB for $5/month +* [dataforest Cloud](https://cloud.dataforest.net/en) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. +* [ISHosting](https://ishosting.com/) - Hosting and VPS solutions +* [PetroSky Cloud](https://petrosky.io) - Open source cloud deployment solutions +* [QuickSrv](https://quicksrv.io/) - Fast and reliable server hosting ### Small Sponsors -OpenElements -XamanApp -UXWizz -Evercam -Imre Ujlaki -jyc.dev -TheRealJP -360Creators -NiftyCo -Dry Software -Lightspeed.run -LinkDr -Gravity Wiz -BitLaunch -Best for Android -Ilias Ism -Formbricks -Server Searcher -Reshot -Cirun -Typebot -Creating Coding Careers -Internet Garden -Web3 Jobs -Codext -Michael Mazurczak -Fider -Flint -Paweł Pierścionek -RunPod -DartNode -Tyler Whitesides -Aquarela -Crypto Jobs List -Alfred Nutile -Startup Fame -Younes Barrad -Jonas Jaeger -Pixel Infinito -Corentin Clichy -Thompson Edolo -Devhuset -Arvensis Systems -Niklas Lausch -Cap-go -InterviewPal -Transcript LOL +Movavi +ABXY +LaunchFast Boilerplates +Vanaways +Netrouting +MindEd Tech YouStable -MindedTech -NetRouting -ParsecPH - +Transcript LOL +Autom +HuntAPI +ULTRASERVERS +VibeTone +Piloterr +Alexey Panteleev +SummYT - YouTube Summarizer +OpenElements +Xaman +Monadical +Magic as a Service +FiveManage +Crypto Jobs List +SerpAPI +typebot +360Creators +Cap-go +Cirun +Puls Digital Group +Jonathan Pereira +Internet Garden +Evercam +Web3 Jobs +LinkDr +Arvensis Systems +Reshot +RunPod +Gravity Wiz +UXWizz +Codext +InterviewPal +Decidable +Host Havoc ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 1057d1e4d..1061394e6 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -143,8 +143,6 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St ) ); - ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}"); - return; } diff --git a/app/Actions/Destination/RemoveStandaloneDockerNetwork.php b/app/Actions/Destination/RemoveStandaloneDockerNetwork.php new file mode 100644 index 000000000..21c40a50a --- /dev/null +++ b/app/Actions/Destination/RemoveStandaloneDockerNetwork.php @@ -0,0 +1,16 @@ +network); + + instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false); + instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server); + } +} diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index c186c8042..d437a3a17 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -4,7 +4,9 @@ use App\Models\Team; use App\Models\User; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; @@ -12,6 +14,16 @@ class CreateNewUser implements CreatesNewUsers { + private const REGISTRATION_IP_MAX_ATTEMPTS = 3; + + private const REGISTRATION_IP_DECAY_SECONDS = 600; + + private const REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS = 3; + + private const REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS = 3600; + + public function __construct(private readonly Request $request) {} + /** * Validate and create a newly registered user. * @@ -23,6 +35,9 @@ public function create(array $input): User if (! $settings->isPasswordRegistrationAllowed()) { abort(403); } + + $this->ensureRegistrationIsNotRateLimited($input); + Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], 'email' => [ @@ -72,4 +87,42 @@ public function create(array $input): User return $user; } + + /** + * @param array $input + */ + private function ensureRegistrationIsNotRateLimited(array $input): void + { + $keys = [ + [ + 'key' => 'registration:ip:'.sha1($this->realIp()), + 'max' => self::REGISTRATION_IP_MAX_ATTEMPTS, + 'decay' => self::REGISTRATION_IP_DECAY_SECONDS, + ], + ]; + + $emailIdentity = normalize_email_identity($input['email'] ?? null); + if ($emailIdentity !== null) { + $keys[] = [ + 'key' => 'registration:email-identity:'.sha1($emailIdentity), + 'max' => self::REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS, + 'decay' => self::REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS, + ]; + } + + foreach ($keys as $limit) { + if (RateLimiter::tooManyAttempts($limit['key'], $limit['max'])) { + abort(429, 'Too many registration attempts. Please try again later.'); + } + } + + foreach ($keys as $limit) { + RateLimiter::hit($limit['key'], $limit['decay']); + } + } + + private function realIp(): string + { + return $this->request->server('REMOTE_ADDR') ?? $this->request->ip(); + } } diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 06abeb3a6..2f9d58797 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -20,7 +20,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ $realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion"; $helperImageVersion = getHelperVersion(); - $helperImage = config('constants.coolify.helper_image'); + $helperImage = coolifyHelperImage(); $helperImageWithVersion = "$helperImage:$helperImageVersion"; $helperImageWithoutPrefix = 'coollabsio/coolify-helper'; $helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion"; diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index 45ec68abc..c6f032013 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -6,14 +6,16 @@ use App\Models\Server; use App\Models\Team; use App\Notifications\Server\HetznerDeletionFailed; +use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\VultrService; use Lorisleiva\Actions\Concerns\AsAction; class DeleteServer { use AsAction; - public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null) + public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null) { $server = Server::withTrashed()->find($serverId); @@ -26,22 +28,32 @@ public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $het ); } - ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); + if ($deleteFromVultr && ($vultrInstanceId || ($server && $server->vultr_instance_id))) { + $this->deleteFromVultrById( + $vultrInstanceId ?? $server->vultr_instance_id, + $cloudProviderTokenId ?? $server->cloud_provider_token_id, + $teamId ?? $server->team_id + ); + } + + if ($deleteFromDigitalOcean && ($digitalOceanDropletId || ($server && $server->digitalocean_droplet_id))) { + $this->deleteFromDigitalOceanById( + $digitalOceanDropletId ?? $server->digitalocean_droplet_id, + $cloudProviderTokenId ?? $server->cloud_provider_token_id, + $teamId ?? $server->team_id + ); + } + + logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); // If server is already deleted from Coolify, skip this part if (! $server) { return; // Server already force deleted from Coolify } - ray('force deleting server from Coolify', ['server_id' => $server->id]); - try { $server->forceDelete(); } catch (\Throwable $e) { - ray('Failed to force delete server from Coolify', [ - 'error' => $e->getMessage(), - 'server_id' => $server->id, - ]); logger()->error('Failed to force delete server from Coolify', [ 'error' => $e->getMessage(), 'server_id' => $server->id, @@ -56,7 +68,10 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider $token = null; if ($cloudProviderTokenId) { - $token = CloudProviderToken::find($cloudProviderTokenId); + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'hetzner') + ->first(); } if (! $token) { @@ -66,10 +81,6 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider } if (! $token) { - ray('No Hetzner token found for team, skipping Hetzner deletion', [ - 'team_id' => $teamId, - 'hetzner_server_id' => $hetznerServerId, - ]); return; } @@ -77,16 +88,7 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider $hetznerService = new HetznerService($token->token); $hetznerService->deleteServer($hetznerServerId); - ray('Deleted server from Hetzner', [ - 'hetzner_server_id' => $hetznerServerId, - 'team_id' => $teamId, - ]); } catch (\Throwable $e) { - ray('Failed to delete server from Hetzner', [ - 'error' => $e->getMessage(), - 'hetzner_server_id' => $hetznerServerId, - 'team_id' => $teamId, - ]); // Log the error but don't prevent the server from being deleted from Coolify logger()->error('Failed to delete server from Hetzner', [ @@ -100,4 +102,84 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider $team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage())); } } + + private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProviderTokenId, int $teamId): void + { + try { + $token = null; + + if ($cloudProviderTokenId) { + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'vultr') + ->first(); + } + + if (! $token) { + $token = CloudProviderToken::where('team_id', $teamId) + ->where('provider', 'vultr') + ->first(); + } + + if (! $token) { + throw new \RuntimeException('No Vultr token found for the server team.'); + } + + $vultrService = new VultrService($token->token); + $vultrService->deleteInstance($vultrInstanceId); + + logger()->debug('Deleted server from Vultr', [ + 'vultr_instance_id' => $vultrInstanceId, + 'team_id' => $teamId, + ]); + } catch (\Throwable $e) { + logger()->error('Failed to delete server from Vultr', [ + 'error' => $e->getMessage(), + 'vultr_instance_id' => $vultrInstanceId, + 'team_id' => $teamId, + ]); + + throw $e; + } + } + + private function deleteFromDigitalOceanById(int $digitalOceanDropletId, ?int $cloudProviderTokenId, int $teamId): void + { + try { + $token = null; + + if ($cloudProviderTokenId) { + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'digitalocean') + ->first(); + } + + if (! $token) { + $token = CloudProviderToken::where('team_id', $teamId) + ->where('provider', 'digitalocean') + ->first(); + } + + if (! $token) { + throw new \RuntimeException('No DigitalOcean token found for the server team.'); + } + + $digitalOceanService = new DigitalOceanService($token->token); + $digitalOceanService->deleteDroplet($digitalOceanDropletId); + + logger()->debug('Deleted droplet from DigitalOcean', [ + 'digitalocean_droplet_id' => $digitalOceanDropletId, + 'team_id' => $teamId, + ]); + } catch (\Throwable $e) { + logger()->error('Failed to delete droplet from DigitalOcean', [ + 'error' => $e->getMessage(), + 'digitalocean_droplet_id' => $digitalOceanDropletId, + 'team_id' => $teamId, + ]); + + throw $e; + } + } } diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 289ab9ebe..6350a5f37 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -26,7 +26,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer $endpoint = data_get($server, 'settings.sentinel_custom_url'); $debug = data_get($server, 'settings.is_sentinel_debug_enabled'); $mountDir = '/data/coolify/sentinel'; - $image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version; + $image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version; if (! $endpoint) { throw new \RuntimeException('You should set FQDN in Instance Settings.'); } diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index b5ebd92b2..a8c9d21e8 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -118,10 +118,14 @@ private function update() { $latestHelperImageVersion = getHelperVersion(); $upgradeScriptUrl = config('constants.coolify.upgrade_script_url'); + $registryUrl = coolifyRegistryUrl(); remote_process([ "curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh", - "bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion", + 'bash /data/coolify/source/upgrade.sh '. + escapeshellarg($this->latestVersion).' '. + escapeshellarg($latestHelperImageVersion).' '. + escapeshellarg($registryUrl), ], $this->server); } } diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index 22c48aa89..378998fe7 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -28,6 +28,32 @@ public function handle(Server $server) $server->update([ 'validation_logs' => null, ]); + if ($server->vultr_instance_id) { + $status = $server->refreshVultrState(); + if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) { + $this->error = $status === 'deleted' + ? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.' + : 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.'; + $server->update([ + 'validation_logs' => $this->error, + ]); + throw new \Exception($this->error); + } + } + + if ($server->digitalocean_droplet_id) { + $status = $server->refreshDigitalOceanState(); + if (in_array($status, ['off', 'archive', 'deleted'], true)) { + $this->error = $status === 'deleted' + ? 'DigitalOcean droplet is deleted or no longer accessible. Relink this server before validating.' + : 'DigitalOcean droplet is '.($status ?? 'not running').'. Power it on before validating.'; + $server->update([ + 'validation_logs' => $this->error, + ]); + throw new \Exception($this->error); + } + } + ['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection(); if (! $this->uptime) { $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php new file mode 100644 index 000000000..363166bcc --- /dev/null +++ b/app/Actions/Service/DeployServiceApplication.php @@ -0,0 +1,65 @@ +service; + $composeServiceName = $serviceApplication->name; + + $service->parse(); + $service->saveComposeConfigs(); + $service->isConfigurationChanged(save: true); + + $workdir = $service->workdir(); + $composeFile = "{$workdir}/docker-compose.yml"; + $safeWorkdir = escapeshellarg($workdir); + $safeComposeFile = escapeshellarg($composeFile); + $safeProjectName = escapeshellarg($service->uuid); + $safeComposeServiceName = escapeshellarg($composeServiceName); + + $commands = collect([ + 'echo '.escapeshellarg("Saved configuration files to {$workdir}."), + 'touch '.escapeshellarg("{$workdir}/.env"), + ]); + + if ($pullLatestImages) { + $commands->push('echo Pulling image for service.'); + $commands->push("docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName}"); + } + + if ($service->networks()->count() > 0) { + $commands->push('echo Creating Docker network.'); + $commands->push("docker network inspect {$safeProjectName} >/dev/null 2>&1 || docker network create --attachable {$safeProjectName}"); + } + + $upCommand = "docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} up -d --no-deps"; + if ($forceRebuild) { + $upCommand .= ' --build'; + } + $upCommand .= " {$safeComposeServiceName}"; + $commands->push('echo Starting service container.'); + $commands->push($upCommand); + + $commands->push("docker network connect {$safeProjectName} coolify-proxy >/dev/null 2>&1 || true"); + + if (data_get($service, 'connect_to_docker_network')) { + $network = escapeshellarg($service->destination->network); + $containerName = escapeshellarg("{$composeServiceName}-{$service->uuid}"); + $networkAlias = escapeshellarg("{$composeServiceName}-{$service->uuid}"); + $commands->push("docker network connect --alias {$networkAlias} {$network} {$containerName} >/dev/null 2>&1 || true"); + } + + return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); + } +} diff --git a/app/Actions/Service/RestartServiceApplication.php b/app/Actions/Service/RestartServiceApplication.php new file mode 100644 index 000000000..c83cbe660 --- /dev/null +++ b/app/Actions/Service/RestartServiceApplication.php @@ -0,0 +1,24 @@ +service; + $server = $service->destination->server; + $containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid); + + instant_remote_process([ + "docker restart {$containerName}", + ], $server); + } +} diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php new file mode 100644 index 000000000..cc1afbb96 --- /dev/null +++ b/app/Actions/Service/StopServiceApplication.php @@ -0,0 +1,24 @@ +service; + $server = $service->destination->server; + $containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid); + + instant_remote_process([ + "docker stop {$containerName}", + ], $server); + } +} diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php new file mode 100644 index 000000000..7bc04dfdb --- /dev/null +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -0,0 +1,107 @@ +boolean('force_domain_override'); + + if (array_key_exists('url', $payload)) { + $urlRaw = $payload['url']; + if ($urlRaw !== null && ! is_string($urlRaw)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['url' => 'The url must be a string.'], + ], 422); + } + + $parsed = ServiceComposeUrl::validateUrlString( + is_string($urlRaw) ? $urlRaw : null, + $forceDomainOverride + ); + + if (count($parsed['errors']) > 0) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $parsed['errors'], + ], 422); + } + + if ($parsed['normalized'] !== null) { + $containerUrls = str($parsed['normalized']) + ->explode(',') + ->map(fn ($url) => str(trim((string) $url))->lower()); + + $result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $serviceApplication->uuid); + if (isset($result['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$result['error']], + ], 422); + } + + if ($result['hasConflicts'] && ! $forceDomainOverride) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + } + + $serviceApplication->fqdn = $parsed['normalized']; + } + + if (array_key_exists('human_name', $payload)) { + $serviceApplication->human_name = $payload['human_name']; + } + + if (array_key_exists('description', $payload)) { + $serviceApplication->description = $payload['description']; + } + + if (array_key_exists('image', $payload)) { + $serviceApplication->image = $payload['image']; + } + + if (array_key_exists('exclude_from_status', $payload)) { + $serviceApplication->exclude_from_status = filter_var($payload['exclude_from_status'], FILTER_VALIDATE_BOOLEAN); + } + + if (array_key_exists('is_gzip_enabled', $payload)) { + $serviceApplication->is_gzip_enabled = filter_var($payload['is_gzip_enabled'], FILTER_VALIDATE_BOOLEAN); + } + + if (array_key_exists('is_stripprefix_enabled', $payload)) { + $serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN); + } + + if (array_key_exists('is_log_drain_enabled', $payload)) { + $enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN); + $server = $serviceApplication->service->destination->server; + if ($enabled && ! $server->isLogDrainEnabled()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.'], + ], + ], 422); + } + $serviceApplication->is_log_drain_enabled = $enabled; + } + + $serviceApplication->save(); + $serviceApplication->refresh(); + + updateCompose($serviceApplication); + + return null; + } +} diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 3f3e213fd..55acf3828 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -5,9 +5,12 @@ use Illuminate\Console\Command; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Pool; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use function Laravel\Prompts\confirm; +use function Laravel\Prompts\multiselect; +use function Laravel\Prompts\select; class SyncBunny extends Command { @@ -16,7 +19,7 @@ class SyncBunny extends Command * * @var string */ - protected $signature = 'sync:bunny {--templates} {--release} {--nightly}'; + protected $signature = 'sync:bunny {--bunny}'; /** * The console command description. @@ -25,15 +28,234 @@ class SyncBunny extends Command */ protected $description = 'Sync files to BunnyCDN'; + protected function removeTemporaryDirectory(string $tmpDir): void + { + $temporaryRoot = realpath(sys_get_temp_dir()); + $temporaryDirectory = realpath($tmpDir); + + if ($temporaryRoot === false || $temporaryDirectory === false) { + return; + } + + $expectedPrefix = rtrim($temporaryRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'coollabs-cdn-'; + if (! str_starts_with($temporaryDirectory, $expectedPrefix)) { + return; + } + + File::deleteDirectory($temporaryDirectory); + } + + /** + * Fetch GitHub releases and sync to GitHub repository + */ + private function syncReleasesToGitHubRepo(array $files, bool $nightly = false): bool + { + $this->info('Fetching releases from GitHub...'); + try { + $response = Http::timeout(30) + ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ + 'per_page' => 30, // Fetch more releases for better changelog + ]); + + if (! $response->successful()) { + $this->error('Failed to fetch releases from GitHub: '.$response->status()); + + return false; + } + + $releasesFile = tempnam(sys_get_temp_dir(), 'coolify-releases-'); + if ($releasesFile === false || file_put_contents($releasesFile, json_encode($response->json(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) { + $this->error('Failed to create temporary releases.json.'); + + return false; + } + + $files[$releasesFile] = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json'; + + try { + return $this->syncFilesToGitHubRepo($files, $nightly); + } finally { + @unlink($releasesFile); + } + } catch (\Throwable $e) { + $this->error('Error syncing releases: '.$e->getMessage()); + + return false; + } + } + + /** + * Sync install.sh, docker-compose, and env files to GitHub repository via PR + */ + private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool + { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("Syncing $envLabel files to GitHub repository..."); + try { + $timestamp = time(); + $tmpDir = sys_get_temp_dir().'/coollabs-cdn-files-'.$timestamp; + $branchName = 'update-files-'.$timestamp; + + // Clone the repository + $this->info('Cloning coollabs-cdn repository...'); + $output = []; + exec('gh repo clone coollabsio/coollabs-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to clone repository: '.implode("\n", $output)); + + return false; + } + + // Create feature branch + $this->info('Creating feature branch...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to create branch: '.implode("\n", $output)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + // Copy each file to its target path in the CDN repo + $copiedFiles = []; + foreach ($files as $sourceFile => $targetPath) { + if (! file_exists($sourceFile)) { + $this->warn("Source file not found, skipping: $sourceFile"); + + continue; + } + + $destPath = "$tmpDir/$targetPath"; + $destDir = dirname($destPath); + + if (! is_dir($destDir)) { + if (! mkdir($destDir, 0755, true)) { + $this->error("Failed to create directory: $destDir"); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + } + + if (copy($sourceFile, $destPath) === false) { + $this->error("Failed to copy $sourceFile to $destPath"); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + $copiedFiles[] = $targetPath; + $this->info("Copied: $targetPath"); + } + + if (empty($copiedFiles)) { + $this->warn('No files were copied. Nothing to commit.'); + $this->removeTemporaryDirectory($tmpDir); + + return true; + } + + // Stage all copied files + $this->info('Staging changes...'); + $output = []; + $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1'; + exec($stageCmd, $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to stage changes: '.implode("\n", $output)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + // Check for changes + $this->info('Checking for changes...'); + $changedFiles = []; + exec('cd '.escapeshellarg($tmpDir).' && git diff --cached --name-only 2>&1', $changedFiles, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to check changed files: '.implode("\n", $changedFiles)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + $changedFiles = array_values(array_filter($changedFiles)); + if (empty($changedFiles)) { + $this->info('All files are already up to date. No changes to commit.'); + $this->removeTemporaryDirectory($tmpDir); + + return true; + } + + // Commit changes + $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to commit changes: '.implode("\n", $output)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + // Push to remote + $this->info('Pushing branch to remote...'); + $output = []; + exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to push branch: '.implode("\n", $output)); + $this->removeTemporaryDirectory($tmpDir); + + return false; + } + + // Create pull request + $this->info('Creating pull request...'); + $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s'); + $fileList = implode("\n- ", $changedFiles); + $prBody = "Automated update of $envLabel files:\n- $fileList"; + $prCommand = 'gh pr create --repo coollabsio/coollabs-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; + $output = []; + exec($prCommand, $output, $returnCode); + + // Clean up + $this->removeTemporaryDirectory($tmpDir); + + if ($returnCode !== 0) { + $this->error('Failed to create PR: '.implode("\n", $output)); + + return false; + } + + $this->info('Pull request created successfully!'); + if (! empty($output)) { + $this->info('PR URL: '.implode("\n", $output)); + } + $this->info('Files synced: '.count($changedFiles)); + + return true; + } catch (\Throwable $e) { + $this->error('Error syncing files to GitHub: '.$e->getMessage()); + + return false; + } + } + /** * Execute the console command. */ public function handle() { $that = $this; - $only_template = $this->option('templates'); - $only_version = $this->option('release'); - $nightly = $this->option('nightly'); + $only_bunny = $this->option('bunny'); + $nightly = select( + label: 'Which environment would you like to sync?', + options: [ + 'production' => 'Production', + 'nightly' => 'Nightly', + ], + default: 'production', + ) === 'nightly'; $bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn_path = 'coolify'; $bunny_cdn_storage_name = 'coolcdn'; @@ -55,6 +277,7 @@ public function handle() $upgrade_script_location = "$parent_dir/scripts/upgrade.sh"; $upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh"; $production_env_location = "$parent_dir/.env.production"; + $service_template_location = "$parent_dir/templates/$service_template"; $versions_location = "$parent_dir/$versions"; PendingRequest::macro('storage', function ($fileName) use ($that) { @@ -93,7 +316,7 @@ public function handle() $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } - if (! $only_template && ! $only_version) { + if ($only_bunny) { $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; $this->info("About to sync $envLabel files to BunnyCDN."); $this->newLine(); @@ -108,7 +331,7 @@ public function handle() $install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script", ]; - $diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time(); + $diffTmpDir = sys_get_temp_dir().'/coollabs-cdn-diff-'.time(); @mkdir($diffTmpDir, 0755, true); $hasChanges = false; @@ -151,7 +374,7 @@ public function handle() } } - exec('rm -rf '.escapeshellarg($diffTmpDir)); + $this->removeTemporaryDirectory($diffTmpDir); if (! $hasChanges) { $this->newLine(); @@ -167,49 +390,55 @@ public function handle() return; } } - if ($only_template) { - $this->info('About to sync '.config('constants.services.file_name').' to BunnyCDN.'); - $confirmed = confirm('Are you sure you want to sync?'); - if (! $confirmed) { - return; - } - Http::pool(fn (Pool $pool) => [ - $pool->storage(fileName: "$parent_dir/templates/$service_template")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$service_template"), - $pool->purge("$bunny_cdn/$bunny_cdn_path/$service_template"), - ]); - $this->info('Service template uploaded & purged...'); + if (! $only_bunny) { + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("About to sync $envLabel releases, versions, compose, and environment files to GitHub repository."); - return; - } elseif ($only_version) { if ($nightly) { - $this->info('About to sync NIGHTLY versions.json to BunnyCDN.'); + $files = [ + $versions_location => 'json/coolify/nightly/versions.json', + $compose_file_location => 'json/coolify/nightly/docker-compose.yml', + $compose_file_prod_location => 'json/coolify/nightly/docker-compose.prod.yml', + $production_env_location => 'json/coolify/nightly/.env.production', + $install_script_location => 'json/coolify/nightly/install.sh', + $upgrade_script_location => 'json/coolify/nightly/upgrade.sh', + $upgrade_postgres_script_location => 'json/coolify/nightly/upgrade-postgres.sh', + $service_template_location => 'json/coolify/nightly/service-templates-latest.json', + ]; } else { - $this->info('About to sync PRODUCTION versions.json to BunnyCDN.'); - } - $file = file_get_contents($versions_location); - $json = json_decode($file, true); - $actual_version = data_get($json, 'coolify.v4.version'); - - $this->info("Version: {$actual_version}"); - $this->info('This will:'); - $this->info(' 1. Sync versions.json to BunnyCDN'); - $this->newLine(); - - $confirmed = confirm('Are you sure you want to proceed?'); - if (! $confirmed) { - return; + $files = [ + $versions_location => 'json/coolify/versions.json', + $compose_file_location => 'json/coolify/docker-compose.yml', + $compose_file_prod_location => 'json/coolify/docker-compose.prod.yml', + $production_env_location => 'json/coolify/.env.production', + $install_script_location => 'json/coolify/install.sh', + $upgrade_script_location => 'json/coolify/upgrade.sh', + $upgrade_postgres_script_location => 'json/coolify/upgrade-postgres.sh', + $service_template_location => 'json/coolify/service-templates-latest.json', + ]; } - $this->info('Syncing versions.json to BunnyCDN...'); - Http::pool(fn (Pool $pool) => [ - $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), - $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), - ]); - $this->info('✓ versions.json uploaded & purged to BunnyCDN'); - $this->newLine(); + $releasesTarget = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json'; + $options = [$releasesTarget, ...array_values($files)]; + $selectedFiles = multiselect( + label: 'Which files would you like to sync?', + options: $options, + default: $options, + required: true, + scroll: count($options), + ); - $this->info('=== Summary ==='); - $this->info('BunnyCDN sync: ✓ Complete'); + $includeReleases = in_array($releasesTarget, $selectedFiles, true); + $files = array_filter( + $files, + fn (string $targetPath) => in_array($targetPath, $selectedFiles, true), + ); + + if ($includeReleases) { + $this->syncReleasesToGitHubRepo($files, $nightly); + } else { + $this->syncFilesToGitHubRepo($files, $nightly); + } return; } @@ -231,10 +460,6 @@ public function handle() $pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"), ]); $this->info('All files uploaded & purged to BunnyCDN.'); - $this->newLine(); - - $this->info('=== Summary ==='); - $this->info('BunnyCDN sync: Complete'); } catch (\Throwable $e) { $this->error('Error: '.$e->getMessage()); } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 9570026c1..593207735 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -33,6 +33,57 @@ class ApplicationsController extends Controller { + use Concerns\HandlesTagsApi; + + private const APPLICATION_SETTING_FIELDS = [ + 'is_git_submodules_enabled', + 'is_git_lfs_enabled', + 'is_git_shallow_clone_enabled', + 'disable_build_cache', + 'inject_build_args_to_dockerfile', + 'include_source_commit_in_build', + 'is_env_sorting_enabled', + 'is_pr_deployments_public_enabled', + 'stop_grace_period', + 'docker_images_to_keep', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + 'is_raw_compose_deployment_enabled', + ]; + + private const BOOLEAN_APPLICATION_SETTING_FIELDS = [ + 'is_git_submodules_enabled', + 'is_git_lfs_enabled', + 'is_git_shallow_clone_enabled', + 'disable_build_cache', + 'inject_build_args_to_dockerfile', + 'include_source_commit_in_build', + 'is_env_sorting_enabled', + 'is_pr_deployments_public_enabled', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + 'is_raw_compose_deployment_enabled', + ]; + + protected function findTaggableResource(string $uuid, int|string $teamId): mixed + { + return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); + } + + protected function tagResourceNotFoundMessage(): string + { + return 'Application not found.'; + } + + private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume + { + if (request()->attributes->get('can_read_sensitive', false) === true) { + $storage->makeVisible(['content']); + } + + return $storage; + } + private function removeSensitiveData($application) { $application->makeHidden([ @@ -41,8 +92,8 @@ private function removeSensitiveData($application) 'resourceable_id', 'resourceable_type', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $application->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $application->makeVisible([ 'custom_labels', 'dockerfile', 'docker_compose', @@ -51,10 +102,14 @@ private function removeSensitiveData($application) 'manual_webhook_secret_gitea', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', - 'private_key_id', + 'http_basic_auth_password', 'value', 'real_value', - 'http_basic_auth_password', + ]); + $this->exposeNestedServerSecrets($application); + } else { + $application->makeHidden([ + 'private_key_id', ]); } @@ -62,9 +117,76 @@ private function removeSensitiveData($application) $application->makeHidden(['value', 'real_value']); } + if ($application->relationLoaded('settings')) { + $application->settings?->makeHidden(['id', 'application_id', 'created_at', 'updated_at']); + } + return serializeApiResponse($application); } + private function applicationSettingsFromRequest(Request $request): array + { + $settings = []; + + foreach (self::APPLICATION_SETTING_FIELDS as $field) { + if (! array_key_exists($field, $request->all())) { + continue; + } + + $settings[$field] = in_array($field, self::BOOLEAN_APPLICATION_SETTING_FIELDS, true) + ? $request->boolean($field) + : $request->input($field); + } + + return $settings; + } + + private function applyApplicationSettings(Application $application, array $settings): void + { + if ($settings === []) { + return; + } + + $regenerateLabels = ! $application->wasRecentlyCreated + && $application->settings->is_container_label_readonly_enabled + && (array_key_exists('is_gzip_enabled', $settings) || array_key_exists('is_stripprefix_enabled', $settings)); + + $application->settings->fill($settings)->save(); + + if ($regenerateLabels) { + $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); + $application->save(); + } + } + + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + * Models hide these by default via $hidden; this re-exposes them per-request. + */ + private function exposeNestedServerSecrets($model): void + { + $server = $model->destination?->server ?? null; + if (! $server) { + return; + } + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + $settings = $server->settings ?? null; + if ($settings) { + $settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + #[OA\Get( summary: 'List', description: 'List all applications.', @@ -117,8 +239,12 @@ public function applications(Request $request) } $tagName = $request->query('tag'); + $applicationRelations = $request->attributes->get('can_read_sensitive', false) === true + ? ['destination.server.settings'] + : []; $applications = Application::ownedByCurrentTeamAPI($teamId) + ->with($applicationRelations) ->when($tagName, function ($query, $tagName) { $query->whereHas('tags', function ($query) use ($tagName) { $query->where('name', $tagName); @@ -170,6 +296,7 @@ public function applications(Request $request) 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], + 'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'], '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.'], @@ -227,6 +354,20 @@ public function applications(Request $request) ], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -234,6 +375,7 @@ public function applications(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], ], ) @@ -337,6 +479,7 @@ public function create_public_application(Request $request) 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], + 'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'], '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.'], @@ -393,6 +536,20 @@ public function create_public_application(Request $request) ], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -400,6 +557,7 @@ public function create_public_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], ], ) @@ -503,6 +661,7 @@ public function create_private_gh_app_application(Request $request) 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], + 'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'], '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.'], @@ -559,6 +718,20 @@ public function create_private_gh_app_application(Request $request) ], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -566,6 +739,7 @@ public function create_private_gh_app_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], ], ) @@ -696,7 +870,22 @@ public function create_private_deploy_key_application(Request $request) '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.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], + 'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -704,6 +893,7 @@ public function create_private_deploy_key_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -830,7 +1020,22 @@ public function create_dockerfile_application(Request $request) '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.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], + 'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -838,6 +1043,7 @@ public function create_dockerfile_application(Request $request) 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], ) ), @@ -914,7 +1120,7 @@ private function create_application(Request $request, $type) if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', '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', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', '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', 'dockerfile_location', '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', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', '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', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', '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', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', ...self::APPLICATION_SETTING_FIELDS]; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -928,6 +1134,8 @@ private function create_application(Request $request, $type) 'http_basic_auth_username' => 'string|nullable', 'http_basic_auth_password' => 'string|nullable', 'autogenerate_domain' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -945,6 +1153,13 @@ private function create_application(Request $request, $type) ], 422); } + $return = $this->validateTagsParameter($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $tagNames = $request->input('tags') ?? []; + $environmentUuid = $request->environment_uuid; $environmentName = $request->environment_name; if (blank($environmentUuid) && blank($environmentName)) { @@ -952,18 +1167,37 @@ private function create_application(Request $request, $type) } $serverUuid = $request->server_uuid; $fqdn = $request->domains; + if ($request->has('domains') && is_string($request->domains)) { + $fqdn = ValidationPatterns::normalizeApplicationDomains($request->domains); + $request->offsetSet('domains', $fqdn); + } $autogenerateDomain = $request->boolean('autogenerate_domain', true); $instantDeploy = $request->instant_deploy; $githubAppUuid = $request->github_app_uuid; $useBuildServer = $request->use_build_server; + $useBuildSecrets = $request->use_build_secrets; $isStatic = $request->is_static; $isSpa = $request->is_spa; $isAutoDeployEnabled = $request->is_auto_deploy_enabled; $isForceHttpsEnabled = $request->is_force_https_enabled; + $isPreviewDeploymentsEnabled = $request->is_preview_deployments_enabled; $connectToDockerNetwork = $request->connect_to_docker_network; $customNginxConfiguration = $request->custom_nginx_configuration; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true); $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false); + $applicationSettings = $this->applicationSettingsFromRequest($request); + + $requestedBuildPack = in_array($type, ['public', 'private-gh-app', 'private-deploy-key'], true) + ? $request->input('build_pack') + : $type; + if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'is_raw_compose_deployment_enabled' => 'Raw compose deployment can only be enabled for Docker Compose applications.', + ], + ], 422); + } if (! is_null($customNginxConfiguration)) { if (! isBase64Encoded($customNginxConfiguration)) { @@ -1031,7 +1265,7 @@ private function create_application(Request $request, $type) 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', - 'docker_compose_domains.*.domain' => 'string|nullable', + 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), ]; // ports_exposes is not required for dockercompose if ($request->build_pack === 'dockercompose') { @@ -1087,7 +1321,7 @@ private function create_application(Request $request, $type) $errors = []; $urls = $urls->map(function ($url) use (&$errors) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { + if (! isValidDomainUrl($url)) { $errors[] = "Invalid URL: {$url}"; return $url; @@ -1137,20 +1371,21 @@ private function create_application(Request $request, $type) $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { - $application->docker_compose_domains = $dockerComposeDomainsJson; + $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); } $repository_url_parsed = Url::fromString($request->git_repository); $git_host = $repository_url_parsed->getHost(); if ($git_host === 'github.com') { $application->source_type = GithubApp::class; $application->source_id = GithubApp::find(0)->id; + $application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString(); } - $application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString(); $application->fqdn = $fqdn; $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; $application->save(); + $this->applyApplicationSettings($application, $applicationSettings); if (isset($isStatic)) { $application->settings->is_static = $isStatic; $application->settings->save(); @@ -1167,6 +1402,10 @@ private function create_application(Request $request, $type) $application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->save(); } + if (isset($isPreviewDeploymentsEnabled)) { + $application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1175,6 +1414,10 @@ private function create_application(Request $request, $type) $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); } + if (isset($useBuildSecrets)) { + $application->settings->use_build_secrets = $useBuildSecrets; + $application->settings->save(); + } if (isset($isContainerLabelEscapeEnabled)) { $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); @@ -1193,6 +1436,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1239,7 +1485,7 @@ private function create_application(Request $request, $type) 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', - 'docker_compose_domains.*.domain' => 'string|nullable', + 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validationMessages = [ @@ -1328,7 +1574,7 @@ private function create_application(Request $request, $type) $errors = []; $urls = $urls->map(function ($url) use (&$errors) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { + if (! isValidDomainUrl($url)) { $errors[] = "Invalid URL: {$url}"; return $url; @@ -1378,7 +1624,7 @@ private function create_application(Request $request, $type) $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { - $application->docker_compose_domains = $dockerComposeDomainsJson; + $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); } $application->fqdn = $fqdn; $application->git_repository = str($gitRepository)->trim()->toString(); @@ -1390,6 +1636,7 @@ private function create_application(Request $request, $type) $application->repository_project_id = $repository_project_id; $application->save(); + $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); // Auto-generate domain if requested and no custom domain provided if ($autogenerateDomain && blank($fqdn)) { @@ -1412,6 +1659,10 @@ private function create_application(Request $request, $type) $application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->save(); } + if (isset($isPreviewDeploymentsEnabled)) { + $application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1420,6 +1671,10 @@ private function create_application(Request $request, $type) $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); } + if (isset($useBuildSecrets)) { + $application->settings->use_build_secrets = $useBuildSecrets; + $application->settings->save(); + } if (isset($isContainerLabelEscapeEnabled)) { $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); @@ -1432,6 +1687,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1479,7 +1737,7 @@ private function create_application(Request $request, $type) 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', - 'docker_compose_domains.*.domain' => 'string|nullable', + 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); @@ -1541,7 +1799,7 @@ private function create_application(Request $request, $type) $errors = []; $urls = $urls->map(function ($url) use (&$errors) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { + if (! isValidDomainUrl($url)) { $errors[] = "Invalid URL: {$url}"; return $url; @@ -1591,7 +1849,7 @@ private function create_application(Request $request, $type) $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { - $application->docker_compose_domains = $dockerComposeDomainsJson; + $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); } $application->fqdn = $fqdn; $application->private_key_id = $privateKey->id; @@ -1599,6 +1857,7 @@ private function create_application(Request $request, $type) $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; $application->save(); + $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); // Auto-generate domain if requested and no custom domain provided if ($autogenerateDomain && blank($fqdn)) { @@ -1621,6 +1880,10 @@ private function create_application(Request $request, $type) $application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->save(); } + if (isset($isPreviewDeploymentsEnabled)) { + $application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1629,6 +1892,10 @@ private function create_application(Request $request, $type) $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); } + if (isset($useBuildSecrets)) { + $application->settings->use_build_secrets = $useBuildSecrets; + $application->settings->save(); + } if (isset($isContainerLabelEscapeEnabled)) { $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); @@ -1641,6 +1908,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1735,6 +2005,7 @@ private function create_application(Request $request, $type) $application->git_repository = 'coollabsio/coolify'; $application->git_branch = 'main'; $application->save(); + $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); // Auto-generate domain if requested and no custom domain provided if ($autogenerateDomain && blank($fqdn)) { @@ -1745,6 +2016,10 @@ private function create_application(Request $request, $type) $application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->save(); } + if (isset($isPreviewDeploymentsEnabled)) { + $application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1753,6 +2028,10 @@ private function create_application(Request $request, $type) $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); } + if (isset($useBuildSecrets)) { + $application->settings->use_build_secrets = $useBuildSecrets; + $application->settings->save(); + } if (isset($isContainerLabelEscapeEnabled)) { $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); @@ -1761,6 +2040,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1854,6 +2136,7 @@ private function create_application(Request $request, $type) $application->git_repository = 'coollabsio/coolify'; $application->git_branch = 'main'; $application->save(); + $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); // Auto-generate domain if requested and no custom domain provided if ($autogenerateDomain && blank($fqdn)) { @@ -1864,6 +2147,10 @@ private function create_application(Request $request, $type) $application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->save(); } + if (isset($isPreviewDeploymentsEnabled)) { + $application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled; + $application->settings->save(); + } if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -1872,6 +2159,10 @@ private function create_application(Request $request, $type) $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); } + if (isset($useBuildSecrets)) { + $application->settings->use_build_secrets = $useBuildSecrets; + $application->settings->save(); + } if (isset($isContainerLabelEscapeEnabled)) { $application->settings->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled; $application->settings->save(); @@ -1880,6 +2171,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1911,6 +2205,7 @@ private function create_application(Request $request, $type) 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), ]))->setStatusCode(201); + } return response()->json(['message' => 'Invalid type.'], 400); @@ -1973,7 +2268,7 @@ public function application_by_uuid(Request $request) if (! $uuid) { return response()->json(['message' => 'UUID is required.'], 400); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->with('settings')->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } @@ -2013,6 +2308,13 @@ public function application_by_uuid(Request $request) default: 100, ) ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), ], responses: [ new OA\Response( @@ -2054,7 +2356,7 @@ public function logs_by_uuid(Request $request) if (! $uuid) { return response()->json(['message' => 'UUID is required.'], 400); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } @@ -2076,8 +2378,9 @@ public function logs_by_uuid(Request $request) ], 400); } - $lines = $request->query->get('lines', 100) ?: 100; - $logs = getContainerLogs($application->destination->server, $container['ID'], $lines); + $lines = normalizeLogLines($request->query('lines')); + $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); + $logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps); return response()->json([ 'logs' => $logs, @@ -2147,7 +2450,7 @@ public function delete_by_uuid(Request $request) if (! $request->uuid) { return response()->json(['message' => 'UUID is required.'], 404); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -2224,6 +2527,7 @@ public function delete_by_uuid(Request $request) 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], + 'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'], 'start_command' => ['type' => 'string', 'description' => 'The start command.'], @@ -2279,6 +2583,20 @@ public function delete_by_uuid(Request $request) ], 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], @@ -2358,7 +2676,7 @@ public function update_by_uuid(Request $request) return $return; } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ 'message' => 'Application not found', @@ -2368,7 +2686,7 @@ public function update_by_uuid(Request $request) $this->authorize('update', $application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', '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', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', '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', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled']; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', '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', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', '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', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', ...self::APPLICATION_SETTING_FIELDS]; $validationRules = [ 'name' => 'string|max:255', @@ -2378,11 +2696,13 @@ public function update_by_uuid(Request $request) 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*.name' => 'string|required', - 'docker_compose_domains.*.domain' => 'string|nullable', + 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), 'custom_nginx_configuration' => 'string|nullable', 'is_http_basic_auth_enabled' => 'boolean|nullable', + 'is_preview_deployments_enabled' => 'boolean|nullable', 'http_basic_auth_username' => 'string', 'http_basic_auth_password' => 'string', + 'include_source_commit_in_build' => 'boolean', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validationMessages = [ @@ -2445,6 +2765,17 @@ public function update_by_uuid(Request $request) ], 422); } + $applicationSettings = $this->applicationSettingsFromRequest($request); + $requestedBuildPack = $request->input('build_pack', $application->build_pack); + if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'is_raw_compose_deployment_enabled' => 'Raw compose deployment can only be enabled for Docker Compose applications.', + ], + ], 422); + } + if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) { if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) { $validationErrors = []; @@ -2482,29 +2813,7 @@ public function update_by_uuid(Request $request) $requestHasDomains = $request->has('domains'); if ($requestHasDomains && $server->isProxyShouldRun()) { $uuid = $request->uuid; - $urls = $request->domains; - $urls = str($urls)->replaceStart(',', '')->replaceEnd(',', '')->trim(); - $errors = []; - $urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) { - $url = trim($url); - - // If "domains" is empty clear all URLs from the fqdn column - if (blank($url)) { - return null; - } - - if (! filter_var($url, FILTER_VALIDATE_URL)) { - $errors[] = 'Invalid URL: '.$url; - - return $url; - } - $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; - if (! in_array(strtolower($scheme), ['http', 'https'])) { - $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; - } - - return str($url)->lower(); - }); + $errors = ValidationPatterns::validateApplicationDomains($request->domains); if (count($errors) > 0) { return response()->json([ @@ -2512,6 +2821,9 @@ public function update_by_uuid(Request $request) 'errors' => $errors, ], 422); } + $domains = ValidationPatterns::normalizeApplicationDomains($request->domains); + $request->offsetSet('domains', $domains); + $urls = collect(ValidationPatterns::applicationDomainList($domains)); // Check for domain conflicts $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid); if (isset($result['error'])) { @@ -2556,7 +2868,7 @@ public function update_by_uuid(Request $request) $errors = []; $urls = $urls->map(function ($url) use (&$errors) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { + if (! isValidDomainUrl($url)) { $errors[] = "Invalid URL: {$url}"; return $url; @@ -2615,14 +2927,21 @@ public function update_by_uuid(Request $request) $isSpa = $request->is_spa; $isAutoDeployEnabled = $request->is_auto_deploy_enabled; $isForceHttpsEnabled = $request->is_force_https_enabled; + $isPreviewDeploymentsEnabled = $request->is_preview_deployments_enabled; $connectToDockerNetwork = $request->connect_to_docker_network; $useBuildServer = $request->use_build_server; + $useBuildSecrets = $request->use_build_secrets; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled'); $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled'); + $includeSourceCommitInBuild = $request->boolean('include_source_commit_in_build'); if (isset($useBuildServer)) { $application->settings->is_build_server_enabled = $useBuildServer; $application->settings->save(); } + if (isset($useBuildSecrets)) { + $application->settings->use_build_secrets = $useBuildSecrets; + $application->settings->save(); + } if (isset($isStatic)) { $application->settings->is_static = $isStatic; @@ -2644,6 +2963,11 @@ public function update_by_uuid(Request $request) $application->settings->save(); } + if (isset($isPreviewDeploymentsEnabled)) { + $application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled; + $application->settings->save(); + } + if (isset($connectToDockerNetwork)) { $application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->save(); @@ -2657,6 +2981,11 @@ public function update_by_uuid(Request $request) $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled; $application->settings->save(); } + if ($request->has('include_source_commit_in_build')) { + $application->settings->include_source_commit_in_build = $includeSourceCommitInBuild; + $application->settings->save(); + } + $this->applyApplicationSettings($application, $applicationSettings); removeUnnecessaryFieldsFromRequest($request); $data = $request->only($allowedFields); @@ -3624,7 +3953,7 @@ public function action_deploy(Request $request) 'team_id' => $teamId, 'application_uuid' => $application->uuid, 'application_name' => $application->name, - 'deployment_uuid' => $deployment_uuid->toString(), + 'deployment_uuid' => $deployment_uuid, 'force_rebuild' => $force, 'instant_deploy' => $instant_deploy, ]); @@ -3632,7 +3961,7 @@ public function action_deploy(Request $request) return response()->json( [ 'message' => 'Deployment request queued.', - 'deployment_uuid' => $deployment_uuid->toString(), + 'deployment_uuid' => $deployment_uuid, ], 200 ); @@ -3818,17 +4147,110 @@ public function action_restart(Request $request) 'team_id' => $teamId, 'application_uuid' => $application->uuid, 'application_name' => $application->name, - 'deployment_uuid' => $deployment_uuid->toString(), + 'deployment_uuid' => $deployment_uuid, ]); return response()->json( [ 'message' => 'Restart request queued.', - 'deployment_uuid' => $deployment_uuid->toString(), + 'deployment_uuid' => $deployment_uuid, ], ); } + #[OA\Post( + summary: 'Move', + description: 'Move application to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.', + path: '/applications/{uuid}/move', + operationId: 'move-application-by-uuid', + 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', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the application to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Application moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Application moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['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', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): JsonResponse + { + $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); + } + + $this->authorize('update', $application); + + return moveResourceToEnvironment($request, $application, 'Application', $teamId); + } + private function validateDataApplications(Request $request, Server $server) { $teamId = getTeamIdFromToken(); @@ -3871,36 +4293,16 @@ private function validateDataApplications(Request $request, Server $server) } if ($request->has('domains') && $server->isProxyShouldRun()) { $uuid = $request->uuid; - $urls = $request->domains; - $urls = str($urls)->replaceEnd(',', '')->trim(); - $urls = str($urls)->replaceStart(',', '')->trim(); - $errors = []; - $urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) { - $url = trim($url); - - // If "domains" is empty clear all URLs from the fqdn column - if (blank($url)) { - return null; - } - - if (! filter_var($url, FILTER_VALIDATE_URL)) { - $errors[] = 'Invalid URL: '.$url; - - return str($url)->lower(); - } - $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; - if (! in_array(strtolower($scheme), ['http', 'https'])) { - $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; - } - - return str($url)->lower(); - }); + $errors = ValidationPatterns::validateApplicationDomains($request->domains); if (count($errors) > 0) { return response()->json([ 'message' => 'Validation failed.', 'errors' => $errors, ], 422); } + $normalizedDomains = ValidationPatterns::normalizeApplicationDomains($request->domains); + $request->offsetSet('domains', $normalizedDomains); + $urls = collect(ValidationPatterns::applicationDomainList($normalizedDomains)); // Check for domain conflicts $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid); if (isset($result['error'])) { @@ -3984,6 +4386,7 @@ public function storages(Request $request): JsonResponse $persistentStorages = $application->persistentStorages->sortBy('id')->values(); $fileStorages = $application->fileStorages->sortBy('id')->values(); + $fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage)); return response()->json([ 'persistent_storages' => $persistentStorages, @@ -4198,7 +4601,7 @@ public function update_storage(Request $request): JsonResponse 'mount_path' => $storage->mount_path ?? null, ]); - return response()->json($storage); + return response()->json($this->exposeFileStorageContentIfAllowed($storage)); } #[OA\Post( @@ -4432,7 +4835,7 @@ public function create_storage(Request $request): JsonResponse 'mount_path' => $storage->mount_path, ]); - return response()->json($storage, 201); + return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201); } #[OA\Delete( @@ -4594,4 +4997,148 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons return response()->json(['message' => 'Preview deletion request queued.']); } + + #[OA\Get( + summary: 'List Tags', + description: 'List tags for an application by UUID.', + path: '/applications/{uuid}/tags', + operationId: 'list-tags-by-application-uuid', + 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') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of tags.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + 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 tags(Request $request): JsonResponse + { + return $this->listTags($request); + } + + #[OA\Post( + summary: 'Create Tag', + description: 'Add tag(s) to an application by UUID.', + path: '/applications/{uuid}/tags', + operationId: 'create-tag-by-application-uuid', + 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') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'], + 'tag_names' => [ + 'type' => 'array', + 'items' => new OA\Items(type: 'string'), + 'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.', + ], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tags added successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + 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'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_tag(Request $request): JsonResponse + { + return $this->createTag($request); + } + + #[OA\Delete( + summary: 'Delete Tag', + description: 'Remove a tag from an application by UUID.', + path: '/applications/{uuid}/tags/{tag_uuid}', + operationId: 'delete-tag-by-application-uuid', + 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') + ), + new OA\Parameter( + name: 'tag_uuid', + in: 'path', + description: 'UUID of the tag.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag removed.', + ), + 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 delete_tag(Request $request): JsonResponse + { + return $this->deleteTag($request); + } } diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index ad6eeb982..ad699e8f1 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -16,9 +16,14 @@ private function removeSensitiveData($token) { $token->makeHidden([ 'id', - 'token', ]); + if (request()->attributes->get('can_read_sensitive', false) === true) { + $token->makeVisible([ + 'token', + ]); + } + return serializeApiResponse($token); } @@ -37,6 +42,9 @@ private function validateProviderToken(string $provider, string $token): array 'digitalocean' => Http::withHeaders([ 'Authorization' => 'Bearer '.$token, ])->timeout(10)->get('https://api.digitalocean.com/v2/account'), + 'vultr' => Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.vultr.com/v2/account'), default => null, }; @@ -82,7 +90,7 @@ private function validateProviderToken(string $provider, string $token): array properties: [ 'uuid' => ['type' => 'string'], 'name' => ['type' => 'string'], - 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']], 'team_id' => ['type' => 'integer'], 'servers_count' => ['type' => 'integer'], 'created_at' => ['type' => 'string'], @@ -200,7 +208,7 @@ public function show(Request $request) type: 'object', required: ['provider', 'token', 'name'], properties: [ - 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], 'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'], 'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'], ], @@ -255,7 +263,7 @@ public function store(Request $request) $body = $request->json()->all(); $validator = customApiValidator($body, [ - 'provider' => 'required|string|in:hetzner,digitalocean', + 'provider' => 'required|string|in:hetzner,digitalocean,vultr', 'token' => 'required|string', 'name' => 'required|string|max:255', ]); diff --git a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php new file mode 100644 index 000000000..4c15d0726 --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php @@ -0,0 +1,174 @@ +findTaggableResource($request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404); + } + + $this->authorize('view', $resource); + + return response()->json($resource->tags->map(TagsController::serializeTag(...))); + } + + public function createTag(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $resource = $this->findTaggableResource($request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404); + } + + $this->authorize('update', $resource); + + if ($request->has('tag_name') && $request->has('tag_names')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']], + ], 422); + } + + $validator = Validator::make($request->all(), [ + 'tag_name' => 'required_without:tag_names|string', + 'tag_names' => 'required_without:tag_name|array|min:1', + 'tag_names.*' => 'string', + ]); + + $extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']); + 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); + } + + $tagNames = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]); + $invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2); + if (! empty($invalidTags)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']], + ], 422); + } + + $this->attachTagsToResource($resource, $tagNames, $teamId); + + return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201); + } + + public function deleteTag(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $resource = $this->findTaggableResource($request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404); + } + + $this->authorize('update', $resource); + + $tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first(); + if (! $tag) { + return response()->json(['message' => 'Tag not found.'], 404); + } + + if (! $resource->tags()->whereKey($tag->id)->exists()) { + return response()->json(['message' => 'Tag not found on resource.'], 404); + } + + $resource->tags()->detach($tag->id); + $tag->deleteIfOrphaned(); + + return response()->json(['message' => 'Tag removed.']); + } + + protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void + { + foreach ($this->normalizeTagNames($tagNames) as $tagName) { + if (mb_strlen($tagName) < 2) { + continue; + } + + $tag = Tag::query()->createOrFirst([ + 'team_id' => $teamId, + 'name' => $tagName, + ]); + + $resource->tags()->syncWithoutDetaching([$tag->id]); + } + } + + protected function validateTagsParameter(Request $request): ?JsonResponse + { + if (! $request->has('tags')) { + return null; + } + + $tagNames = $this->normalizeTagNames($request->input('tags', [])); + $invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2); + if (! empty($invalidTags)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']], + ], 422); + } + + $request->merge(['tags' => $tagNames]); + + return null; + } + + protected function normalizeTagNames(array $tagNames): array + { + return collect($tagNames) + ->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName)))) + ->unique() + ->values() + ->all(); + } +} diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 912f81728..1d64a7d6b 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -20,6 +20,7 @@ use App\Models\Server; use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -27,28 +28,123 @@ class DatabasesController extends Controller { - private function removeSensitiveData($database) + use Concerns\HandlesTagsApi; + + protected function findTaggableResource(string $uuid, int|string $teamId): mixed + { + return queryDatabaseByUuidWithinTeam($uuid, $teamId); + } + + protected function tagResourceNotFoundMessage(): string + { + return 'Database not found.'; + } + + private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume + { + if (request()->attributes->get('can_read_sensitive', false) === true) { + $storage->makeVisible(['content']); + } + + return $storage; + } + + private function removeSensitiveData($database, bool $loadNestedServerSecrets = false) { $database->makeHidden([ 'id', 'laravel_through_key', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $database->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $database->makeVisible([ 'internal_db_url', 'external_db_url', + 'init_scripts', 'postgres_password', 'dragonfly_password', 'redis_password', 'mongo_initdb_root_password', 'keydb_password', 'clickhouse_admin_password', + 'mysql_password', + 'mysql_root_password', + 'mariadb_password', + 'mariadb_root_password', ]); + $this->exposeNestedServerSecrets($database); + } else { + $this->hideNestedServerSecrets($database, $loadNestedServerSecrets); } return serializeApiResponse($database); } + private function hideNestedServerSecrets(Model $model, bool $loadRelations = false): void + { + if ($loadRelations) { + $server = data_get($model, 'destination.server'); + } else { + if (! $model->relationLoaded('destination')) { + return; + } + + $destination = $model->getRelation('destination'); + if (! $destination || ! $destination->relationLoaded('server')) { + return; + } + + $server = $destination->getRelation('server'); + } + + if (! $server) { + return; + } + + $server->makeHidden([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + + if ($loadRelations || $server->relationLoaded('settings')) { + $server->settings->makeHidden([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + */ + private function exposeNestedServerSecrets(Model $model): void + { + $server = $model->destination?->server; + if ($server === null) { + return; + } + + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + + if ($server->settings !== null) { + $server->settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + #[OA\Get( summary: 'List', description: 'List all databases.', @@ -85,8 +181,12 @@ public function databases(Request $request) } $projects = Project::where('team_id', $teamId)->get(); $databases = collect(); + $databaseRelations = $request->attributes->get('can_read_sensitive', false) === true + ? ['destination.server.settings'] + : []; + foreach ($projects as $project) { - $databases = $databases->merge($project->databases()); + $databases = $databases->merge($project->databases($databaseRelations)); } $databaseIds = $databases->pluck('id')->toArray(); @@ -228,7 +328,7 @@ public function database_by_uuid(Request $request) $this->authorize('view', $database); - return response()->json($this->removeSensitiveData($database)); + return response()->json($this->removeSensitiveData($database, loadNestedServerSecrets: true)); } #[OA\Patch( @@ -1132,6 +1232,7 @@ public function update_backup(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1200,6 +1301,7 @@ public function create_database_postgresql(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1267,6 +1369,7 @@ public function create_database_clickhouse(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1335,6 +1438,7 @@ public function create_database_dragonfly(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1403,6 +1507,7 @@ public function create_database_redis(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1474,6 +1579,7 @@ public function create_database_keydb(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1545,6 +1651,7 @@ public function create_database_mariadb(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1613,6 +1720,7 @@ public function create_database_mysql(Request $request) 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'], ], ), ) @@ -1643,7 +1751,7 @@ public function create_database_mongodb(Request $request) public function create_database(Request $request, NewDatabaseTypes $type) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1742,6 +1850,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', 'instant_deploy' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]); if ($validator->failed()) { return response()->json([ @@ -1749,6 +1859,13 @@ public function create_database(Request $request, NewDatabaseTypes $type) 'errors' => $validator->errors(), ], 422); } + $return = $this->validateTagsParameter($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $tagNames = $request->input('tags') ?? []; + if ($request->public_port) { if ($request->public_port < 1024 || $request->public_port > 65535) { return response()->json([ @@ -1760,7 +1877,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } } if ($type === NewDatabaseTypes::POSTGRESQL) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false), 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false), @@ -1808,6 +1925,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ 'uuid' => $database->uuid, @@ -1829,7 +1949,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MARIADB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'tags']; $validator = customApiValidator($request->all(), [ 'mariadb_conf' => 'string', 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false), @@ -1876,6 +1996,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -1898,7 +2021,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MYSQL) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false), 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false), @@ -1945,6 +2068,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -1967,7 +2093,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::REDIS) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'redis_password' => ValidationPatterns::databasePasswordRules(required: false), 'redis_conf' => 'string', @@ -2011,6 +2137,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -2033,7 +2162,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::DRAGONFLY) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password', 'tags']; $validator = customApiValidator($request->all(), [ 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false), ]); @@ -2058,12 +2187,15 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } return response()->json(serializeApiResponse([ 'uuid' => $database->uuid, ]))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::KEYDB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf', 'tags']; $validator = customApiValidator($request->all(), [ 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false), 'keydb_conf' => 'string', @@ -2107,6 +2239,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -2129,7 +2264,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::CLICKHOUSE) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password', 'tags']; $validator = customApiValidator($request->all(), [ 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false), 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false), @@ -2153,6 +2288,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -2175,7 +2313,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(serializeApiResponse($payload))->setStatusCode(201); } elseif ($type === NewDatabaseTypes::MONGODB) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'tags']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false), @@ -2221,6 +2359,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -2247,6 +2388,116 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(['message' => 'Invalid database type requested.'], 400); } + #[OA\Get( + summary: 'Get database logs.', + description: 'Get database logs by UUID.', + path: '/databases/{uuid}/logs', + operationId: 'get-database-logs-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema( + type: 'integer', + format: 'int32', + default: 100, + ) + ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get database logs by UUID.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => ['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 logs_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $containers = getCurrentDatabaseContainerStatus($database->destination->server, $database->id); + + if ($containers->count() == 0) { + return response()->json([ + 'message' => 'Database is not running.', + ], 400); + } + + $container = $containers->first(); + + $status = getContainerStatus($database->destination->server, $container['Names']); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Database is not running.', + ], 400); + } + + $lines = normalizeLogLines($request->query('lines')); + $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); + $logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps); + + return response()->json([ + 'logs' => $logs, + ]); + } + #[OA\Delete( summary: 'Delete', description: 'Delete database by UUID.', @@ -2692,6 +2943,99 @@ public function list_backup_executions(Request $request) ]); } + #[OA\Post( + summary: 'Move', + description: 'Move database to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.', + path: '/databases/{uuid}/move', + operationId: 'move-database-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the database to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Database moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Database moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['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', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + return moveResourceToEnvironment($request, $database, 'Database', $teamId); + } + #[OA\Get( summary: 'Start', description: 'Start database. `Post` request is also accepted.', @@ -2970,8 +3314,8 @@ private function removeSensitiveEnvData($env) 'resourceable_id', 'resourceable_type', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $env->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $env->makeVisible([ 'value', 'real_value', ]); @@ -3611,6 +3955,7 @@ public function storages(Request $request): JsonResponse $persistentStorages = $database->persistentStorages->sortBy('id')->values(); $fileStorages = $database->fileStorages->sortBy('id')->values(); + $fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage)); return response()->json([ 'persistent_storages' => $persistentStorages, @@ -3849,7 +4194,7 @@ public function create_storage(Request $request): JsonResponse 'mount_path' => $storage->mount_path, ]); - return response()->json($storage, 201); + return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201); } #[OA\Patch( @@ -4056,7 +4401,7 @@ public function update_storage(Request $request): JsonResponse 'mount_path' => $storage->mount_path ?? null, ]); - return response()->json($storage); + return response()->json($this->exposeFileStorageContentIfAllowed($storage)); } #[OA\Delete( @@ -4143,4 +4488,148 @@ public function delete_storage(Request $request): JsonResponse return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Get( + summary: 'List Tags', + description: 'List tags for a database by UUID.', + path: '/databases/{uuid}/tags', + operationId: 'list-tags-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of tags.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + 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 tags(Request $request): JsonResponse + { + return $this->listTags($request); + } + + #[OA\Post( + summary: 'Create Tag', + description: 'Add tag(s) to a database by UUID.', + path: '/databases/{uuid}/tags', + operationId: 'create-tag-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'], + 'tag_names' => [ + 'type' => 'array', + 'items' => new OA\Items(type: 'string'), + 'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.', + ], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tags added successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + 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'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_tag(Request $request): JsonResponse + { + return $this->createTag($request); + } + + #[OA\Delete( + summary: 'Delete Tag', + description: 'Remove a tag from a database by UUID.', + path: '/databases/{uuid}/tags/{tag_uuid}', + operationId: 'delete-tag-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'tag_uuid', + in: 'path', + description: 'UUID of the tag.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag removed.', + ), + 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 delete_tag(Request $request): JsonResponse + { + return $this->deleteTag($request); + } } diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index f0cf48efa..34f47d289 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -24,6 +24,10 @@ private function removeSensitiveData($deployment) $deployment->makeHidden([ 'logs', ]); + } else { + $deployment->makeVisible([ + 'logs', + ]); } return serializeApiResponse($deployment); @@ -365,7 +369,7 @@ public function deploy(Request $request) $uuids = $request->input('uuid'); $tags = $request->input('tag'); - $force = $request->input('force') ?? false; + $force = $request->boolean('force'); $pullRequestId = $request->input('pull_request_id', $request->input('pr')); $pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0; $dockerTag = $request->string('docker_tag')->trim()->value() ?: null; @@ -425,7 +429,7 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p } ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result; if ($deployment_uuid) { - $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]); + $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid]); } else { $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid]); } @@ -471,7 +475,7 @@ public function by_tags(string $tags, int $team_id, bool $force = false) } ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result; if ($deployment_uuid) { - $deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]); + $deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid]); } $message = $message->merge($return_message); } @@ -529,7 +533,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st 'resource_type' => 'application', 'application_uuid' => $resource->uuid, 'application_name' => $resource->name, - 'deployment_uuid' => $deployment_uuid?->toString(), + 'deployment_uuid' => $deployment_uuid, 'force_rebuild' => $force, 'pull_request_id' => $pr, ]); @@ -698,6 +702,9 @@ public function get_application_deployments(Request $request) $this->authorize('view', $application); $deployments = $application->deployments($skip, $take); + if ($request->attributes->get('can_read_sensitive', false) === true) { + $deployments['deployments']->each->makeVisible(['logs']); + } return response()->json($deployments); } diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php new file mode 100644 index 000000000..a745ea5d2 --- /dev/null +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -0,0 +1,356 @@ + $destination->uuid, + 'name' => $destination->name, + 'network' => $destination->network, + 'type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone', + 'server_uuid' => $destination->server?->uuid, + 'created_at' => $destination->created_at, + 'updated_at' => $destination->updated_at, + ]; + } + + /** + * Resolve the calling token's team id, or return an invalid-token response. + */ + private function teamIdOrAbort(): int|JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + return $teamId; + } + + /** + * StandaloneDocker / SwarmDocker scoped to a team via their parent server. + * Uses whereHas instead of the model's ownedByCurrentTeamAPI() scope so the + * controller works on Coolify versions that pre-date that scope being added + * to the destination models (e.g. 4.0.0-beta.470). + */ + private function teamScopedDockers(int $teamId): array + { + return [ + 'standalone' => StandaloneDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(), + 'swarm' => SwarmDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(), + ]; + } + + private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDocker|SwarmDocker + { + return StandaloneDocker::with('server:id,uuid,team_id,ip,user,port,private_key_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->first() + ?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail(); + } + + #[OA\Get( + summary: 'List destinations', + description: 'List all Docker network destinations for the authenticated team.', + path: '/destinations', + operationId: 'list-destinations', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + responses: [ + new OA\Response( + response: 200, + description: 'Destinations for the authenticated team.', + content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + ], + )] + public function index(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + $sets = $this->teamScopedDockers($teamId); + + return response()->json( + $sets['standalone']->concat($sets['swarm']) + ->map(fn ($destination) => $this->transform($destination)) + ->values() + ); + } + + #[OA\Get( + summary: 'List destinations by server', + description: 'List Docker network destinations attached to a server owned by the authenticated team.', + path: '/servers/{server_uuid}/destinations', + operationId: 'list-server-destinations', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Destinations attached to the server.', + content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] + public function index_by_server(Request $request, string $server_uuid): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + $server = Server::with(['standaloneDockers.server:id,uuid', 'swarmDockers.server:id,uuid']) + ->whereTeamId($teamId) + ->whereUuid($server_uuid) + ->firstOrFail(); + $list = $server->standaloneDockers->concat($server->swarmDockers); + + return response()->json($list->map(fn ($destination) => $this->transform($destination))->values()); + } + + #[OA\Get( + summary: 'Get destination', + description: 'Get a Docker network destination by UUID.', + path: '/destinations/{uuid}', + operationId: 'get-destination-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Destination details.', + content: new OA\JsonContent(ref: '#/components/schemas/Destination'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] + public function show(Request $request, string $uuid): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + $destination = $this->findDestinationForTeam($teamId, $uuid); + + return response()->json($this->transform($destination)); + } + + #[OA\Post( + summary: 'Create destination', + description: 'Create a Docker network destination on a server owned by the authenticated team.', + path: '/servers/{server_uuid}/destinations', + operationId: 'create-server-destination', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['network'], + properties: [ + new OA\Property(property: 'name', type: 'string', maxLength: 255), + new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'), + new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']), + ], + type: 'object', + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Destination created.', + content: new OA\JsonContent(ref: '#/components/schemas/Destination'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'A destination with this network already exists.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function create(Request $request, string $server_uuid): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail(); + + $allowed = ['name', 'network', 'type']; + + $validator = customApiValidator($request->all(), [ + 'name' => 'nullable|string|max:255', + 'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'], + 'type' => 'nullable|in:standalone,swarm', + ]); + $extra = array_diff(array_keys($request->all()), $allowed); + if ($validator->fails() || ! empty($extra)) { + $errors = $validator->errors(); + if (! empty($extra)) { + foreach ($extra as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + + $expectedType = $server->isSwarm() ? 'swarm' : 'standalone'; + $type = $request->input('type', $expectedType); + if ($type !== $expectedType) { + return response()->json(['message' => "Destination type must be {$expectedType} for this server."], 422); + } + + $name = $request->input('name') ?: ($server->name.'-'.$request->input('network')); + $class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class; + + $this->authorize('create', $class); + + $exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists(); + if ($exists) { + return response()->json(['message' => 'A destination with this network already exists on the server.'], 409); + } + + try { + $destination = $class::create([ + 'name' => $name, + 'network' => $request->input('network'), + 'server_id' => $server->id, + ]); + } catch (QueryException $exception) { + if ($this->isUniqueConstraintViolation($exception)) { + return response()->json(['message' => 'A destination with this network already exists on the server.'], 409); + } + + throw $exception; + } + + auditLog('api.destination.created', [ + 'team_id' => $teamId, + 'destination_uuid' => $destination->uuid, + 'destination_name' => $destination->name, + 'destination_type' => $type, + 'server_uuid' => $server->uuid, + ]); + + return response()->json($this->transform($destination->load('server:id,uuid')), 201); + } + + private function isUniqueConstraintViolation(QueryException $exception): bool + { + $sqlState = $exception->errorInfo[0] ?? null; + $driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode()); + + return in_array($sqlState, ['23000', '23505'], true) + || in_array($driverCode, ['19', '1062', '2067'], true); + } + + #[OA\Delete( + summary: 'Delete destination', + description: 'Delete an unused Docker network destination.', + path: '/destinations/{uuid}', + operationId: 'delete-destination-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Destination deleted.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Deleted.'), + ], + type: 'object', + ), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Destination has attached resources.'), + ], + )] + public function delete(Request $request, string $uuid): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + $destination = $this->findDestinationForTeam($teamId, $uuid); + + $this->authorize('delete', $destination); + + // Guard against deleting destinations with attached resources. attachedTo() + // is recent on the destination models; fall back to a manual check for + // older Coolify versions (e.g. 4.0.0-beta.470). + if (method_exists($destination, 'attachedTo')) { + if ($destination->attachedTo()) { + return response()->json(['message' => 'Destination has attached resources, detach first.'], 409); + } + } else { + $hasAttached = $destination->applications()->exists() + || $destination->postgresqls()->exists() + || (method_exists($destination, 'mysqls') && $destination->mysqls()->exists()) + || (method_exists($destination, 'mariadbs') && $destination->mariadbs()->exists()) + || (method_exists($destination, 'mongodbs') && $destination->mongodbs()->exists()) + || (method_exists($destination, 'redis') && $destination->redis()->exists()) + || (method_exists($destination, 'keydbs') && $destination->keydbs()->exists()) + || (method_exists($destination, 'dragonflies') && $destination->dragonflies()->exists()) + || (method_exists($destination, 'clickhouses') && $destination->clickhouses()->exists()) + || (method_exists($destination, 'services') && $destination->services()->exists()); + if ($hasAttached) { + return response()->json(['message' => 'Destination has attached resources, detach first.'], 409); + } + } + if ($destination instanceof StandaloneDocker) { + app(RemoveStandaloneDockerNetwork::class)->handle($destination); + } + + $destinationUuid = $destination->uuid; + $destinationName = $destination->name; + $destinationType = $destination instanceof SwarmDocker ? 'swarm' : 'standalone'; + $serverUuid = $destination->server?->uuid; + + $destination->delete(); + + auditLog('api.destination.deleted', [ + 'team_id' => $teamId, + 'destination_uuid' => $destinationUuid, + 'destination_name' => $destinationName, + 'destination_type' => $destinationType, + 'server_uuid' => $serverUuid, + ]); + + return response()->json(['message' => 'Deleted.']); + } +} diff --git a/app/Http/Controllers/Api/DigitalOceanController.php b/app/Http/Controllers/Api/DigitalOceanController.php new file mode 100644 index 000000000..5bd9d2392 --- /dev/null +++ b/app/Http/Controllers/Api/DigitalOceanController.php @@ -0,0 +1,416 @@ +cloud_provider_token_uuid ?? $request->cloud_provider_token_id; + } + + private function digitalOceanToken(Request $request, int $teamId): CloudProviderToken|JsonResponse + { + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($this->getCloudProviderTokenUuid($request)) + ->where('provider', 'digitalocean') + ->first(); + + if (! $token) { + return response()->json(['message' => 'DigitalOcean cloud provider token not found.'], 404); + } + + $this->authorize('view', $token); + + return $token; + } + + #[OA\Get( + path: '/digitalocean/regions', + operationId: 'get-digitalocean-regions', + summary: 'Get DigitalOcean regions', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + parameters: [ + new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'List of DigitalOcean regions.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + ] + )] + public function regions(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new DigitalOceanService($token->token))->getRegions()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch DigitalOcean regions.'], 500); + } + } + + #[OA\Get( + path: '/digitalocean/sizes', + operationId: 'get-digitalocean-sizes', + summary: 'Get DigitalOcean sizes', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + parameters: [ + new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'List of DigitalOcean sizes.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + ] + )] + public function sizes(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new DigitalOceanService($token->token))->getSizes()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch DigitalOcean sizes.'], 500); + } + } + + #[OA\Get( + path: '/digitalocean/images', + operationId: 'get-digitalocean-images', + summary: 'Get DigitalOcean images', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + parameters: [ + new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'List of DigitalOcean images.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + ] + )] + public function images(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new DigitalOceanService($token->token))->getImages()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch DigitalOcean images.'], 500); + } + } + + #[OA\Get( + path: '/digitalocean/ssh-keys', + operationId: 'get-digitalocean-ssh-keys', + summary: 'Get DigitalOcean SSH keys', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + parameters: [ + new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'List of DigitalOcean SSH keys.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + ] + )] + public function sshKeys(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new DigitalOceanService($token->token))->getSshKeys()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch DigitalOcean SSH keys.'], 500); + } + } + + #[OA\Post( + path: '/servers/digitalocean', + operationId: 'create-digitalocean-server', + summary: 'Create a server on DigitalOcean', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + responses: [ + new OA\Response(response: 201, description: 'DigitalOcean droplet created and linked to a Coolify server.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'DigitalOcean rate limit exceeded.'), + ] + )] + public function createServer(Request $request): JsonResponse + { + $allowedFields = [ + 'cloud_provider_token_uuid', + 'cloud_provider_token_id', + 'region', + 'size', + 'image', + 'name', + 'private_key_uuid', + 'enable_ipv6', + 'monitoring', + 'digitalocean_ssh_key_ids', + 'cloud_init_script', + 'instant_validate', + ]; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $this->authorize('create', [Server::class]); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + 'region' => 'required|string', + 'size' => 'required|string', + 'image' => 'required', + 'name' => ['nullable', 'string', 'max:253', new ValidHostname], + 'private_key_uuid' => 'required|string', + 'enable_ipv6' => 'nullable|boolean', + 'monitoring' => 'nullable|boolean', + 'digitalocean_ssh_key_ids' => 'nullable|array', + 'digitalocean_ssh_key_ids.*' => 'integer', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'instant_validate' => 'nullable|boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $team = Team::find($teamId); + if (Team::serverLimitReached($team)) { + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); + } + + $request->offsetSet('name', $request->name ?: generate_random_name()); + $request->offsetSet('enable_ipv6', $request->boolean('enable_ipv6', true)); + $request->offsetSet('monitoring', $request->boolean('monitoring', true)); + $request->offsetSet('digitalocean_ssh_key_ids', $request->digitalocean_ssh_key_ids ?? []); + $request->offsetSet('instant_validate', $request->boolean('instant_validate', false)); + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); + if (! $privateKey) { + return response()->json(['message' => 'Private key not found.'], 404); + } + + $digitalOceanService = null; + $dropletId = null; + $server = null; + + try { + $digitalOceanService = new DigitalOceanService($token->token); + $sshKeyId = $this->getOrCreateSshKey($digitalOceanService, $privateKey); + + $sshKeys = array_values(array_unique(array_merge( + [$sshKeyId], + $request->digitalocean_ssh_key_ids + ))); + + $normalizedServerName = strtolower(trim($request->name)); + $params = [ + 'name' => $normalizedServerName, + 'region' => $request->region, + 'size' => $request->size, + 'image' => $request->image, + 'ssh_keys' => $sshKeys, + 'ipv6' => $request->enable_ipv6, + 'monitoring' => $request->monitoring, + ]; + + if (! empty($request->cloud_init_script)) { + $params['user_data'] = $request->cloud_init_script; + } + + $droplet = $digitalOceanService->createDroplet($params); + $dropletId = (int) $droplet['id']; + + $server = DB::transaction(function () use ($normalizedServerName, $teamId, $privateKey, $token, $dropletId, $droplet): Server { + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => Server::PLACEHOLDER_IP, + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'digitalocean_droplet_id' => $dropletId, + 'digitalocean_droplet_status' => $droplet['status'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return $server; + }); + + try { + $droplet = $digitalOceanService->waitForPublicIp($droplet, true, $request->enable_ipv6); + $ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $request->enable_ipv6); + + if ($ipAddress) { + $server->update([ + 'ip' => $ipAddress, + 'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status, + ]); + } + } catch (\Throwable $e) { + report($e); + } + + if ($request->instant_validate) { + ValidateServer::dispatch($server); + } + + auditLog('api.digitalocean_droplet.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'digitalocean_droplet_id' => $dropletId, + 'ip' => $server->ip, + ]); + + return response()->json([ + 'uuid' => $server->uuid, + 'digitalocean_droplet_id' => $dropletId, + 'ip' => $server->ip, + ])->setStatusCode(201); + } catch (RateLimitException $e) { + $this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server); + + $response = response()->json(['message' => $e->getMessage()], 429); + if ($e->retryAfter !== null) { + $response->header('Retry-After', $e->retryAfter); + } + + return $response; + } catch (\Throwable $e) { + $this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server); + + logger()->error('Failed to create DigitalOcean server', [ + 'error' => $e->getMessage(), + ]); + + return response()->json(['message' => 'Failed to create DigitalOcean server.'], 500); + } + } + + private function deleteUntrackedDroplet(?DigitalOceanService $digitalOceanService, ?int $dropletId, ?Server $server): void + { + if (! $digitalOceanService || ! $dropletId || $server) { + return; + } + + try { + $digitalOceanService->deleteDroplet($dropletId); + } catch (\Throwable $e) { + report($e); + } + } + + private function getOrCreateSshKey(DigitalOceanService $digitalOceanService, PrivateKey $privateKey): int + { + $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); + + foreach ($digitalOceanService->getSshKeys() as $key) { + if (($key['fingerprint'] ?? null) === $md5Fingerprint) { + return (int) $key['id']; + } + } + + $uploadedKey = $digitalOceanService->uploadSshKey($privateKey->name, $privateKey->getPublicKey()); + + return (int) $uploadedKey['id']; + } +} diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 150743f99..5c073e9c0 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -17,10 +17,17 @@ class GithubController extends Controller { private function removeSensitiveData($githubApp) { - $githubApp->makeHidden([ - 'client_secret', - 'webhook_secret', - ]); + if (request()->attributes->get('can_read_sensitive', false) === true) { + $githubApp->makeVisible([ + 'client_secret', + 'webhook_secret', + ]); + } else { + $githubApp->makeHidden([ + 'client_secret', + 'webhook_secret', + ]); + } return serializeApiResponse($githubApp); } @@ -129,7 +136,7 @@ public function list_github_apps(Request $request) 'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'], 'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'], ], - required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'], + required: ['name', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'], ), ), ], @@ -205,10 +212,14 @@ public function create_github_app(Request $request) 'is_system_wide', ]; + $request->merge([ + 'organization' => normalizeGithubOrganization($request->input('organization')), + ]); + $validator = customApiValidator($request->all(), [ 'name' => 'required|string|max:255', - 'organization' => 'nullable|string|max:255', - 'api_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'], + 'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl], 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], 'custom_user' => 'nullable|string|max:255', 'custom_port' => 'nullable|integer|min:1|max:65535', @@ -252,7 +263,9 @@ public function create_github_app(Request $request) 'uuid' => Str::uuid(), 'name' => $request->input('name'), 'organization' => $request->input('organization'), - 'api_url' => $request->input('api_url'), + 'api_url' => filled($request->input('api_url')) + ? $request->input('api_url') + : githubApiUrlFromHtmlUrl($request->input('html_url')), 'html_url' => $request->input('html_url'), 'custom_user' => $request->input('custom_user', 'git'), 'custom_port' => $request->input('custom_port', 22), @@ -589,13 +602,17 @@ public function update_github_app(Request $request, $github_app_id) $payload = $request->only($allowedFields); + if (array_key_exists('organization', $payload)) { + $payload['organization'] = normalizeGithubOrganization($payload['organization']); + } + // Validate the request $rules = []; if (isset($payload['name'])) { $rules['name'] = 'string'; } if (isset($payload['organization'])) { - $rules['organization'] = 'nullable|string'; + $rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/']; } if (isset($payload['api_url'])) { $rules['api_url'] = ['url', new SafeExternalUrl]; @@ -639,6 +656,13 @@ public function update_github_app(Request $request, $github_app_id) ], 422); } + if (array_key_exists('organization', $payload)) { + $payload['organization'] = normalizeGithubOrganization($payload['organization']); + } + if (isset($payload['html_url']) && ! filled($payload['api_url'] ?? null)) { + $payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']); + } + // Handle private_key_uuid -> private_key_id conversion if (isset($payload['private_key_uuid'])) { $privateKey = PrivateKey::where('team_id', $teamId) diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 1c9d6f9ef..4cadc0eb6 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -460,6 +460,195 @@ public function sshKeys(Request $request) } } + #[OA\Get( + summary: 'Get Hetzner Firewalls', + description: 'Get all existing Hetzner firewalls for the current project.', + path: '/hetzner/firewalls', + operationId: 'get-hetzner-firewalls', + security: [ + ['bearerAuth' => []], + ], + tags: ['Hetzner'], + parameters: [ + new OA\Parameter( + name: 'cloud_provider_token_uuid', + in: 'query', + required: false, + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'cloud_provider_token_id', + in: 'query', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of Hetzner firewalls.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + ] + ) + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function firewalls(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $tokenUuid = $this->getCloudProviderTokenUuid($request); + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); + } + $this->authorize('view', $token); + + try { + $hetznerService = new HetznerService($token->token); + + return response()->json($hetznerService->getFirewalls()); + } catch (\Throwable $e) { + return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500); + } + } + + #[OA\Get( + summary: 'Get Hetzner Networks', + description: 'Get all existing Hetzner private networks for the current project.', + path: '/hetzner/networks', + operationId: 'get-hetzner-networks', + security: [ + ['bearerAuth' => []], + ], + tags: ['Hetzner'], + parameters: [ + new OA\Parameter( + name: 'cloud_provider_token_uuid', + in: 'query', + required: false, + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'cloud_provider_token_id', + in: 'query', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of Hetzner networks.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'ip_range' => ['type' => 'string'], + ] + ) + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function networks(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $tokenUuid = $this->getCloudProviderTokenUuid($request); + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); + } + $this->authorize('view', $token); + + try { + $hetznerService = new HetznerService($token->token); + + return response()->json($hetznerService->getNetworks()); + } catch (\Throwable $e) { + return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500); + } + } + #[OA\Post( summary: 'Create Hetzner Server', description: 'Create a new server on Hetzner and register it in Coolify.', @@ -487,7 +676,10 @@ public function sshKeys(Request $request) 'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'], 'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'], 'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'], + 'enable_backups' => ['type' => 'boolean', 'example' => false, 'description' => 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'], 'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'], + 'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'], + 'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'], 'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'], 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'], ], @@ -545,7 +737,10 @@ public function createServer(Request $request) 'private_key_uuid', 'enable_ipv4', 'enable_ipv6', + 'enable_backups', 'hetzner_ssh_key_ids', + 'hetzner_firewall_ids', + 'hetzner_network_ids', 'cloud_init_script', 'instant_validate', ]; @@ -571,8 +766,13 @@ public function createServer(Request $request) 'private_key_uuid' => 'required|string', 'enable_ipv4' => 'nullable|boolean', 'enable_ipv6' => 'nullable|boolean', + 'enable_backups' => 'nullable|boolean', 'hetzner_ssh_key_ids' => 'nullable|array', 'hetzner_ssh_key_ids.*' => 'integer', + 'hetzner_firewall_ids' => 'nullable|array', + 'hetzner_firewall_ids.*' => 'integer', + 'hetzner_network_ids' => 'nullable|array', + 'hetzner_network_ids.*' => 'integer', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'instant_validate' => 'nullable|boolean', ]); @@ -608,13 +808,32 @@ public function createServer(Request $request) if (is_null($request->enable_ipv6)) { $request->offsetSet('enable_ipv6', true); } + if (is_null($request->enable_backups)) { + $request->offsetSet('enable_backups', false); + } if (is_null($request->hetzner_ssh_key_ids)) { $request->offsetSet('hetzner_ssh_key_ids', []); } + if (is_null($request->hetzner_firewall_ids)) { + $request->offsetSet('hetzner_firewall_ids', []); + } + if (is_null($request->hetzner_network_ids)) { + $request->offsetSet('hetzner_network_ids', []); + } if (is_null($request->instant_validate)) { $request->offsetSet('instant_validate', false); } + if (! $request->boolean('enable_ipv4') && ! $request->boolean('enable_ipv6')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'enable_ipv4' => ['Enable at least one public IP protocol.'], + 'enable_ipv6' => ['Enable at least one public IP protocol.'], + ], + ], 422); + } + // Validate cloud provider token $tokenUuid = $this->getCloudProviderTokenUuid($request); $token = CloudProviderToken::whereTeamId($teamId) @@ -687,6 +906,18 @@ public function createServer(Request $request) ], ]; + $firewallIds = array_values(array_unique($request->hetzner_firewall_ids)); + if ($firewallIds !== []) { + $params['firewalls'] = array_map(function (int $firewallId): array { + return ['firewall' => $firewallId]; + }, $firewallIds); + } + + $networkIds = array_values(array_unique($request->hetzner_network_ids)); + if ($networkIds !== []) { + $params['networks'] = $networkIds; + } + // Add cloud-init script if provided if (! empty($request->cloud_init_script)) { $params['user_data'] = $request->cloud_init_script; @@ -723,6 +954,14 @@ public function createServer(Request $request) $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + if ($request->enable_backups) { + try { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } catch (\Throwable $e) { + report($e); + } + } + // Validate server if requested if ($request->instant_validate) { ValidateServer::dispatch($server); diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 92f19c7ae..ea3b54e80 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -166,6 +166,9 @@ public function environment_details(Request $request) return response()->json(['message' => 'Environment not found.'], 404); } $environment = $environment->load(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']); + collect(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']) + ->flatMap(fn (string $relation) => $environment->{$relation}) + ->each(fn ($resource) => exposeSensitiveFields($resource)); return response()->json(serializeApiResponse($environment)); } diff --git a/app/Http/Controllers/Api/ResourcesController.php b/app/Http/Controllers/Api/ResourcesController.php index d5dc4a046..53d542d44 100644 --- a/app/Http/Controllers/Api/ResourcesController.php +++ b/app/Http/Controllers/Api/ResourcesController.php @@ -56,6 +56,7 @@ public function resources(Request $request) } $resources = $resources->flatten(); $resources = $resources->map(function ($resource) { + exposeSensitiveFields($resource); $payload = $resource->toArray(); $payload['status'] = $resource->status; $payload['type'] = $resource->type(); diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index 0559bcd99..25430631b 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -16,6 +16,10 @@ private function removeSensitiveData($team) $team->makeHidden([ 'private_key', ]); + } else { + $team->makeVisible([ + 'private_key', + ]); } return serializeApiResponse($team); diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index 3af05f4fa..b3685daa4 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -97,11 +97,6 @@ public function push(Request $request) if ($this->shouldDispatchUpdate($server, $data)) { PushServerUpdateJob::dispatch($server, $data); - - auditLog('sentinel.metrics_pushed', [ - 'server_uuid' => $server->uuid, - 'team_id' => $server->team_id, - ]); } return response()->json(['message' => 'ok'], 200); @@ -148,8 +143,9 @@ private function shouldDispatchUpdate(Server $server, array $data): bool * health checks can flap between starting/healthy/unhealthy while the * container lifecycle state remains unchanged. Both would otherwise defeat * the hash and dispatch DB-heavy PushServerUpdateJob instances too often. - * The force window still refreshes full state periodically. Sorted by name - * so container ordering from Sentinel does not affect the hash. + * The snapshot completeness flag is included so a complete snapshot always + * dispatches after a partial snapshot. Sorted by name so container ordering + * from Sentinel does not affect the hash. */ private function containerStateHash(array $data): string { @@ -162,6 +158,14 @@ private function containerStateHash(array $data): string ->values() ->all(); - return hash('xxh128', json_encode($containers)); + return hash('xxh128', json_encode([ + 'snapshot_complete' => $this->isCompleteSnapshot($data), + 'containers' => $containers, + ])); + } + + private function isCompleteSnapshot(array $data): bool + { + return data_get($data, 'snapshot.complete', true) !== false; } } diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index f4efc8577..fa0017f87 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -23,9 +23,14 @@ class ServersController extends Controller { private function removeSensitiveDataFromSettings($settings) { - if (request()->attributes->get('can_read_sensitive', false) === false) { - $settings = $settings->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $settings = $settings->makeVisible([ 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', ]); } @@ -37,8 +42,11 @@ private function removeSensitiveData($server) $server->makeHidden([ 'id', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - // Do nothing + if (request()->attributes->get('can_read_sensitive', false) === true) { + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); } return serializeApiResponse($server); @@ -854,7 +862,11 @@ public function delete_server(Request $request) false, // Don't delete from Hetzner via API $server->hetzner_server_id, $server->cloud_provider_token_id, - $server->team_id + $server->team_id, + false, // Don't delete from Vultr via API + $server->vultr_instance_id, + false, // Don't delete from DigitalOcean via API + $server->digitalocean_droplet_id ); auditLog('api.server.deleted', [ diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php new file mode 100644 index 000000000..a144b3ea6 --- /dev/null +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -0,0 +1,766 @@ +makeHidden([ + 'id', + 'resourceable', + 'resourceable_id', + 'resourceable_type', + ]); + + $serialized = serializeApiResponse($serviceApplication); + + if ($serialized instanceof Collection) { + return $serialized->all(); + } + + return (array) $serialized; + } + + private function resolveService(Request $request, int $teamId): ?Service + { + $uuid = $request->route('uuid'); + if (! $uuid) { + return null; + } + + return Service::whereRelation('environment.project.team', 'id', $teamId) + ->whereUuid($uuid) + ->first(); + } + + private function resolveServiceApplicationForService(Request $request, Service $service): ?ServiceApplication + { + $appUuid = $request->route('app_uuid'); + if (! $appUuid) { + return null; + } + + return $service->applications() + ->where('uuid', $appUuid) + ->with(['service.destination.server']) + ->first(); + } + + private function swarmNotSupportedResponse(): JsonResponse + { + return response()->json([ + 'message' => 'This operation is not supported for Swarm servers yet.', + ], 501); + } + + #[OA\Get( + summary: 'List service applications', + description: 'List compose service applications (containers) for a single service.', + path: '/services/{uuid}/applications', + operationId: 'list-service-applications-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Service applications for this service.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(type: 'object') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function index(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('view', $service); + + $items = $service->applications() + ->get() + ->map(fn (ServiceApplication $sa) => $this->removeSensitiveData($sa)); + + return response()->json($items); + } + + #[OA\Get( + summary: 'Get service application', + description: 'Get a single compose service application by service UUID and application UUID.', + path: '/services/{uuid}/applications/{app_uuid}', + operationId: 'get-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Service application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(type: 'object') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function show(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('view', $serviceApplication); + + return response()->json($this->removeSensitiveData($serviceApplication)); + } + + #[OA\Patch( + summary: 'Update service application', + description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).', + path: '/services/{uuid}/applications/{app_uuid}', + operationId: 'patch-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'force_domain_override', + in: 'query', + description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + requestBody: new OA\RequestBody( + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'url' => new OA\Property( + property: 'url', + type: 'string', + nullable: true, + description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.' + ), + 'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true), + 'description' => new OA\Property(property: 'description', type: 'string', nullable: true), + 'image' => new OA\Property(property: 'image', type: 'string', nullable: true), + 'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true), + 'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true), + 'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true), + 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), + ] + ) + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Updated service application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(type: 'object') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 409, + description: 'Domain conflicts (unless force_domain_override).', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update(Request $request, UpdateServiceApplicationFromApi $updateServiceApplicationFromApi): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('update', $serviceApplication); + + $payload = $request->json()->all(); + if (empty($payload)) { + $payload = $request->request->all(); + } + + $allowedFields = [ + 'url', + 'human_name', + 'description', + 'image', + 'exclude_from_status', + 'is_log_drain_enabled', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + ]; + + $validationRules = [ + 'url' => 'nullable|string', + 'human_name' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'image' => 'nullable|string', + 'exclude_from_status' => 'sometimes|boolean', + 'is_log_drain_enabled' => 'sometimes|boolean', + 'is_gzip_enabled' => 'sometimes|boolean', + 'is_stripprefix_enabled' => 'sometimes|boolean', + ]; + + $validator = Validator::make($payload, $validationRules); + + $extraFields = array_diff(array_keys($payload), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId, $payload); + if ($response instanceof JsonResponse) { + return $response; + } + + $serviceApplication->refresh(); + + return response()->json($this->removeSensitiveData($serviceApplication)); + } + + #[OA\Get( + summary: 'Get service application logs', + description: 'Get Docker logs for a single compose service container.', + path: '/services/{uuid}/applications/{app_uuid}/logs', + operationId: 'get-service-application-logs-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema(type: 'integer', format: 'int32', default: 100) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Logs.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => new OA\Property(property: 'logs', type: 'string'), + ] + ) + ), + ] + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 501, + description: 'Swarm not supported.', + ), + ] + )] + public function logs_by_uuid(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('view', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + $containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid; + + $status = getContainerStatus($server, $containerName); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Service application container is not running.', + ], 400); + } + + $lines = (int) ($request->query('lines', 100) ?: 100); + $logs = getContainerLogs($server, $containerName, $lines); + + return response()->json([ + 'logs' => $logs, + ]); + } + + #[OA\Get( + summary: 'Start or redeploy service application container', + description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.', + path: '/services/{uuid}/applications/{app_uuid}/start', + operationId: 'start-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'force', + in: 'query', + description: 'When true, passes --build to docker compose up.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + new OA\Parameter( + name: 'latest', + in: 'query', + description: 'When true, pulls the image for this compose service before up.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Deploy request queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 501, + description: 'Swarm not supported.', + ), + ] + )] + public function action_start(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + $pullLatest = $request->boolean('latest', false); + $forceRebuild = $request->boolean('force', false); + + DeployServiceApplication::dispatch($serviceApplication, $pullLatest, $forceRebuild); + + return response()->json([ + 'message' => 'Service application deploy request queued.', + ], 200); + } + + #[OA\Get( + summary: 'Restart service application container', + description: 'Restarts a single compose service container (docker restart).', + path: '/services/{uuid}/applications/{app_uuid}/restart', + operationId: 'restart-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Restart queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 501, + description: 'Swarm not supported.', + ), + ] + )] + public function action_restart(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + RestartServiceApplication::dispatch($serviceApplication); + + return response()->json([ + 'message' => 'Service application restart request queued.', + ], 200); + } + + #[OA\Get( + summary: 'Stop service application container', + description: 'Stops a single compose service container (docker stop).', + path: '/services/{uuid}/applications/{app_uuid}/stop', + operationId: 'stop-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Stop queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 501, + description: 'Swarm not supported.', + ), + ] + )] + public function action_stop(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + StopServiceApplication::dispatch($serviceApplication); + + return response()->json([ + 'message' => 'Service application stop request queued.', + ], 200); + } +} diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 6c121bcf8..bb45b672a 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -14,29 +14,57 @@ use App\Models\Server; use App\Models\Service; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; use Symfony\Component\Yaml\Yaml; class ServicesController extends Controller { + use Concerns\HandlesTagsApi; + + protected function findTaggableResource(string $uuid, int|string $teamId): mixed + { + return Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($uuid)->first(); + } + + protected function tagResourceNotFoundMessage(): string + { + return 'Service not found.'; + } + + private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume + { + if (request()->attributes->get('can_read_sensitive', false) === true) { + $storage->makeVisible(['content']); + } + + return $storage; + } + private function removeSensitiveData($service) { + if ($service instanceof Collection) { + return $service->map(fn (Service $item) => $this->removeSensitiveData($item)); + } + $service->makeHidden([ 'id', 'resourceable', 'resourceable_id', 'resourceable_type', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $service->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $service->makeVisible([ 'docker_compose_raw', 'docker_compose', 'value', 'real_value', ]); + $this->exposeNestedServerSecrets($service); } if ($service->is_shown_once ?? false) { @@ -46,6 +74,42 @@ private function removeSensitiveData($service) return serializeApiResponse($service); } + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + * Handles both single models and Eloquent Collections (the listing endpoint + * passes a Collection of Services per project to removeSensitiveData()). + */ + private function exposeNestedServerSecrets(Model|Collection $model): void + { + if ($model instanceof Collection) { + foreach ($model as $item) { + $this->exposeNestedServerSecrets($item); + } + + return; + } + $server = $model->destination?->server ?? $model->server ?? null; + if (! $server) { + return; + } + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + $settings = $server->settings ?? null; + if ($settings) { + $settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + private function applyServiceUrls(Service $service, array $urlsArray, string $teamId, bool $forceDomainOverride = false): ?array { $errors = []; @@ -60,19 +124,10 @@ private function applyServiceUrls(Service $service, array $urlsArray, string $te return str($urlValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter(); }); - $urls = $urls->map(function ($url) use (&$errors) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { - $errors[] = "Invalid URL: {$url}"; - - return $url; - } - $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; - if (! in_array(strtolower($scheme), ['http', 'https'])) { - $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; - } - - return $url; - }); + $errors = ValidationPatterns::validateApplicationDomains($urls->implode(',')); + $urls = collect(ValidationPatterns::applicationDomainList( + ValidationPatterns::normalizeApplicationDomains($urls->implode(',')) + )); $duplicates = $urls->duplicates()->unique()->values(); if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { @@ -101,10 +156,10 @@ private function applyServiceUrls(Service $service, array $urlsArray, string $te } if (filled($containerUrls)) { - $containerUrls = str($containerUrls)->replaceStart(',', '')->replaceEnd(',', '')->trim(); - $containerUrls = str($containerUrls)->explode(',')->map(fn ($url) => str(trim($url))->lower()); + $containerUrls = ValidationPatterns::normalizeApplicationDomains($containerUrls); + $containerUrlCollection = collect(ValidationPatterns::applicationDomainList($containerUrls)); - $result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $application->uuid); + $result = checkIfDomainIsAlreadyUsedViaAPI($containerUrlCollection, $teamId, $application->uuid); if (isset($result['error'])) { $errors[] = $result['error']; @@ -116,8 +171,6 @@ private function applyServiceUrls(Service $service, array $urlsArray, string $te return; } - - $containerUrls = $containerUrls->filter(fn ($u) => filled($u))->unique()->implode(','); } else { $containerUrls = null; } @@ -181,8 +234,12 @@ public function services(Request $request) } $projects = Project::where('team_id', $teamId)->get(); $services = collect(); + $serviceRelations = $request->attributes->get('can_read_sensitive', false) === true + ? ['destination.server.settings'] + : []; + foreach ($projects as $project) { - $services->push($project->services()->get()); + $services->push($project->services()->with($serviceRelations)->get()); } foreach ($services as $service) { $service = $this->removeSensitiveData($service); @@ -231,6 +288,7 @@ public function services(Request $request) ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'], ], ), ), @@ -297,7 +355,7 @@ public function services(Request $request) )] public function create_service(Request $request) { - $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -327,6 +385,8 @@ public function create_service(Request $request) 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -348,6 +408,11 @@ public function create_service(Request $request) ], 422); } + $return = $this->validateTagsParameter($request); + if ($return instanceof JsonResponse) { + return $return; + } + if (filled($request->type) && filled($request->docker_compose_raw)) { return response()->json([ 'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.', @@ -486,6 +551,10 @@ public function create_service(Request $request) } } + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -506,7 +575,7 @@ public function create_service(Request $request) return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); } elseif (filled($request->docker_compose_raw)) { - $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags']; $validationRules = [ 'project_uuid' => 'string|required', @@ -525,6 +594,8 @@ public function create_service(Request $request) 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -658,6 +729,10 @@ public function create_service(Request $request) } } + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -737,11 +812,135 @@ public function service_by_uuid(Request $request) $this->authorize('view', $service); - $service = $service->load(['applications', 'databases']); + $serviceRelations = ['applications', 'databases']; + if ($request->attributes->get('can_read_sensitive', false) === true) { + $serviceRelations[] = 'destination.server.settings'; + } + + $service = $service->load($serviceRelations); return response()->json($this->removeSensitiveData($service)); } + #[OA\Get( + summary: 'Get service logs.', + description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.', + path: '/services/{uuid}/logs', + operationId: 'get-service-logs-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'sub_service_name', + in: 'query', + description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.', + required: true, + schema: new OA\Schema(type: 'string', example: 'appwrite-console'), + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema( + type: 'integer', + format: 'int32', + default: 100, + ) + ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get service logs by UUID.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => ['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 logs_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $subServiceName = $request->query->get('sub_service_name'); + if (! $subServiceName) { + return response()->json(['message' => 'Sub service name is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $name = "{$subServiceName}-{$service->uuid}"; + $containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $name); + $container = $containers->first(); + + if (! $container) { + return response()->json(['message' => 'Container not found.'], 404); + } + + $status = getContainerStatus($service->destination->server, $container['Names']); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Container is not running.', + ], 400); + } + + $lines = normalizeLogLines($request->query('lines')); + $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); + $logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps); + + return response()->json([ + 'logs' => $logs, + ]); + } + #[OA\Delete( summary: 'Delete', description: 'Delete service by UUID.', @@ -1670,6 +1869,99 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable deleted.']); } + #[OA\Post( + summary: 'Move', + description: 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.', + path: '/services/{uuid}/move', + operationId: 'move-service-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the service to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Service moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Service moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['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', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('update', $service); + + return moveResourceToEnvironment($request, $service, 'Service', $teamId); + } + #[OA\Get( summary: 'Start', description: 'Start service. `Post` request is also accepted.', @@ -2029,6 +2321,8 @@ public function storages(Request $request): JsonResponse ); } + $fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage)); + return response()->json([ 'persistent_storages' => $persistentStorages->sortBy('id')->values(), 'file_storages' => $fileStorages->sortBy('id')->values(), @@ -2276,7 +2570,7 @@ public function create_storage(Request $request): JsonResponse 'mount_path' => $storage->mount_path, ]); - return response()->json($storage, 201); + return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201); } #[OA\Patch( @@ -2513,7 +2807,7 @@ public function update_storage(Request $request): JsonResponse 'mount_path' => $storage->mount_path ?? null, ]); - return response()->json($storage); + return response()->json($this->exposeFileStorageContentIfAllowed($storage)); } #[OA\Delete( @@ -2627,4 +2921,148 @@ public function delete_storage(Request $request): JsonResponse return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Get( + summary: 'List Tags', + description: 'List tags for a service by UUID.', + path: '/services/{uuid}/tags', + operationId: 'list-tags-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of tags.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + 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 tags(Request $request): JsonResponse + { + return $this->listTags($request); + } + + #[OA\Post( + summary: 'Create Tag', + description: 'Add tag(s) to a service by UUID.', + path: '/services/{uuid}/tags', + operationId: 'create-tag-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'], + 'tag_names' => [ + 'type' => 'array', + 'items' => new OA\Items(type: 'string'), + 'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.', + ], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tags added successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + 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'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_tag(Request $request): JsonResponse + { + return $this->createTag($request); + } + + #[OA\Delete( + summary: 'Delete Tag', + description: 'Remove a tag from a service by UUID.', + path: '/services/{uuid}/tags/{tag_uuid}', + operationId: 'delete-tag-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'tag_uuid', + in: 'path', + description: 'UUID of the tag.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag removed.', + ), + 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 delete_tag(Request $request): JsonResponse + { + return $this->deleteTag($request); + } } diff --git a/app/Http/Controllers/Api/TagsController.php b/app/Http/Controllers/Api/TagsController.php new file mode 100644 index 000000000..173a8ab7b --- /dev/null +++ b/app/Http/Controllers/Api/TagsController.php @@ -0,0 +1,61 @@ + $tag->uuid, + 'name' => $tag->name, + 'created_at' => $tag->created_at, + 'updated_at' => $tag->updated_at, + ]; + } + + #[OA\Get( + summary: 'List', + description: 'List all tags for the current team.', + path: '/tags', + operationId: 'list-tags', + security: [ + ['bearerAuth' => []], + ], + tags: ['Tags'], + responses: [ + new OA\Response( + response: 200, + description: 'All tags for the current team.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Tag') + ) + ), + ] + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + ] + )] + public function tags(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $tags = Tag::where('team_id', $teamId)->orderBy('name')->get(); + + return response()->json($tags->map(self::serializeTag(...))); + } +} diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php new file mode 100644 index 000000000..60b9d0a0a --- /dev/null +++ b/app/Http/Controllers/Api/VultrController.php @@ -0,0 +1,406 @@ +cloud_provider_token_uuid ?? $request->cloud_provider_token_id; + } + + private function getVultrToken(Request $request): CloudProviderToken|JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($this->getCloudProviderTokenUuid($request)) + ->where('provider', 'vultr') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Vultr cloud provider token not found.'], 404); + } + + $this->authorize('view', $token); + + return $token; + } + + #[OA\Get( + summary: 'Get Vultr Regions', + description: 'Get all available Vultr regions.', + path: '/vultr/regions', + operationId: 'get-vultr-regions', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr regions.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function regions(Request $request): JsonResponse + { + $token = $this->getVultrToken($request); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new VultrService($token->token))->getRegions()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch Vultr regions.'], 500); + } + } + + #[OA\Get( + summary: 'Get Vultr Plans', + description: 'Get all available Vultr plans.', + path: '/vultr/plans', + operationId: 'get-vultr-plans', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr plans.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function plans(Request $request): JsonResponse + { + $token = $this->getVultrToken($request); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new VultrService($token->token))->getPlans()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch Vultr plans.'], 500); + } + } + + #[OA\Get( + summary: 'Get Vultr Operating Systems', + description: 'Get all available Vultr operating systems.', + path: '/vultr/os', + operationId: 'get-vultr-operating-systems', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr operating systems.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function operatingSystems(Request $request): JsonResponse + { + $token = $this->getVultrToken($request); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new VultrService($token->token))->getOperatingSystems()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch Vultr operating systems.'], 500); + } + } + + #[OA\Get( + summary: 'Get Vultr SSH Keys', + description: 'Get all Vultr SSH keys available to the selected token.', + path: '/vultr/ssh-keys', + operationId: 'get-vultr-ssh-keys', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr SSH keys.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function sshKeys(Request $request): JsonResponse + { + $token = $this->getVultrToken($request); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new VultrService($token->token))->getSshKeys()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch Vultr SSH keys.'], 500); + } + } + + #[OA\Post( + summary: 'Create Vultr Server', + description: 'Create a Vultr instance and link it as a Coolify server.', + path: '/servers/vultr', + operationId: 'create-vultr-server', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 201, description: 'Vultr server created.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Vultr API rate limit exceeded.'), + ] + )] + public function createServer(Request $request): JsonResponse + { + $allowedFields = [ + 'cloud_provider_token_uuid', + 'cloud_provider_token_id', + 'region', + 'plan', + 'os_id', + 'name', + 'private_key_uuid', + 'enable_ipv6', + 'disable_public_ipv4', + 'vultr_ssh_key_ids', + 'cloud_init_script', + 'instant_validate', + ]; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + 'region' => 'required|string', + 'plan' => 'required|string', + 'os_id' => 'required|integer', + 'name' => ['nullable', 'string', 'max:253', new ValidHostname], + 'private_key_uuid' => 'required|string', + 'enable_ipv6' => 'nullable|boolean', + 'disable_public_ipv4' => 'nullable|boolean', + 'vultr_ssh_key_ids' => 'nullable|array', + 'vultr_ssh_key_ids.*' => 'string', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'instant_validate' => 'nullable|boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $team = Team::find($teamId); + if (Team::serverLimitReached($team)) { + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); + } + + if (! $request->name) { + $request->offsetSet('name', generate_random_name()); + } + if (is_null($request->enable_ipv6)) { + $request->offsetSet('enable_ipv6', true); + } + if (is_null($request->disable_public_ipv4)) { + $request->offsetSet('disable_public_ipv4', false); + } + if (is_null($request->vultr_ssh_key_ids)) { + $request->offsetSet('vultr_ssh_key_ids', []); + } + if (is_null($request->instant_validate)) { + $request->offsetSet('instant_validate', false); + } + + if ($request->disable_public_ipv4 && ! $request->enable_ipv6) { + return $this->networkConfigurationErrorResponse(); + } + + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($this->getCloudProviderTokenUuid($request)) + ->where('provider', 'vultr') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Vultr cloud provider token not found.'], 404); + } + + $this->authorize('view', $token); + + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); + if (! $privateKey) { + return response()->json(['message' => 'Private key not found.'], 404); + } + + try { + $vultrService = new VultrService($token->token); + $publicKey = $privateKey->getPublicKey(); + $existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey); + + if ($existingKey) { + $sshKeyId = $existingKey['id']; + } else { + $uploadedKey = $vultrService->uploadSshKey($privateKey->name, $publicKey); + $sshKeyId = $uploadedKey['id']; + } + + $normalizedServerName = strtolower(trim($request->name)); + $sshKeys = array_values(array_unique(array_merge([$sshKeyId], $request->vultr_ssh_key_ids))); + + $params = [ + 'region' => $request->region, + 'plan' => $request->plan, + 'os_id' => $request->os_id, + 'label' => $normalizedServerName, + 'hostname' => $normalizedServerName, + 'sshkey_id' => $sshKeys, + 'enable_ipv6' => $request->enable_ipv6, + 'disable_public_ipv4' => $request->disable_public_ipv4, + ]; + + if (! empty($request->cloud_init_script)) { + $params['user_data'] = $request->cloud_init_script; + } + + $vultrInstance = $vultrService->createInstance($params); + $ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP; + + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'vultr_instance_id' => $vultrInstance['id'], + 'vultr_instance_status' => $vultrInstance['status'] ?? null, + ]); + + $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); + $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); + if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { + $ipAddress = $assignedIpAddress; + $server->update([ + 'ip' => $assignedIpAddress, + 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, + ]); + } + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + if ($request->instant_validate) { + ValidateServer::dispatch($server); + } + + auditLog('api.vultr_server.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'vultr_instance_id' => $vultrInstance['id'], + 'ip' => $ipAddress, + ]); + + return response()->json([ + 'uuid' => $server->uuid, + 'vultr_instance_id' => $vultrInstance['id'], + 'ip' => $ipAddress, + ])->setStatusCode(201); + } catch (RateLimitException $e) { + $response = response()->json(['message' => $e->getMessage()], 429); + if ($e->retryAfter !== null) { + $response->header('Retry-After', $e->retryAfter); + } + + return $response; + } catch (\Throwable) { + return response()->json(['message' => 'Failed to create Vultr server.'], 500); + } + } + + private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array + { + $normalizedPublicKey = $this->normalizePublicKey($publicKey); + + foreach ($sshKeys as $sshKey) { + if ($this->normalizePublicKey($sshKey['ssh_key'] ?? '') === $normalizedPublicKey) { + return $sshKey; + } + } + + return null; + } + + private function normalizePublicKey(string $publicKey): string + { + $parts = preg_split('/\s+/', trim($publicKey)); + + return implode(' ', array_slice($parts ?: [], 0, 2)); + } + + private function networkConfigurationErrorResponse(): JsonResponse + { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'enable_ipv6' => ['Enable IPv6 when disabling public IPv4.'], + ], + ], 422); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 3090538c3..c723d811a 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -98,7 +98,7 @@ public function forgot_password(Request $request) public function link() { $token = request()->get('token'); - if ($token) { + if (is_string($token) && $token !== '') { try { $decrypted = Crypt::decryptString($token); } catch (DecryptException) { @@ -126,9 +126,8 @@ public function link() $invitation = TeamInvitation::query() ->where('email', $email) ->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid)) - ->where('link', request()->fullUrl()) ->first(); - if (! $invitation || ! $invitation->isValid()) { + if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) { return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); } @@ -139,10 +138,11 @@ public function link() } $invitation->delete(); - Auth::login($user); $user->forceFill([ 'password' => Hash::make(Str::random(64)), ])->save(); + + Auth::login($user); session(['currentTeam' => $team]); return redirect()->route('dashboard'); @@ -152,6 +152,19 @@ public function link() return redirect()->route('login')->with('error', 'Invalid credentials.'); } + private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool + { + $query = parse_url($invitation->link, PHP_URL_QUERY); + if (! is_string($query)) { + return false; + } + + parse_str($query, $parameters); + $storedToken = $parameters['token'] ?? null; + + return is_string($storedToken) && hash_equals($storedToken, $token); + } + public function showInvitation() { $invitationUuid = request()->route('uuid'); diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 435f5efab..fea55586b 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -162,7 +162,7 @@ public function manual(Request $request) 'mode' => 'manual', 'application_uuid' => $application->uuid, 'application_name' => $application->name, - 'deployment_uuid' => $deployment_uuid->toString(), + 'deployment_uuid' => $deployment_uuid, 'commit' => $commit, 'repository' => $full_name ?? null, ]); diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 82a8cc8af..a59cb5498 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -148,7 +148,7 @@ public function manual(Request $request) 'mode' => 'manual', 'application_uuid' => $application->uuid, 'application_name' => $application->name, - 'deployment_uuid' => $deployment_uuid->toString(), + 'deployment_uuid' => $deployment_uuid, 'commit' => data_get($payload, 'after'), 'repository' => $full_name ?? null, ]); diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index c90f4ad40..cd68c1b67 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -190,7 +190,7 @@ public function manual(Request $request) 'mode' => 'manual', 'application_uuid' => $application->uuid, 'application_name' => $application->name, - 'deployment_uuid' => $deployment_uuid->toString(), + 'deployment_uuid' => $deployment_uuid, 'commit' => data_get($payload, 'after'), 'repository' => $full_name ?? null, ]); diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 02a49aaa8..9e8dee83e 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -12,6 +12,7 @@ use App\Http\Middleware\DecideWhatToDoWithUser; use App\Http\Middleware\EncryptCookies; use App\Http\Middleware\EnsureMcpEnabled; +use App\Http\Middleware\EnsureTeamMcpEnabled; use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember; use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\RedirectIfAuthenticated; @@ -110,5 +111,6 @@ class Kernel extends HttpKernel 'can.update.resource' => CanUpdateResource::class, 'can.access.terminal' => CanAccessTerminal::class, 'mcp.enabled' => EnsureMcpEnabled::class, + 'mcp.team.enabled' => EnsureTeamMcpEnabled::class, ]; } diff --git a/app/Http/Middleware/ApiSensitiveData.php b/app/Http/Middleware/ApiSensitiveData.php index 8d7c51d11..05d1b3e6e 100644 --- a/app/Http/Middleware/ApiSensitiveData.php +++ b/app/Http/Middleware/ApiSensitiveData.php @@ -11,8 +11,9 @@ public function handle(Request $request, Closure $next) { $token = $request->user()->currentAccessToken(); $hasTokenPermission = $token->can('root') || $token->can('read:sensitive'); - $teamId = (int) data_get($token, 'team_id'); - $isAdmin = $teamId ? $request->user()->isAdminOfTeam($teamId) : false; + $teamId = data_get($token, 'team_id'); + // team_id 0 is the instance-admin team, so a falsy check must not exclude it + $isAdmin = ! is_null($teamId) ? $request->user()->isAdminOfTeam((int) $teamId) : false; // Allow access to sensitive data only if token has permission AND user is admin/owner $request->attributes->add([ diff --git a/app/Http/Middleware/EnsureTeamMcpEnabled.php b/app/Http/Middleware/EnsureTeamMcpEnabled.php new file mode 100644 index 000000000..5c76d2a1b --- /dev/null +++ b/app/Http/Middleware/EnsureTeamMcpEnabled.php @@ -0,0 +1,26 @@ +user(); + $teamId = $user?->currentAccessToken()?->team_id; + + $team = $user?->teams() + ->where('teams.id', $teamId) + ->first(); + + if (! $team?->is_mcp_server_enabled) { + return response()->json(['message' => 'MCP server is disabled for this team.'], 403); + } + + return $next($request); + } +} diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 545735cf6..3e415c729 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -52,6 +52,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; + private const DOCKER_CLIENT_ENV_KEYS = [ + 'BUILDKIT_HOST', + 'BUILDX_BUILDER', + 'BUILDX_CONFIG', + 'DOCKER_API_VERSION', + 'DOCKER_BUILDKIT', + 'DOCKER_CERT_PATH', + 'DOCKER_CLI_EXPERIMENTAL', + 'DOCKER_CONFIG', + 'DOCKER_CONTEXT', + 'DOCKER_HOST', + 'DOCKER_TLS', + 'DOCKER_TLS_VERIFY', + ]; + public $tries = 1; public $timeout = 3600; @@ -1701,6 +1716,10 @@ private function generate_buildtime_environment_variables() } foreach ($sorted_environment_variables as $env) { + if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) { + continue; + } + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { @@ -1752,6 +1771,10 @@ private function generate_buildtime_environment_variables() } foreach ($sorted_environment_variables as $env) { + if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) { + continue; + } + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { @@ -2124,7 +2147,7 @@ private function create_workdir() private function prepare_builder_image(bool $firstTry = true) { $this->checkForCancellation(); - $helperImage = config('constants.coolify.helper_image'); + $helperImage = coolifyHelperImage(); $helperImage = "{$helperImage}:".getHelperVersion(); // Get user home directory $this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server); @@ -2229,7 +2252,7 @@ private function set_coolify_variables() // Only include SOURCE_COMMIT in build context if enabled in settings if ($this->application->settings->include_source_commit_in_build) { - $this->coolify_variables .= "SOURCE_COMMIT={$this->commit} "; + $this->coolify_variables .= 'SOURCE_COMMIT='.escapeShellValue($this->commit).' '; } if ($this->pull_request_id === 0) { $fqdn = $this->application->fqdn; @@ -2241,17 +2264,33 @@ private function set_coolify_variables() $fqdn = $url->getHost(); $url = $url->withHost($fqdn)->withPort(null)->__toString(); if ((int) $this->application->compose_parsing_version >= 3) { - $this->coolify_variables .= "COOLIFY_URL={$url} "; - $this->coolify_variables .= "COOLIFY_FQDN={$fqdn} "; + $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' '; + $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' '; } else { - $this->coolify_variables .= "COOLIFY_URL={$fqdn} "; - $this->coolify_variables .= "COOLIFY_FQDN={$url} "; + $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' '; + $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' '; } } if (isset($this->application->git_branch)) { $this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' '; } - $this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} "; + $this->coolify_variables .= 'COOLIFY_RESOURCE_UUID='.escapeShellValue($this->application->uuid).' '; + } + + private function shellAssignmentForDockerfileArg(string $assignment): string + { + [$key, $value] = array_pad(explode('=', $assignment, 2), 2, null); + + if ($value === null) { + return $assignment; + } + + if (str_starts_with($value, "'") && str_ends_with($value, "'")) { + $value = substr($value, 1, -1); + $value = str_replace("'\\''", "'", $value); + } + + return "{$key}={$value}"; } private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string @@ -2327,7 +2366,7 @@ private function check_git_if_build_needed() ], ); } - if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) { + if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback && $this->shouldResolveBranchHeadCommit()) { // Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings) // Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines // Note: Git warnings can be on the same line as the result (no newline) @@ -2359,6 +2398,13 @@ private function check_git_if_build_needed() } } + private function shouldResolveBranchHeadCommit(): bool + { + $commit = trim($this->commit); + + return $commit === '' || $commit === 'HEAD'; + } + private function clone_repository() { $importCommands = $this->generate_git_import_commands(); @@ -2566,6 +2612,20 @@ private function generate_nixpacks_env_variables() $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } + private function is_reserved_docker_client_env_key(?string $key): bool + { + if (blank($key)) { + return false; + } + + return in_array(strtoupper($key), self::DOCKER_CLIENT_ENV_KEYS, true); + } + + private function without_reserved_docker_client_variables(Collection $variables): Collection + { + return $variables->reject(fn ($value, $key) => $this->is_reserved_docker_client_env_key((string) $key)); + } + private function generate_railpack_env_variables(): Collection { $variables = $this->railpack_build_variables(); @@ -2686,6 +2746,8 @@ private function railpack_build_secret_flags(Collection $variables): string private function railpack_build_command(string $imageName, Collection $variables): string { + $variables = $this->without_reserved_docker_client_variables($variables); + $cacheArgs = ''; if ($this->force_rebuild) { $cacheArgs = '--no-cache'; @@ -2712,7 +2774,7 @@ private function railpack_build_command(string $imageName, Collection $variables $secretFlags = $this->railpack_build_secret_flags($variables); $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version'); - $buildxBuildCommand = "{$environmentPrefix}docker buildx build --builder coolify-railpack" + $buildxBuildCommand = "{$environmentPrefix}DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack" ." {$this->addHosts} --network host" ." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\"" ." {$cacheArgs}" @@ -2723,7 +2785,7 @@ private function railpack_build_command(string $imageName, Collection $variables ." -t {$imageName}" ." {$this->workdir}"; - return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' + return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' .' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand); } @@ -2878,9 +2940,25 @@ private function ensure_docker_buildx_available_for_railpack(): void throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.'); } + private function ensure_helper_docker_buildx_available_for_railpack(): void + { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'DOCKER_CONFIG=/root/.docker docker buildx version >/dev/null 2>&1 && echo available || echo not-available'), + 'hidden' => true, + 'save' => 'railpack_helper_buildx_available', + ]); + + if (trim((string) $this->saved_outputs->get('railpack_helper_buildx_available')) === 'available') { + return; + } + + throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin inside the Coolify helper container. The helper could not find buildx at /root/.docker/cli-plugins/docker-buildx. Pull the latest helper image and retry the deployment.'); + } + private function build_railpack_image(): void { $this->ensure_docker_buildx_available_for_railpack(); + $this->ensure_helper_docker_buildx_available_for_railpack(); $railpackVariables = $this->generate_railpack_env_variables(); $railpackConfigPath = $this->generate_railpack_config_file(); @@ -4080,6 +4158,10 @@ private function generate_docker_env_flags_for_secrets() $variables = $this->env_args; + if ($this->build_pack === 'railpack') { + $variables = $this->without_reserved_docker_client_variables($variables); + } + if ($variables->isEmpty()) { return ''; } @@ -4220,7 +4302,7 @@ private function add_build_env_variables_to_dockerfile() $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) ->filter() ->map(function ($var) { - return "ARG {$var}"; + return 'ARG '.$this->shellAssignmentForDockerfileArg($var); }); $argsToInsert = $argsToInsert->merge($coolify_vars); } @@ -4242,7 +4324,7 @@ private function add_build_env_variables_to_dockerfile() $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) ->filter() ->map(function ($var) { - return "ARG {$var}"; + return 'ARG '.$this->shellAssignmentForDockerfileArg($var); }); $argsToInsert = $argsToInsert->merge($coolify_vars); } diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index f6f5e8b5b..f1635d6d4 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -36,7 +36,7 @@ public function handle(): void 'active_deployment_uuids' => $activeDeployments, ]); - $containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false); + $containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false); $helperContainers = collect(json_decode($containers)); if ($helperContainers->count() > 0) { diff --git a/app/Jobs/CleanupOrphanedPreviewContainersJob.php b/app/Jobs/CleanupOrphanedPreviewContainersJob.php index 5d3bed457..e74cba554 100644 --- a/app/Jobs/CleanupOrphanedPreviewContainersJob.php +++ b/app/Jobs/CleanupOrphanedPreviewContainersJob.php @@ -12,6 +12,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; /** @@ -53,11 +54,13 @@ public function handle(): void /** * Get all functional servers to check for orphaned containers. */ - private function getServersToCheck(): \Illuminate\Support\Collection + private function getServersToCheck(): Collection { $query = Server::whereRelation('settings', 'is_usable', true) ->whereRelation('settings', 'is_reachable', true) - ->where('ip', '!=', '1.2.3.4'); + ->whereNotNull('ip') + ->where('ip', '!=', '') + ->whereNotIn('ip', Server::PLACEHOLDER_IPS); if (isCloud()) { $query = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true); @@ -99,7 +102,7 @@ private function cleanupOrphanedContainersOnServer(Server $server): void /** * Get all PR containers on a server (containers with pullRequestId > 0). */ - private function getPRContainersOnServer(Server $server): \Illuminate\Support\Collection + private function getPRContainersOnServer(Server $server): Collection { try { $output = instant_remote_process([ diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 9878e0a38..e9e1f9105 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -16,6 +16,7 @@ use App\Notifications\Database\BackupFailed; use App\Notifications\Database\BackupSuccess; use App\Notifications\Database\BackupSuccessWithS3Warning; +use App\Rules\SafeWebhookUrl; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -716,8 +717,12 @@ private function upload_to_s3(): void $escapedSecret = escapeshellarg($secret); $escapedBackupLocation = escapeshellarg($this->backup_location); $escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/"); + $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint)) + ->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption)) + ->implode(' '); + $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}"; instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); @@ -734,7 +739,7 @@ private function upload_to_s3(): void private function getFullImageName(): string { - $helperImage = config('constants.coolify.helper_image'); + $helperImage = coolifyHelperImage(); $latestVersion = getHelperVersion(); return "{$helperImage}:{$latestVersion}"; diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 61fc3d4ee..5186a58bb 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -96,7 +96,16 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp return; } - if (self::shouldSkipDeployAny([$this->pullRequestTitle])) { + $repository_parts = explode('/', $this->fullName); + $owner = $repository_parts[0] ?? ''; + $repo = $repository_parts[1] ?? ''; + $headCommitMessage = null; + + if ($this->action === 'opened' || $this->action === 'synchronize' || $this->action === 'reopened') { + $headCommitMessage = getGithubCommitMessage($githubApp, $owner, $repo, $this->commitSha); + } + + if (self::shouldSkipDeployAny([$this->pullRequestTitle, $headCommitMessage])) { return; } @@ -120,9 +129,6 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp // Get changed files for watch path filtering $changed_files = collect(); - $repository_parts = explode('/', $this->fullName); - $owner = $repository_parts[0] ?? ''; - $repo = $repository_parts[1] ?? ''; if ($this->action === 'synchronize' && $this->beforeSha && $this->afterSha) { // For synchronize events, get files changed between before and after commits diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 62e98934e..fbf5cd154 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -311,6 +311,10 @@ public function handle() } } + if (! $this->isCompleteSnapshot()) { + return; + } + $this->updateProxyStatus(); $this->updateNotFoundApplicationStatus(); @@ -329,6 +333,11 @@ public function handle() $this->checkLogDrainContainer(); } + private function isCompleteSnapshot(): bool + { + return data_get($this->data, 'snapshot.complete', true) !== false; + } + private function loadApplications(): Collection { [$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds(); @@ -700,6 +709,9 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta $database->status = $containerStatus; $database->save(); } + if (! $this->isCompleteSnapshot()) { + return; + } 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'; diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index e7a21949c..46bc89d42 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -457,7 +457,9 @@ private function processDockerCleanup(Server $server): void private function getServersForCleanupQuery(): Builder { $query = Server::with('settings') - ->where('ip', '!=', '1.2.3.4'); + ->whereNotNull('ip') + ->where('ip', '!=', '') + ->whereNotIn('ip', Server::PLACEHOLDER_IPS); if (isCloud()) { $query diff --git a/app/Jobs/SendMessageToDiscordJob.php b/app/Jobs/SendMessageToDiscordJob.php index 9ac017396..d5c29efb0 100644 --- a/app/Jobs/SendMessageToDiscordJob.php +++ b/app/Jobs/SendMessageToDiscordJob.php @@ -51,13 +51,24 @@ public function handle(): void if ($validator->fails()) { Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [ - 'url' => $this->webhookUrl, + 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl), 'errors' => $validator->errors()->all(), ]); return; } - Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->message->toPayload()); + try { + $httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl); + } catch (\RuntimeException $e) { + Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL at send time', [ + 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl), + 'error' => $e->getMessage(), + ]); + + return; + } + + Http::withOptions($httpOptions)->post($this->webhookUrl, $this->message->toPayload()); } } diff --git a/app/Jobs/SendMessageToSlackJob.php b/app/Jobs/SendMessageToSlackJob.php index e5cff5818..3a306c23d 100644 --- a/app/Jobs/SendMessageToSlackJob.php +++ b/app/Jobs/SendMessageToSlackJob.php @@ -44,15 +44,26 @@ public function handle(): void if ($validator->fails()) { Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [ - 'url' => $this->webhookUrl, + 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl), 'errors' => $validator->errors()->all(), ]); return; } + try { + $httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl); + } catch (\RuntimeException $e) { + Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL at send time', [ + 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl), + 'error' => $e->getMessage(), + ]); + + return; + } + if ($this->isSlackWebhook()) { - $this->sendToSlack(); + $this->sendToSlack($httpOptions); return; } @@ -62,7 +73,7 @@ public function handle(): void * * @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708 */ - $this->sendToMattermost(); + $this->sendToMattermost($httpOptions); } private function isSlackWebhook(): bool @@ -79,9 +90,12 @@ private function isSlackWebhook(): bool return $scheme === 'https' && $host === 'hooks.slack.com'; } - private function sendToSlack(): void + /** + * @param array $httpOptions + */ + private function sendToSlack(array $httpOptions): void { - Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [ + Http::withOptions($httpOptions)->post($this->webhookUrl, [ 'text' => $this->message->title, 'blocks' => [ [ @@ -119,11 +133,14 @@ private function sendToSlack(): void /** * @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type. */ - private function sendToMattermost(): void + /** + * @param array $httpOptions + */ + private function sendToMattermost(array $httpOptions): void { $username = config('app.name'); - Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [ + Http::withOptions($httpOptions)->post($this->webhookUrl, [ 'username' => $username, 'attachments' => [ [ diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php index beee24179..c14d70a77 100644 --- a/app/Jobs/SendWebhookJob.php +++ b/app/Jobs/SendWebhookJob.php @@ -50,28 +50,24 @@ public function handle(): void if ($validator->fails()) { Log::warning('SendWebhookJob: blocked unsafe webhook URL', [ - 'url' => $this->webhookUrl, + 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl), 'errors' => $validator->errors()->all(), ]); return; } - if (isDev()) { - ray('Sending webhook notification', [ - 'url' => $this->webhookUrl, - 'payload' => $this->payload, + try { + $httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl); + } catch (\RuntimeException $e) { + Log::warning('SendWebhookJob: blocked unsafe webhook URL at send time', [ + 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl), + 'error' => $e->getMessage(), ]); + + return; } - $response = Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->payload); - - if (isDev()) { - ray('Webhook response', [ - 'status' => $response->status(), - 'body' => $response->body(), - 'successful' => $response->successful(), - ]); - } + Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload); } } diff --git a/app/Jobs/ServerCloudProviderStatusCheckJob.php b/app/Jobs/ServerCloudProviderStatusCheckJob.php new file mode 100644 index 000000000..cd2e51df7 --- /dev/null +++ b/app/Jobs/ServerCloudProviderStatusCheckJob.php @@ -0,0 +1,59 @@ +onQueue('high'); + } + + public function middleware(): array + { + return [(new WithoutOverlapping('server-cloud-provider-status-'.$this->server->uuid))->expireAfter(130)->dontRelease()]; + } + + public function handle(): void + { + try { + if (! $this->server->cloudProviderToken) { + return; + } + + match ($this->server->cloudProviderToken->provider) { + 'hetzner' => $this->server->hetzner_server_id + ? $this->server->refreshHetznerState() + : null, + 'vultr' => $this->server->vultr_instance_id + ? $this->server->refreshVultrState() + : null, + 'digitalocean' => $this->server->digitalocean_droplet_id + ? $this->server->refreshDigitalOceanState() + : null, + default => null, + }; + } catch (\Throwable $e) { + Log::debug('Cloud provider status check failed', [ + 'server_id' => $this->server->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 98ad60fff..f86686df7 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -6,7 +6,6 @@ use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use App\Services\ConfigurationRepository; -use App\Services\HetznerService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -42,8 +41,12 @@ private function disableSshMux(): void $configRepository->disableSshMux(); } - public function handle() + public function handle(): void { + if ($this->server->hasPlaceholderIp()) { + return; + } + $wasReachable = (bool) $this->server->settings->is_reachable; $wasNotified = (bool) $this->server->unreachable_notification_sent; @@ -62,11 +65,6 @@ public function handle() return; } - // Check Hetzner server status if applicable - if ($this->server->hetzner_server_id && $this->server->cloudProviderToken) { - $this->checkHetznerStatus(); - } - // Temporarily disable mux if requested if ($this->disableMux) { $this->disableSshMux(); @@ -128,17 +126,6 @@ public function handle() public function failed(?\Throwable $exception): void { if ($exception instanceof TimeoutExceededException) { - $wasReachable = (bool) $this->server->settings->is_reachable; - $wasNotified = (bool) $this->server->unreachable_notification_sent; - - $this->server->settings->update([ - 'is_reachable' => false, - 'is_usable' => false, - ]); - $this->server->increment('unreachable_count'); - - $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false); - // Delete the queue job so it doesn't appear in Horizon's failed list. $this->job?->delete(); } @@ -163,29 +150,6 @@ private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $w } } - private function checkHetznerStatus(): void - { - $status = null; - - try { - $hetznerService = new HetznerService($this->server->cloudProviderToken->token); - $serverData = $hetznerService->getServer($this->server->hetzner_server_id); - $status = $serverData['status'] ?? null; - - } catch (\Throwable) { - // Silently ignore — server may have been deleted from Hetzner. - } - if ($this->server->hetzner_server_status !== $status) { - $this->server->update(['hetzner_server_status' => $status]); - $this->server->hetzner_server_status = $status; - if ($status === 'off') { - ray('Server is powered off, marking as unreachable'); - throw new \Exception('Server is powered off'); - } - } - - } - private function checkConnection(): bool { try { diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 9532282cc..67c222c24 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -55,6 +55,13 @@ public function handle(): void // Get all servers to process $servers = $this->getServers(); + // Provider state checks run independently so slow APIs cannot block SSH checks. + $this->dispatchCloudProviderStatusChecks($servers); + + $servers = $servers + ->reject(fn (Server $server) => $server->hasPlaceholderIp()) + ->values(); + // Dispatch ServerConnectionCheck for all servers efficiently $this->dispatchConnectionChecks($servers); @@ -64,24 +71,45 @@ public function handle(): void private function getServers(): Collection { - $allServers = Server::with('settings')->where('ip', '!=', '1.2.3.4'); + $allServers = Server::with(['settings', 'cloudProviderToken']); if (isCloud()) { $servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); - $own = Team::find(0)->servers()->with('settings')->get(); + $own = Team::find(0)->servers()->with(['settings', 'cloudProviderToken'])->get(); - return $servers->merge($own); + return $servers->merge($own)->unique('id')->values(); } else { return $allServers->get(); } } + private function dispatchCloudProviderStatusChecks(Collection $servers): void + { + if (! shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-cloud-provider-status-checks', $this->executionTime)) { + return; + } + + $servers->each(function (Server $server) { + $hasCloudResource = $server->hetzner_server_id + || $server->vultr_instance_id + || $server->digitalocean_droplet_id; + + if ($hasCloudResource && $server->cloudProviderToken) { + ServerCloudProviderStatusCheckJob::dispatch($server); + } + }); + } + private function dispatchConnectionChecks(Collection $servers): void { if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) { $servers->each(function (Server $server) { try { + if ($server->hasPlaceholderIp()) { + return; + } + // Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity if ($server->isSentinelEnabled() && $server->isSentinelLive()) { return; diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 4148764de..bf64ee8e9 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -251,7 +251,6 @@ private function loadSearchableItems() $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id); $this->allSearchableItems = Cache::remember($cacheKey, 300, function () { - ray()->showQueries(); $items = collect(); $team = auth()->user()->currentTeam(); @@ -530,7 +529,6 @@ private function loadSearchableItems() 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'), ]; }); - ray($servers); // Get all projects $projects = Project::ownedByCurrentTeam() ->withCount(['environments', 'applications', 'services']) @@ -1136,6 +1134,12 @@ private function loadCreatableItems() public function navigateToResource($type) { + if ($type === 'server') { + $this->dispatch('closeSearchModal'); + + return redirectRoute($this, 'server.create'); + } + // Find the item by type - check regular items first, then services $item = collect($this->creatableItems)->firstWhere('type', $type); diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index fd82b4ccb..6c956f70f 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -192,13 +192,6 @@ public function saveModel() $this->syncData(true); refreshSession(); - if (isDev()) { - ray('Webhook settings saved', [ - 'webhook_enabled' => $this->settings->webhook_enabled, - 'webhook_url' => $this->settings->webhook_url, - ]); - } - $this->dispatch('success', 'Settings saved.'); } @@ -207,13 +200,6 @@ public function sendTestNotification() try { $this->authorize('sendTest', $this->settings); - if (isDev()) { - ray('Sending test webhook notification', [ - 'team_id' => $this->team->id, - 'webhook_url' => $this->settings->webhook_url, - ]); - } - $this->team->notify(new Test(channel: 'webhook')); $this->dispatch('success', 'Test notification sent.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index f62f8bfdd..bf84f385d 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -286,6 +286,7 @@ public function saveStopGracePeriod() $this->application->settings->save(); $this->dispatch('success', 'Stop grace period updated.'); + $this->dispatch('configurationChanged'); } catch (ValidationException $e) { throw $e; } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 7af0a275d..3e5a51944 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -12,7 +12,6 @@ use Illuminate\Support\Collection; use Livewire\Component; use Livewire\Features\SupportEvents\Event; -use Spatie\Url\Url; class General extends Component { @@ -142,7 +141,8 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'fqdn' => 'nullable', + 'fqdn' => ValidationPatterns::applicationDomainRules(), + 'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(), 'gitRepository' => 'required', 'gitBranch' => ['required', 'string', new ValidGitBranch], 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], @@ -489,6 +489,7 @@ public function instantSave() if ($this->isContainerLabelReadonlyEnabled) { $this->resetDefaultLabels(false); } + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -771,16 +772,7 @@ public function submit($showToaster = true) $oldBaseDirectory = $this->application->base_directory; // Process FQDN with intermediate variable to avoid Collection/string confusion - $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); - $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); - $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { - $domain = trim($domain); - Url::fromString($domain, ['http', 'https']); - - return str($domain)->lower(); - }); - - $this->fqdn = $domains->unique()->implode(','); + $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn); $warning = sslipDomainWarning($this->fqdn); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); @@ -863,6 +855,9 @@ public function submit($showToaster = true) } } if ($this->buildPack === 'dockercompose') { + foreach ($this->parsedServiceDomains as $serviceName => $service) { + $this->parsedServiceDomains[$serviceName]['domain'] = ValidationPatterns::normalizeApplicationDomains(data_get($service, 'domain')); + } $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); if ($this->application->isDirty('docker_compose_domains')) { foreach ($this->parsedServiceDomains as $service) { diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 74b2ebce8..338f102b5 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -6,6 +6,7 @@ use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\ApplicationPreview; +use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; @@ -117,12 +118,14 @@ public function save_preview($preview_id) }); if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) { + $this->validate([ + "previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(), + ]); + $fqdn = $this->previewFqdns[$previewKey]; if (! empty($fqdn)) { - $fqdn = str($fqdn)->replaceEnd(',', '')->trim(); - $fqdn = str($fqdn)->replaceStart(',', '')->trim(); - $fqdn = str($fqdn)->trim()->lower(); + $fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn); $this->previewFqdns[$previewKey] = $fqdn; if (! validateDNSEntry($fqdn, $this->application->destination->server)) { diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php index e8da3b45c..48392a742 100644 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ b/app/Livewire/Project/Application/PreviewsCompose.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Application; use App\Models\ApplicationPreview; +use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Spatie\Url\Url; @@ -33,6 +34,11 @@ public function save() { try { $this->authorize('update', $this->preview->application); + $this->validate([ + 'domain' => ValidationPatterns::applicationDomainRules(), + ]); + + $this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain); $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); $docker_compose_domains = json_decode($docker_compose_domains, true) ?: []; @@ -73,9 +79,13 @@ public function generate() $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; } else { + foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) { + throw new \InvalidArgumentException($error); + } + // Use the existing domain from the main application // Handle multiple domains separated by commas - $domain_list = explode(',', $domain_string); + $domain_list = ValidationPatterns::applicationDomainList($domain_string); $preview_fqdns = []; $template = $this->preview->application->preview_url_template; $random = new_public_id(); diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 3ee5919fe..fe6a6397a 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -147,6 +147,7 @@ public function changeSource($sourceId, $sourceType) 'source_id' => $source->id, 'source_type' => $sourceType, ]); + $this->dispatch('configurationChanged'); ['repository' => $customRepository] = $this->application->customRepository(); $repository = githubApi($this->application->source, "repos/{$customRepository}"); diff --git a/app/Livewire/Project/Application/Swarm.php b/app/Livewire/Project/Application/Swarm.php index 94d627e67..661578fb3 100644 --- a/app/Livewire/Project/Application/Swarm.php +++ b/app/Livewire/Project/Application/Swarm.php @@ -57,6 +57,7 @@ public function instantSave() $this->authorize('update', $this->application); $this->syncData(true); $this->dispatch('success', 'Swarm settings updated.'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -68,6 +69,7 @@ public function submit() $this->authorize('update', $this->application); $this->syncData(true); $this->dispatch('success', 'Swarm settings updated.'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index 2f6bcb3b4..62d4e1a59 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -14,6 +14,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Rules\SafeWebhookUrl; use App\Support\DatabaseBackupFileValidator; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -598,6 +599,7 @@ public function checkS3File() 'bucket' => $s3Storage->bucket, 'endpoint' => $s3Storage->endpoint, 'use_path_style_endpoint' => true, + 'http' => SafeWebhookUrl::httpClientOptions($s3Storage->endpoint), ]); // Check if file exists @@ -678,7 +680,7 @@ public function restoreFromS3(string $password = ''): bool|string } // Get helper image - $helperImage = config('constants.coolify.helper_image'); + $helperImage = coolifyHelperImage(); $latestVersion = getHelperVersion(); $fullImageName = "{$helperImage}:{$latestVersion}"; diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index cff886f98..34601f5dd 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -112,14 +112,17 @@ public function loadServices() $default_logo = 'images/default.webp'; $logo = data_get($service, 'logo', $default_logo); $local_logo_path = public_path($logo); + $serviceKey = (string) $key; return [ - 'name' => str($key)->headline(), + 'id' => $serviceKey, + 'name' => str($serviceKey)->headline(), + 'docsSlug' => str($serviceKey)->lower()->value(), 'logo' => asset($logo), 'logo_github_url' => file_exists($local_logo_path) ? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo : asset($default_logo), - 'templateLastUpdated' => $templateLastUpdatedMap[(string) $key] ?? null, + 'templateLastUpdated' => $templateLastUpdatedMap[$serviceKey] ?? null, ] + (array) $service; })->all(); @@ -336,7 +339,10 @@ private function formatLastModified(string $path): ?string public function setType(string $type) { - $type = str($type)->lower()->slug()->value(); + if (! str($type)->startsWith('one-click-service-')) { + $type = str($type)->lower()->slug()->value(); + } + if ($this->loading) { return; } diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index 7158b6e40..96fe6a62c 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -3,10 +3,10 @@ namespace App\Livewire\Project\Service; use App\Models\ServiceApplication; +use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; -use Spatie\Url\Url; class EditDomain extends Component { @@ -28,12 +28,15 @@ class EditDomain extends Component public $requiredPort = null; - #[Validate(['nullable'])] + #[Validate] public ?string $fqdn = null; - protected $rules = [ - 'fqdn' => 'nullable', - ]; + protected function rules(): array + { + return [ + 'fqdn' => ValidationPatterns::applicationDomainRules(), + ]; + } public function mount() { @@ -82,15 +85,9 @@ public function submit() { try { $this->authorize('update', $this->application); - $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); - $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); - $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { - $domain = trim($domain); - Url::fromString($domain, ['http', 'https']); + $this->validate(); - return str($domain)->lower(); - }); - $this->fqdn = $domains->unique()->implode(','); + $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn); $warning = sslipDomainWarning($this->fqdn); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 12c0edbca..7249c8133 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -8,11 +8,11 @@ use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Livewire\Component; -use Spatie\Url\Url; class Index extends Component { @@ -480,15 +480,11 @@ public function submitApplication() { try { $this->authorize('update', $this->serviceApplication); - $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); - $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); - $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { - $domain = trim($domain); - Url::fromString($domain, ['http', 'https']); + $this->validate([ + 'fqdn' => ValidationPatterns::applicationDomainRules(), + ]); - return str($domain)->lower(); - }); - $this->fqdn = $domains->unique()->implode(','); + $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn); $warning = sslipDomainWarning($this->fqdn); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 3a5145023..bac4546ce 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -76,6 +76,7 @@ public function instantSave() $this->resource->settings->save(); $this->getDevView(); $this->dispatch('success', 'Environment variable settings updated.'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -93,6 +94,10 @@ public function getEnvironmentVariablesPreviewProperty() private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection { + if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) { + return collect(); + } + $query = $isPreview ? $this->resource->environment_variables_preview() : $this->resource->environment_variables(); @@ -119,12 +124,21 @@ private function searchTerm(): string return trim($this->search); } + private function supportsPreviewEnvironmentVariables(): bool + { + return $this->showPreview && $this->resource instanceof Application; + } + public function getHasEnvironmentVariablesProperty(): bool { - return $this->environmentVariables->isNotEmpty() || + $hasPreviewEnvironmentVariables = $this->supportsPreviewEnvironmentVariables() && ( $this->environmentVariablesPreview->isNotEmpty() || + $this->hardcodedEnvironmentVariablesPreview->isNotEmpty() + ); + + return $this->environmentVariables->isNotEmpty() || $this->hardcodedEnvironmentVariables->isNotEmpty() || - $this->hardcodedEnvironmentVariablesPreview->isNotEmpty(); + $hasPreviewEnvironmentVariables; } private function nullLockedValues($envs) @@ -158,6 +172,10 @@ public function getHardcodedEnvironmentVariablesPreviewProperty() protected function getHardcodedVariables(bool $isPreview) { + if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) { + return collect([]); + } + // Only for services and docker-compose applications if ($this->resource->type() !== 'service' && ($this->resourceClass !== 'App\Models\Application' || diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 5fa62b04e..cb60a3f39 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -152,6 +152,7 @@ public function instantSave() $this->resource->custom_healthcheck_found = $this->customHealthcheckFound; $this->resource->save(); $this->dispatch('success', 'Health check updated.'); + $this->dispatch('configurationChanged'); } public function submit() @@ -178,6 +179,7 @@ public function submit() $this->resource->custom_healthcheck_found = $this->customHealthcheckFound; $this->resource->save(); $this->dispatch('success', 'Health check updated.'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -213,6 +215,7 @@ public function toggleHealthcheck() } else { $this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.'); } + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/Logs.php b/app/Livewire/Project/Shared/Logs.php index a95259c71..1b93ec47e 100644 --- a/app/Livewire/Project/Shared/Logs.php +++ b/app/Livewire/Project/Shared/Logs.php @@ -90,7 +90,6 @@ private function getContainersForServer($server) } } catch (\Exception $e) { // Log error but don't fail the entire operation - ray("Error loading containers for server {$server->name}: ".$e->getMessage()); return []; } diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php index 37b8b277a..61d04e20b 100644 --- a/app/Livewire/Project/Shared/Tags.php +++ b/app/Livewire/Project/Shared/Tags.php @@ -91,9 +91,7 @@ public function deleteTag(string $id) $this->authorize('update', $this->resource); $this->resource->tags()->detach($id); $found_more_tags = Tag::ownedByCurrentTeam()->find($id); - if ($found_more_tags && $found_more_tags->applications()->count() == 0 && $found_more_tags->services()->count() == 0) { - $found_more_tags->delete(); - } + $found_more_tags?->deleteIfOrphaned(); $this->refresh(); $this->dispatch('success', 'Tag deleted.'); } catch (\Exception $e) { diff --git a/app/Livewire/Security/CloudInitScript/Show.php b/app/Livewire/Security/CloudInitScript/Show.php new file mode 100644 index 000000000..6e2a3937d --- /dev/null +++ b/app/Livewire/Security/CloudInitScript/Show.php @@ -0,0 +1,97 @@ + 'required|string|max:255', + 'script' => ['required', 'string', new ValidCloudInitYaml], + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Script name is required.', + 'name.max' => 'Script name cannot exceed 255 characters.', + 'script.required' => 'Cloud-init script content is required.', + ]; + } + + public function mount(string $cloud_init_script_uuid): void + { + try { + $this->cloudInitScript = CloudInitScript::ownedByCurrentTeam() + ->whereUuid($cloud_init_script_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->cloudInitScript); + + $this->name = $this->cloudInitScript->name; + $this->script = $this->cloudInitScript->script; + } catch (AuthorizationException) { + abort(403, 'You do not have permission to view this cloud-init script.'); + } catch (\Throwable) { + abort(404); + } + } + + public function save(): void + { + $this->authorize('update', $this->cloudInitScript); + $this->validate(); + + $this->cloudInitScript->update([ + 'name' => $this->name, + 'script' => $this->script, + ]); + + auditLog('ui.cloud_init_script.updated', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $this->cloudInitScript->id, + 'cloud_init_script_name' => $this->cloudInitScript->name, + ]); + + $this->dispatch('success', 'Cloud-init script updated successfully.'); + } + + public function delete(): mixed + { + $this->authorize('delete', $this->cloudInitScript); + + $scriptId = $this->cloudInitScript->id; + $scriptName = $this->cloudInitScript->name; + + $this->cloudInitScript->delete(); + + auditLog('ui.cloud_init_script.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $scriptId, + 'cloud_init_script_name' => $scriptName, + ]); + + return redirectRoute($this, 'security.cloud-init-scripts'); + } + + public function render() + { + return view('livewire.security.cloud-init-script.show'); + } +} diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php index 57b7324d3..e66a749cf 100644 --- a/app/Livewire/Security/CloudInitScripts.php +++ b/app/Livewire/Security/CloudInitScripts.php @@ -27,6 +27,13 @@ public function getListeners() public function loadScripts() { + CloudInitScript::ownedByCurrentTeam() + ->whereNull('uuid') + ->get() + ->each(function (CloudInitScript $script): void { + $script->forceFill(['uuid' => new_public_id()])->save(); + }); + $this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get(); } diff --git a/app/Livewire/Security/CloudProviderToken/Show.php b/app/Livewire/Security/CloudProviderToken/Show.php new file mode 100644 index 000000000..aa9270be0 --- /dev/null +++ b/app/Livewire/Security/CloudProviderToken/Show.php @@ -0,0 +1,177 @@ + 'required|string|max:255', + 'description' => 'nullable|string|max:1000', + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Token name is required.', + ]; + } + + public function mount(string $cloud_token_uuid): void + { + try { + $this->cloudProviderToken = CloudProviderToken::ownedByCurrentTeam() + ->whereUuid($cloud_token_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->cloudProviderToken); + + $this->name = $this->cloudProviderToken->name; + $this->description = $this->cloudProviderToken->description; + } catch (AuthorizationException) { + abort(403, 'You do not have permission to view this cloud token.'); + } catch (\Throwable) { + abort(404); + } + } + + public function save(): void + { + $this->authorize('update', $this->cloudProviderToken); + $this->validate(); + + $description = trim($this->description ?? ''); + + $this->cloudProviderToken->update([ + 'name' => $this->name, + 'description' => $description === '' ? null : $description, + ]); + + auditLog('ui.cloud_token.updated', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + ]); + + $this->dispatch('success', 'Cloud provider token updated.'); + } + + public function validateToken(): void + { + $this->authorize('view', $this->cloudProviderToken); + + $isValid = match ($this->cloudProviderToken->provider) { + 'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token), + 'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token), + 'vultr' => $this->validateVultrToken($this->cloudProviderToken->token), + default => false, + }; + + $providerName = $this->providerName(); + + $this->dispatch( + $isValid ? 'success' : 'error', + $isValid + ? "{$providerName} token is valid." + : "{$providerName} token validation failed. Please check the token." + ); + + auditLog('ui.cloud_token.validated', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + 'valid' => $isValid, + ]); + } + + public function delete(): mixed + { + $this->authorize('delete', $this->cloudProviderToken); + + if ($this->cloudProviderToken->hasServers()) { + $serverCount = $this->cloudProviderToken->servers()->count(); + $this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first."); + + return null; + } + + auditLog('ui.cloud_token.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + ]); + + $this->cloudProviderToken->delete(); + + return redirectRoute($this, 'security.cloud-tokens'); + } + + public function providerName(): string + { + return match ($this->cloudProviderToken->provider) { + 'digitalocean' => 'DigitalOcean', + 'vultr' => 'Vultr', + default => 'Hetzner', + }; + } + + private function validateHetznerToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.hetzner.cloud/v1/servers?per_page=1') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + private function validateDigitalOceanToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.digitalocean.com/v2/account') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + private function validateVultrToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.vultr.com/v2/account') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + public function render() + { + return view('livewire.security.cloud-provider-token.show'); + } +} diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index 6d0efa15f..c2466c622 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -2,6 +2,9 @@ namespace App\Livewire\Security; +use App\Livewire\Server\CloudProviderToken\Show as ServerCloudProviderTokenShow; +use App\Livewire\Server\New\ByDigitalOcean; +use App\Livewire\Server\New\ByHetzner; use App\Models\CloudProviderToken; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; @@ -19,6 +22,8 @@ class CloudProviderTokenForm extends Component public string $name = ''; + public ?string $description = null; + public function mount() { try { @@ -31,9 +36,10 @@ public function mount() protected function rules(): array { return [ - 'provider' => 'required|string|in:hetzner,digitalocean', + 'provider' => 'required|string|in:hetzner,digitalocean,vultr', 'token' => 'required|string', 'name' => 'required|string|max:255', + 'description' => 'nullable|string|max:1000', ]; } @@ -58,8 +64,22 @@ private function validateToken(string $provider, string $token): bool return $response->successful(); } - // Add other providers here in the future - // if ($provider === 'digitalocean') { ... } + if ($provider === 'digitalocean') { + $response = Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://api.digitalocean.com/v2/account'); + + return $response->successful(); + } + + if ($provider === 'vultr') { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.vultr.com/v2/account'); + + return $response->successful(); + } return false; } catch (\Throwable $e) { @@ -77,11 +97,14 @@ public function addToken() return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); } + $description = trim($this->description ?? ''); + $savedToken = CloudProviderToken::create([ 'team_id' => currentTeam()->id, 'provider' => $this->provider, 'token' => $this->token, 'name' => $this->name, + 'description' => $description === '' ? null : $description, ]); auditLog('ui.cloud_token.created', [ @@ -91,10 +114,24 @@ public function addToken() 'provider' => $savedToken->provider, ]); - $this->reset(['token', 'name']); + $this->reset(['token', 'name', 'description']); // Dispatch event with token ID so parent components can react $this->dispatch('tokenAdded', tokenId: $savedToken->id); + $this->dispatch('tokenAdded', tokenId: $savedToken->id)->to(CloudProviderTokens::class); + + if ($savedToken->provider === 'digitalocean') { + $this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ByDigitalOcean::class); + } + + if ($savedToken->provider === 'hetzner') { + $this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ByHetzner::class); + $this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class); + } + + if ($this->modal_mode) { + $this->dispatch('close-modal'); + } $this->dispatch('success', 'Cloud provider token added successfully.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index dabb199ed..e94aa087b 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -55,6 +55,13 @@ public function validateToken(int $tokenId) } else { $this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.'); } + } elseif ($token->provider === 'vultr') { + $isValid = $this->validateVultrToken($token->token); + if ($isValid) { + $this->dispatch('success', 'Vultr token is valid.'); + } else { + $this->dispatch('error', 'Vultr token validation failed. Please check the token.'); + } } else { $this->dispatch('error', 'Unknown provider.'); } @@ -97,6 +104,19 @@ private function validateDigitalOceanToken(string $token): bool } } + private function validateVultrToken(string $token): bool + { + try { + $response = Http::withToken($token) + ->timeout(10) + ->get('https://api.vultr.com/v2/account'); + + return $response->successful(); + } catch (\Throwable $e) { + return false; + } + } + public function deleteToken(int $tokenId) { try { diff --git a/app/Livewire/Security/PrivateKey/Create.php b/app/Livewire/Security/PrivateKey/Create.php index 8b7ba73dd..8be1011d5 100644 --- a/app/Livewire/Security/PrivateKey/Create.php +++ b/app/Livewire/Security/PrivateKey/Create.php @@ -43,22 +43,6 @@ protected function messages(): array ); } - public function generateNewRSAKey() - { - $this->generateNewKey('rsa'); - } - - public function generateNewEDKey() - { - $this->generateNewKey('ed25519'); - } - - private function generateNewKey($type) - { - $keyData = PrivateKey::generateNewKeyPair($type); - $this->setKeyData($keyData); - } - public function updated($property) { if ($property === 'value') { @@ -93,14 +77,6 @@ public function createPrivateKey() } } - 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); diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 0362b65fa..540ef5fa1 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -10,6 +10,31 @@ class Index extends Component { use AuthorizesRequests; + public function generatePrivateKey(string $type) + { + try { + $this->authorize('create', PrivateKey::class); + + if (! in_array($type, ['ed25519', 'rsa'], true)) { + $this->dispatch('error', 'Invalid private key type.'); + + return; + } + + $keyData = PrivateKey::generateNewKeyPair($type); + $privateKey = PrivateKey::createAndStore([ + 'name' => $keyData['name'], + 'description' => $keyData['description'], + 'private_key' => $keyData['private_key'], + 'team_id' => currentTeam()->id, + ]); + + return redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { $privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description', 'team_id'])->get(); diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index fa7397d13..826289b88 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -4,6 +4,7 @@ use App\Models\PrivateKey; use App\Support\ValidationPatterns; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -22,8 +23,12 @@ class Show extends Component public bool $isGitRelated = false; + public bool $isInUse = false; + public $public_key = 'Loading...'; + public string $deleteDisabledReason = 'This private key is currently used by a server, application, or Git app and cannot be deleted.'; + protected function rules(): array { return [ @@ -74,16 +79,17 @@ private function syncData(bool $toModel = false): void } } - public function mount() + public function mount(?string $private_key_uuid = null) { try { - $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid(request()->private_key_uuid)->firstOrFail(); + $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid($private_key_uuid ?? request()->private_key_uuid)->firstOrFail(); // Explicit authorization check - will throw 403 if not authorized $this->authorize('view', $this->private_key); $this->syncData(false); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + $this->isInUse = $this->private_key->isInUse(); + } catch (AuthorizationException $e) { abort(403, 'You do not have permission to view this private key.'); } catch (\Throwable) { abort(404); @@ -102,7 +108,15 @@ public function delete() { try { $this->authorize('delete', $this->private_key); - $this->private_key->safeDelete(); + + if ($this->private_key->isInUse()) { + $this->isInUse = true; + $this->dispatch('error', $this->deleteDisabledReason); + + return; + } + + $this->private_key->delete(); currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); return redirectRoute($this, 'security.private-key.index'); diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php index e3232d3f3..a42a68804 100644 --- a/app/Livewire/Server/CloudProviderToken/Show.php +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -18,6 +18,10 @@ class Show extends Component public $parameters = []; + public string $provider = 'hetzner'; + + public string $providerName = 'Hetzner'; + public function mount(string $server_uuid) { try { @@ -31,14 +35,17 @@ public function mount(string $server_uuid) public function getListeners() { return [ - 'tokenAdded' => 'handleTokenAdded', + 'tokenAdded.hetzner' => 'handleTokenAdded', ]; } public function loadTokens() { + $this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner'; + $this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner'; + $this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam() - ->where('provider', 'hetzner') + ->where('provider', $this->provider) ->get(); } @@ -78,7 +85,7 @@ public function setCloudProviderToken($tokenId) 'provider' => $ownedToken->provider, ]); - $this->dispatch('success', 'Hetzner token updated successfully.'); + $this->dispatch('success', "{$this->providerName} token updated successfully."); $this->dispatch('refreshServerShow'); } catch (\Exception $e) { $this->server->refresh(); @@ -89,10 +96,13 @@ public function setCloudProviderToken($tokenId) private function validateTokenForServer(CloudProviderToken $token): array { try { - // First, validate the token itself + $endpoint = $token->provider === 'vultr' + ? 'https://api.vultr.com/v2/account' + : 'https://api.hetzner.cloud/v1/servers'; + $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, - ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + ])->timeout(10)->get($endpoint); if (! $response->successful()) { return [ @@ -101,7 +111,6 @@ private function validateTokenForServer(CloudProviderToken $token): array ]; } - // Check if this token can access the specific Hetzner server if ($this->server->hetzner_server_id) { $serverResponse = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, @@ -115,6 +124,19 @@ private function validateTokenForServer(CloudProviderToken $token): array } } + if ($this->server->vultr_instance_id) { + $serverResponse = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get("https://api.vultr.com/v2/instances/{$this->server->vultr_instance_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this instance. It may belong to a different Vultr account.', + ]; + } + } + return ['valid' => true]; } catch (\Throwable $e) { return [ @@ -129,19 +151,23 @@ public function validateToken() try { $token = $this->server->cloudProviderToken; if (! $token) { - $this->dispatch('error', 'No Hetzner token is associated with this server.'); + $this->dispatch('error', "No {$this->providerName} token is associated with this server."); return; } + $endpoint = $token->provider === 'vultr' + ? 'https://api.vultr.com/v2/account' + : 'https://api.hetzner.cloud/v1/servers'; + $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, - ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + ])->timeout(10)->get($endpoint); if ($response->successful()) { - $this->dispatch('success', 'Hetzner token is valid and working.'); + $this->dispatch('success', "{$this->providerName} token is valid and working."); } else { - $this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.'); + $this->dispatch('error', "{$this->providerName} token is invalid or has insufficient permissions."); } auditLog('ui.server.cloud_token_validated', [ diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index 5fd2ea4f7..7edfcaf72 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -11,12 +11,21 @@ class Create extends Component { public $private_keys = []; + public ?string $selectedType = null; + + public ?string $selectedTokenUuid = null; + public bool $limit_reached = false; public bool $has_hetzner_tokens = false; - public function mount() + public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void { + $this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true) + ? $selectedType + : null; + $this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null; + $this->private_keys = PrivateKey::ownedByCurrentTeamCached(); if (! isCloud()) { $this->limit_reached = false; diff --git a/app/Livewire/Server/CreatePage.php b/app/Livewire/Server/CreatePage.php new file mode 100644 index 000000000..158f28351 --- /dev/null +++ b/app/Livewire/Server/CreatePage.php @@ -0,0 +1,55 @@ +type = $type; + $this->token_uuid = $token_uuid; + $this->tokenProvider = match ($type) { + 'hetzner' => 'hetzner', + 'vultr' => 'vultr', + 'digital-ocean' => 'digitalocean', + default => null, + }; + $this->tokenProviderName = match ($type) { + 'hetzner' => 'Hetzner', + 'vultr' => 'Vultr', + 'digital-ocean' => 'DigitalOcean', + default => null, + }; + $this->hasProviderTokens = $this->tokenProvider + ? CloudProviderToken::ownedByCurrentTeam()->where('provider', $this->tokenProvider)->exists() + : false; + $this->title = match ($type) { + 'hetzner' => 'Hetzner', + 'vultr' => 'Vultr', + 'digital-ocean' => 'DigitalOcean', + 'manual' => 'Manual', + default => 'New Server', + }; + } + + public function render(): View + { + return view('livewire.server.create-page'); + } +} diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index d06543b39..f53339eed 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -16,6 +16,10 @@ class Delete extends Component public bool $delete_from_hetzner = false; + public bool $delete_from_vultr = false; + + public bool $delete_from_digitalocean = false; + public bool $force_delete_resources = false; public function mount(string $server_uuid) @@ -35,6 +39,8 @@ public function delete($password, $selectedActions = []) if (! empty($selectedActions)) { $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); + $this->delete_from_vultr = in_array('delete_from_vultr', $selectedActions); + $this->delete_from_digitalocean = in_array('delete_from_digitalocean', $selectedActions); $this->force_delete_resources = in_array('force_delete_resources', $selectedActions); } try { @@ -57,7 +63,11 @@ public function delete($password, $selectedActions = []) $this->delete_from_hetzner, $this->server->hetzner_server_id, $this->server->cloud_provider_token_id, - $this->server->team_id + $this->server->team_id, + $this->delete_from_vultr, + $this->server->vultr_instance_id, + $this->delete_from_digitalocean, + $this->server->digitalocean_droplet_id ); return redirectRoute($this, 'server.index'); @@ -87,6 +97,22 @@ public function render() ]; } + if ($this->server->vultr_instance_id) { + $checkboxes[] = [ + 'id' => 'delete_from_vultr', + 'label' => 'Also delete server from Vultr', + 'default_warning' => 'The actual server on Vultr will NOT be deleted.', + ]; + } + + if ($this->server->digitalocean_droplet_id) { + $checkboxes[] = [ + 'id' => 'delete_from_digitalocean', + 'label' => 'Also delete droplet from DigitalOcean', + 'default_warning' => 'The actual droplet on DigitalOcean will NOT be deleted.', + ]; + } + return view('livewire.server.delete', [ 'checkboxes' => $checkboxes, ]); diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index cd9cfcba6..73c256cbe 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -39,6 +39,7 @@ public function getListeners() return [ 'refreshServerShow' => 'refreshServer', "echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification', + "echo-private:team.{$teamId},SentinelRestarted" => 'refreshSentinelStatus', ]; } @@ -203,6 +204,15 @@ public function refreshServer() $this->server->load('settings'); } + public function refreshSentinelStatus($event = null): void + { + if (isset($event['serverUuid']) && $event['serverUuid'] !== $this->server->uuid) { + return; + } + + $this->refreshServer(); + } + /** * Check if Traefik has any outdated version info (patch or minor upgrade). * This shows a warning indicator in the navbar. diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php new file mode 100644 index 000000000..cf27f0f08 --- /dev/null +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -0,0 +1,546 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); + $this->loadSavedCloudInitScripts(); + $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function loadSavedCloudInitScripts(): void + { + $this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get(); + } + + public function getListeners(): array + { + return [ + 'tokenAdded.digitalocean' => 'handleTokenAdded', + 'privateKeyCreated' => 'handlePrivateKeyCreated', + 'modalClosed' => 'resetSelection', + ]; + } + + public function resetSelection(): void + { + $this->selected_token_id = null; + $this->current_step = 1; + $this->cloud_init_script = null; + $this->save_cloud_init_script = false; + $this->cloud_init_script_name = null; + $this->selected_cloud_init_script_id = null; + $this->show_cloud_init_script = false; + $this->selectedDigitalOceanSshKeyIds = []; + } + + public function loadTokens(): void + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'digitalocean') + ->get(); + } + + public function handleTokenAdded($tokenId): void + { + $this->loadTokens(); + $this->selected_token_id = $tokenId; + $this->nextStep(); + } + + public function handlePrivateKeyCreated($keyId): void + { + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + $this->private_key_id = $keyId; + $this->resetErrorBag('private_key_id'); + } + + protected function rules(): array + { + $rules = [ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]; + + if ($this->current_step === 2) { + $rules = array_merge($rules, [ + 'server_name' => ['required', 'string', 'max:253', new ValidHostname], + 'selected_region' => 'required|string', + 'selected_image' => 'required', + 'selected_size' => 'required|string', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'selectedDigitalOceanSshKeyIds' => 'nullable|array', + 'selectedDigitalOceanSshKeyIds.*' => 'integer', + 'enable_ipv6' => 'required|boolean', + 'monitoring' => 'required|boolean', + 'show_cloud_init_script' => 'boolean', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'save_cloud_init_script' => 'boolean', + 'cloud_init_script_name' => 'nullable|string|max:255', + 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id', + ]); + } + + return $rules; + } + + protected function messages(): array + { + return [ + 'selected_token_id.required' => 'Please select a DigitalOcean token.', + 'selected_token_id.exists' => 'Selected token not found.', + ]; + } + + public function selectToken(int $tokenId): mixed + { + $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if (! $token) { + return; + } + + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; + } + + private function getDigitalOceanToken(): string + { + if ($this->selected_token_id) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token ? $token->token : ''; + } + + return ''; + } + + public function nextStep() + { + $this->validate([ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]); + + try { + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'digital-ocean', + 'token_uuid' => $token->uuid, + ], navigate: true); + } + } + + $this->current_step = 2; + $this->loading_data = true; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function previousStep(): mixed + { + if ($this->selectedTokenUuid) { + return $this->redirectRoute('server.create.type', ['type' => 'digital-ocean'], navigate: true); + } + + $this->current_step = 1; + + return null; + } + + public function loadDigitalOceanData(): void + { + $token = $this->getDigitalOceanToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid DigitalOcean token.'); + + return; + } + + $this->loading_data = true; + $this->provider_data_error = null; + + try { + $digitalOceanService = new DigitalOceanService($token); + + $this->regions = $digitalOceanService->getRegions(); + $this->sizes = $digitalOceanService->getSizes(); + $this->images = collect($digitalOceanService->getImages()) + ->sortBy(fn (array $image) => ($image['distribution'] ?? '').' '.($image['name'] ?? '')) + ->values() + ->toArray(); + $this->digitalOceanSshKeys = $digitalOceanService->getSshKeys(); + $this->loading_data = false; + } catch (\Throwable $e) { + $this->loading_data = false; + $this->provider_data_error = $this->providerDataErrorMessage('DigitalOcean', $e, 'message'); + $this->dispatch('error', $this->provider_data_error); + } + } + + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + + public function getAvailableSizesProperty(): array + { + if (! $this->selected_region) { + return $this->sizes; + } + + return collect($this->sizes) + ->filter(fn (array $size) => in_array($this->selected_region, $size['regions'] ?? [])) + ->values() + ->toArray(); + } + + public function getAvailableImagesProperty(): array + { + if (! $this->selected_region) { + return $this->images; + } + + return collect($this->images) + ->filter(fn (array $image) => in_array($this->selected_region, $image['regions'] ?? [])) + ->values() + ->toArray(); + } + + public function getSelectedDropletPriceProperty(): ?string + { + if (! $this->selected_size) { + return null; + } + + $size = collect($this->sizes)->firstWhere('slug', $this->selected_size); + if (! $size || ! isset($size['price_monthly'])) { + return null; + } + + return '$'.number_format((float) $size['price_monthly'], 2); + } + + public function updatedSelectedRegion(): void + { + $this->selected_size = null; + $this->selected_image = null; + } + + public function updatedSelectedSize(): void + { + $this->selected_image = null; + } + + public function updatedSelectedCloudInitScriptId($value): void + { + if ($value) { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); + $this->cloud_init_script = $script->script; + $this->cloud_init_script_name = $script->name; + $this->show_cloud_init_script = true; + } + } + + public function updatedSaveCloudInitScript(bool $value): void + { + if (! $value) { + $this->cloud_init_script_name = null; + } + } + + public function showCloudInitScript(): void + { + $this->show_cloud_init_script = true; + } + + public function getAdvancedDigitalOceanOptionsSummaryProperty(): array + { + $summary = []; + + if (count($this->selectedDigitalOceanSshKeyIds) > 0) { + $summary[] = count($this->selectedDigitalOceanSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedDigitalOceanSshKeyIds)); + } + + if (! $this->enable_ipv6) { + $summary[] = 'IPv4 only'; + } + + if (! $this->monitoring) { + $summary[] = 'Monitoring off'; + } + + if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) { + $summary[] = 'Cloud-init'; + } + + return $summary; + } + + public function clearCloudInitScript(): void + { + $this->selected_cloud_init_script_id = null; + $this->cloud_init_script = ''; + $this->cloud_init_script_name = ''; + $this->save_cloud_init_script = false; + $this->show_cloud_init_script = false; + } + + /** + * Create the droplet on DigitalOcean and return the raw droplet payload. + * The public IP may not be assigned yet at this point. + */ + private function createDigitalOceanDroplet(DigitalOceanService $digitalOceanService): array + { + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); + + $sshKeyId = null; + foreach ($digitalOceanService->getSshKeys() as $key) { + if (($key['fingerprint'] ?? null) === $md5Fingerprint) { + $sshKeyId = (int) $key['id']; + break; + } + } + + if (! $sshKeyId) { + $uploadedKey = $digitalOceanService->uploadSshKey($privateKey->name, $privateKey->getPublicKey()); + $sshKeyId = (int) $uploadedKey['id']; + } + + $sshKeys = array_values(array_unique(array_merge( + [$sshKeyId], + $this->selectedDigitalOceanSshKeyIds + ))); + + $params = [ + 'name' => strtolower(trim($this->server_name)), + 'region' => $this->selected_region, + 'size' => $this->selected_size, + 'image' => $this->selected_image, + 'ssh_keys' => $sshKeys, + 'ipv6' => $this->enable_ipv6, + 'monitoring' => $this->monitoring, + ]; + + if (! empty($this->cloud_init_script)) { + $params['user_data'] = $this->cloud_init_script; + } + + return $digitalOceanService->createDroplet($params); + } + + public function submit() + { + $this->validate(); + + $digitalOceanService = null; + $dropletId = null; + $server = null; + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) { + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->cloud_init_script_name, + 'script' => $this->cloud_init_script, + ]); + } + + $digitalOceanService = new DigitalOceanService($this->getDigitalOceanToken()); + $droplet = $this->createDigitalOceanDroplet($digitalOceanService); + $dropletId = (int) $droplet['id']; + + // Persist the server immediately so the droplet is always tracked + // in Coolify, even if waiting for the public IP fails below. + $server = DB::transaction(function () use ($dropletId, $droplet): Server { + $server = Server::create([ + 'name' => strtolower(trim($this->server_name)), + 'ip' => Server::PLACEHOLDER_IP, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $this->private_key_id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'digitalocean_droplet_id' => $dropletId, + 'digitalocean_droplet_status' => $droplet['status'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return $server; + }); + + try { + $droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6); + $ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6); + if ($ipAddress) { + $server->update([ + 'ip' => $ipAddress, + 'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status, + ]); + } + } catch (\Throwable $e) { + // Non-fatal: the server page polling backfills the IP later. + report($e); + } + + if ($this->from_onboarding) { + currentTeam()->update([ + 'show_boarding' => false, + ]); + refreshSession(); + } + + return redirectRoute($this, 'server.show', [$server->uuid]); + } catch (\Throwable $e) { + $this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server); + + return handleError($e, $this); + } + } + + private function deleteUntrackedDroplet(?DigitalOceanService $digitalOceanService, ?int $dropletId, ?Server $server): void + { + if (! $digitalOceanService || ! $dropletId || $server) { + return; + } + + try { + $digitalOceanService->deleteDroplet($dropletId); + } catch (\Throwable $e) { + report($e); + } + } + + public function render() + { + return view('livewire.server.new.by-digital-ocean'); + } +} diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 9ae065d83..1059c6713 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -12,6 +12,7 @@ use App\Rules\ValidHostname; use App\Services\HetznerService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Livewire\Attributes\Locked; @@ -37,6 +38,8 @@ class ByHetzner extends Component // Step 1: Token selection public ?int $selected_token_id = null; + public ?string $selectedTokenUuid = null; + // Step 2: Server configuration public array $locations = []; @@ -46,6 +49,10 @@ class ByHetzner extends Component public array $hetznerSshKeys = []; + public array $hetznerFirewalls = []; + + public array $hetznerNetworks = []; + public ?string $selected_location = null; public ?int $selected_image = null; @@ -54,16 +61,26 @@ class ByHetzner extends Component public array $selectedHetznerSshKeyIds = []; + public array $selectedHetznerFirewallIds = []; + + public array $selectedHetznerNetworkIds = []; + public string $server_name = ''; public ?int $private_key_id = null; public bool $loading_data = false; + public ?string $provider_data_error = null; + public bool $enable_ipv4 = true; public bool $enable_ipv6 = true; + public bool $enable_backups = false; + + public bool $show_cloud_init_script = false; + public ?string $cloud_init_script = null; public bool $save_cloud_init_script = false; @@ -77,11 +94,12 @@ class ByHetzner extends Component public bool $from_onboarding = false; - public function mount() + public function mount(?string $selectedTokenUuid = null) { try { $this->authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); $this->loadSavedCloudInitScripts(); $this->server_name = generate_random_name(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); @@ -89,6 +107,11 @@ public function mount() if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } } catch (\Throwable $e) { return handleError($e, $this); } @@ -102,7 +125,7 @@ public function loadSavedCloudInitScripts() public function getListeners() { return [ - 'tokenAdded' => 'handleTokenAdded', + 'tokenAdded.hetzner' => 'handleTokenAdded', 'privateKeyCreated' => 'handlePrivateKeyCreated', 'modalClosed' => 'resetSelection', ]; @@ -112,10 +135,15 @@ public function resetSelection() { $this->selected_token_id = null; $this->current_step = 1; + $this->enable_backups = false; $this->cloud_init_script = null; $this->save_cloud_init_script = false; $this->cloud_init_script_name = null; $this->selected_cloud_init_script_id = null; + $this->show_cloud_init_script = false; + $this->selectedHetznerSshKeyIds = []; + $this->selectedHetznerFirewallIds = []; + $this->selectedHetznerNetworkIds = []; } public function loadTokens() @@ -164,8 +192,14 @@ protected function rules(): array 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, 'selectedHetznerSshKeyIds' => 'nullable|array', 'selectedHetznerSshKeyIds.*' => 'integer', + 'selectedHetznerFirewallIds' => 'nullable|array', + 'selectedHetznerFirewallIds.*' => 'integer', + 'selectedHetznerNetworkIds' => 'nullable|array', + 'selectedHetznerNetworkIds.*' => 'integer', 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', + 'enable_backups' => 'required|boolean', + 'show_cloud_init_script' => 'boolean', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'save_cloud_init_script' => 'boolean', 'cloud_init_script_name' => 'nullable|string|max:255', @@ -184,9 +218,27 @@ protected function messages(): array ]; } - public function selectToken(int $tokenId) + public function selectToken(int $tokenId): mixed { $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if (! $token) { + return; + } + + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; } private function validateHetznerToken(string $token): bool @@ -221,17 +273,20 @@ public function nextStep() ]); try { - $hetznerToken = $this->getHetznerToken(); + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); - if (! $hetznerToken) { - return $this->dispatch('error', 'Please select a valid Hetzner token.'); + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'hetzner', + 'token_uuid' => $token->uuid, + ], navigate: true); + } } - // Load Hetzner data - $this->loadHetznerData($hetznerToken); - - // Move to step 2 + // Move to step 2; provider data is loaded after initial render via wire:init. $this->current_step = 2; + $this->loading_data = true; } catch (\Throwable $e) { return handleError($e, $this); } @@ -239,12 +294,29 @@ public function nextStep() public function previousStep() { + if ($this->selectedTokenUuid) { + return $this->redirectRoute('server.create.type', ['type' => 'hetzner'], navigate: true); + } + $this->current_step = 1; } - private function loadHetznerData(string $token) + public function loadHetznerData(): void { + $token = $this->getHetznerToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid Hetzner token.'); + + return; + } + $this->loading_data = true; + $this->provider_data_error = null; + $this->selectedHetznerSshKeyIds = []; + $this->selectedHetznerFirewallIds = []; + $this->selectedHetznerNetworkIds = []; try { $hetznerService = new HetznerService($token); @@ -274,13 +346,33 @@ private function loadHetznerData(string $token) ->toArray(); // Load SSH keys from Hetzner $this->hetznerSshKeys = $hetznerService->getSshKeys(); + $this->hetznerFirewalls = collect($hetznerService->getFirewalls()) + ->sortBy('name') + ->values() + ->toArray(); + $this->hetznerNetworks = collect($hetznerService->getNetworks()) + ->sortBy('name') + ->values() + ->toArray(); $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; - throw $e; + $this->provider_data_error = $this->providerDataErrorMessage('Hetzner', $e, 'error.message'); + $this->dispatch('error', $this->provider_data_error); } } + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + private function getCpuVendorInfo(array $serverType): ?string { $name = strtolower($serverType['name'] ?? ''); @@ -349,6 +441,37 @@ public function getAvailableImagesProperty() return $filtered; } + public function getAvailableNetworksProperty(): array + { + $attachableNetworks = collect($this->hetznerNetworks) + ->filter(function (array $network) { + return collect($network['subnets'] ?? [])->contains(function (array $subnet) { + return in_array($subnet['type'] ?? null, ['cloud', 'server'], true); + }); + }); + + if (! $this->selected_location) { + return $attachableNetworks->values()->toArray(); + } + + $location = collect($this->locations)->firstWhere('name', $this->selected_location); + $networkZone = $location['network_zone'] ?? null; + + if (! $networkZone) { + return $attachableNetworks->values()->toArray(); + } + + return $attachableNetworks + ->filter(function (array $network) use ($networkZone) { + return collect($network['subnets'] ?? [])->contains(function (array $subnet) use ($networkZone) { + return in_array($subnet['type'] ?? null, ['cloud', 'server'], true) + && ($subnet['network_zone'] ?? null) === $networkZone; + }); + }) + ->values() + ->toArray(); + } + public function getSelectedServerPriceProperty(): ?string { if (! $this->selected_server_type) { @@ -366,11 +489,74 @@ public function getSelectedServerPriceProperty(): ?string return '€'.number_format($price, 2); } + public function getSelectedServerBackupSurchargeProperty(): ?string + { + if (! $this->selected_server_type) { + return null; + } + + $serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); + + if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) { + return null; + } + + $price = (float) $serverType['prices'][0]['price_monthly']['gross']; + + return '€'.number_format($price * 0.2, 2); + } + + public function getAdvancedHetznerOptionsSummaryProperty(): array + { + $summary = []; + + if (count($this->selectedHetznerSshKeyIds) > 0) { + $summary[] = count($this->selectedHetznerSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedHetznerSshKeyIds)); + } + + if (count($this->selectedHetznerFirewallIds) > 0) { + $summary[] = count($this->selectedHetznerFirewallIds).' '.str('firewall')->plural(count($this->selectedHetznerFirewallIds)); + } + + if (count($this->selectedHetznerNetworkIds) > 0) { + $summary[] = count($this->selectedHetznerNetworkIds).' private '.str('network')->plural(count($this->selectedHetznerNetworkIds)); + } + + if ($this->enable_backups) { + $summary[] = 'Backups on'; + } + + if (! $this->enable_ipv4 || ! $this->enable_ipv6) { + $summary[] = collect([ + $this->enable_ipv4 ? 'IPv4' : null, + $this->enable_ipv6 ? 'IPv6' : null, + ])->filter()->join(' + ') ?: 'No public IP'; + } + + if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) { + $summary[] = 'Cloud-init'; + } + + return $summary; + } + + public function showCloudInitScript(): void + { + $this->show_cloud_init_script = true; + } + public function updatedSelectedLocation($value) { // Reset server type and image when location changes $this->selected_server_type = null; $this->selected_image = null; + + $this->selectedHetznerNetworkIds = array_values(array_filter( + $this->selectedHetznerNetworkIds, + function (int $selectedNetworkId): bool { + return collect($this->availableNetworks)->contains('id', $selectedNetworkId); + } + )); } public function updatedSelectedServerType($value) @@ -390,6 +576,14 @@ public function updatedSelectedCloudInitScriptId($value) $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); $this->cloud_init_script = $script->script; $this->cloud_init_script_name = $script->name; + $this->show_cloud_init_script = true; + } + } + + public function updatedSaveCloudInitScript(bool $value): void + { + if (! $value) { + $this->cloud_init_script_name = null; } } @@ -399,12 +593,11 @@ public function clearCloudInitScript() $this->cloud_init_script = ''; $this->cloud_init_script_name = ''; $this->save_cloud_init_script = false; + $this->show_cloud_init_script = false; } - private function createHetznerServer(string $token): array + private function createHetznerServer(HetznerService $hetznerService): array { - $hetznerService = new HetznerService($token); - // Get the private key and extract public key $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); @@ -458,6 +651,18 @@ private function createHetznerServer(string $token): array ], ]; + $firewallIds = array_values(array_unique($this->selectedHetznerFirewallIds)); + if ($firewallIds !== []) { + $params['firewalls'] = array_map(function (int $firewallId): array { + return ['firewall' => $firewallId]; + }, $firewallIds); + } + + $networkIds = array_values(array_unique($this->selectedHetznerNetworkIds)); + if ($networkIds !== []) { + $params['networks'] = $networkIds; + } + // Add cloud-init script if provided if (! empty($this->cloud_init_script)) { $params['user_data'] = $this->cloud_init_script; @@ -473,6 +678,13 @@ public function submit() { $this->validate(); + if (! $this->enable_ipv4 && ! $this->enable_ipv6) { + $this->addError('enable_ipv4', 'Enable at least one public IP protocol.'); + $this->addError('enable_ipv6', 'Enable at least one public IP protocol.'); + + return null; + } + try { $this->authorize('create', Server::class); @@ -492,9 +704,10 @@ public function submit() } $hetznerToken = $this->getHetznerToken(); + $hetznerService = new HetznerService($hetznerToken); // Create server on Hetzner - $hetznerServer = $this->createHetznerServer($hetznerToken); + $hetznerServer = $this->createHetznerServer($hetznerService); // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; @@ -504,26 +717,33 @@ public function submit() $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; } - if (! $ipAddress) { - throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.'); - } - - // Create server in Coolify database + // Create server in Coolify database immediately so the Hetzner + // server is always tracked, even when no IP is assigned yet — + // the server page polling backfills the placeholder IP later. $server = Server::create([ 'name' => $this->server_name, - 'ip' => $ipAddress, + 'ip' => $ipAddress ?? Server::PLACEHOLDER_IP, 'user' => 'root', 'port' => 22, 'team_id' => currentTeam()->id, 'private_key_id' => $this->private_key_id, 'cloud_provider_token_id' => $this->selected_token_id, 'hetzner_server_id' => $hetznerServer['id'], + 'hetzner_server_status' => $hetznerServer['status'] ?? null, ]); $server->proxy->set('status', 'exited'); $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + if ($this->enable_backups) { + try { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } catch (\Throwable $e) { + report($e); + } + } + if ($this->from_onboarding) { // Complete the boarding when server is successfully created via Hetzner currentTeam()->update([ diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index f5ea2ae80..a85306f6b 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server\New; use App\Enums\ProxyTypes; +use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; use App\Rules\ValidServerIp; @@ -84,11 +85,51 @@ protected function messages(): array ]); } + public function getListeners(): array + { + return [ + 'privateKeyCreated' => 'handlePrivateKeyCreated', + ]; + } + public function setPrivateKey(string $private_key_id) { $this->private_key_id = $private_key_id; } + public function generatePrivateKey(string $type): void + { + try { + $this->authorize('create', PrivateKey::class); + + if (! in_array($type, ['ed25519', 'rsa'], true)) { + $this->dispatch('error', 'Invalid private key type.'); + + return; + } + + $keyData = PrivateKey::generateNewKeyPair($type); + $privateKey = PrivateKey::createAndStore([ + 'name' => $keyData['name'], + 'description' => $keyData['description'], + 'private_key' => $keyData['private_key'], + 'team_id' => currentTeam()->id, + ]); + + $this->handlePrivateKeyCreated($privateKey->id); + $this->dispatch('success', 'Private key created successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function handlePrivateKeyCreated($keyId): void + { + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + $this->private_key_id = $keyId; + $this->resetErrorBag('private_key_id'); + } + public function instantSave() { // $this->dispatch('success', 'Application settings updated!'); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php new file mode 100644 index 000000000..fd0aa4c34 --- /dev/null +++ b/app/Livewire/Server/New/ByVultr.php @@ -0,0 +1,521 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); + $this->loadSavedCloudInitScripts(); + $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } + } + + public function getListeners(): array + { + return [ + 'tokenAdded' => 'handleTokenAdded', + 'privateKeyCreated' => 'handlePrivateKeyCreated', + 'modalClosed' => 'resetSelection', + ]; + } + + public function loadTokens(): void + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'vultr') + ->get(); + } + + public function loadSavedCloudInitScripts(): void + { + $this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get(); + } + + public function resetSelection(): void + { + $this->selected_token_id = null; + $this->current_step = 1; + $this->cloud_init_script = null; + $this->save_cloud_init_script = false; + $this->cloud_init_script_name = null; + $this->selected_cloud_init_script_id = null; + } + + public function handleTokenAdded($tokenId): void + { + $this->loadTokens(); + $this->selected_token_id = $tokenId; + $this->nextStep(); + } + + public function handlePrivateKeyCreated($keyId): void + { + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + $this->private_key_id = $keyId; + $this->resetErrorBag('private_key_id'); + } + + protected function rules(): array + { + $rules = [ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]; + + if ($this->current_step === 2) { + $rules = array_merge($rules, [ + 'server_name' => ['required', 'string', 'max:253', new ValidHostname], + 'selected_region' => 'required|string', + 'selected_plan' => 'required|string', + 'selected_os_id' => 'required|integer', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'selectedVultrSshKeyIds' => 'nullable|array', + 'selectedVultrSshKeyIds.*' => 'string', + 'enable_ipv6' => 'required|boolean', + 'disable_public_ipv4' => 'required|boolean', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'save_cloud_init_script' => 'boolean', + 'cloud_init_script_name' => 'nullable|string|max:255', + 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id', + ]); + } + + return $rules; + } + + protected function messages(): array + { + return [ + 'selected_token_id.required' => 'Please select a Vultr token.', + 'selected_token_id.exists' => 'Selected token not found.', + ]; + } + + public function selectToken(int $tokenId): mixed + { + $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if (! $token) { + return; + } + + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; + } + + public function nextStep(): mixed + { + $this->validate([ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]); + + try { + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'vultr', + 'token_uuid' => $token->uuid, + ], navigate: true); + } + } + + $this->current_step = 2; + $this->loading_data = true; + } catch (\Throwable $e) { + return handleError($e, $this); + } + + return null; + } + + public function previousStep(): mixed + { + if ($this->selectedTokenUuid) { + return $this->redirectRoute('server.create.type', ['type' => 'vultr'], navigate: true); + } + + $this->current_step = 1; + + return null; + } + + public function updatedSelectedRegion(): void + { + $this->selected_plan = null; + } + + public function updatedSelectedCloudInitScriptId($value): void + { + if ($value) { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); + $this->cloud_init_script = $script->script; + $this->cloud_init_script_name = $script->name; + } + } + + public function clearCloudInitScript(): void + { + $this->selected_cloud_init_script_id = null; + $this->cloud_init_script = ''; + $this->cloud_init_script_name = ''; + $this->save_cloud_init_script = false; + } + + public function getAvailablePlansProperty(): array + { + if (! $this->selected_region) { + return $this->plans; + } + + return collect($this->plans) + ->filter(function ($plan) { + $locations = $plan['locations'] ?? []; + + return empty($locations) || in_array($this->selected_region, $locations); + }) + ->values() + ->toArray(); + } + + public function getSelectedServerPriceProperty(): ?string + { + if (! $this->selected_plan) { + return null; + } + + $plan = collect($this->plans)->firstWhere('id', $this->selected_plan); + $monthlyCost = $plan['monthly_cost'] ?? null; + + if ($monthlyCost === null) { + return null; + } + + return '$'.number_format((float) $monthlyCost, 2); + } + + public function getAdvancedVultrOptionsSummaryProperty(): array + { + $summary = []; + + if (count($this->selectedVultrSshKeyIds) > 0) { + $summary[] = count($this->selectedVultrSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedVultrSshKeyIds)); + } + + if (! $this->enable_ipv6) { + $summary[] = 'IPv6 disabled'; + } + + if ($this->disable_public_ipv4) { + $summary[] = 'Public IPv4 disabled'; + } + + if (! empty($this->cloud_init_script)) { + $summary[] = 'cloud-init'; + } + + return $summary; + } + + private function getVultrToken(): string + { + if ($this->selected_token_id) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token ? $token->token : ''; + } + + return ''; + } + + public function loadVultrData(): void + { + $token = $this->getVultrToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid Vultr token.'); + + return; + } + + $this->loading_data = true; + $this->provider_data_error = null; + + try { + $vultrService = new VultrService($token); + + $this->regions = collect($vultrService->getRegions()) + ->sortBy('id') + ->values() + ->toArray(); + + $this->plans = collect($vultrService->getPlans()) + ->sortBy('monthly_cost') + ->values() + ->toArray(); + + $this->operatingSystems = collect($vultrService->getOperatingSystems()) + ->sortBy('name') + ->values() + ->toArray(); + + $this->vultrSshKeys = $vultrService->getSshKeys(); + $this->loading_data = false; + } catch (\Throwable $e) { + $this->loading_data = false; + $this->provider_data_error = $this->providerDataErrorMessage('Vultr', $e, 'error'); + $this->dispatch('error', $this->provider_data_error); + } + } + + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + + private function createVultrServer(string $token): array + { + $vultrService = new VultrService($token); + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $publicKey = $privateKey->getPublicKey(); + $existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey); + + if ($existingKey) { + $sshKeyId = $existingKey['id']; + } else { + $uploadedKey = $vultrService->uploadSshKey($privateKey->name, $publicKey); + $sshKeyId = $uploadedKey['id']; + } + + $sshKeys = array_values(array_unique(array_merge([$sshKeyId], $this->selectedVultrSshKeyIds))); + $normalizedServerName = strtolower(trim($this->server_name)); + + $params = [ + 'region' => $this->selected_region, + 'plan' => $this->selected_plan, + 'os_id' => $this->selected_os_id, + 'label' => $normalizedServerName, + 'hostname' => $normalizedServerName, + 'sshkey_id' => $sshKeys, + 'enable_ipv6' => $this->enable_ipv6, + 'disable_public_ipv4' => $this->disable_public_ipv4, + ]; + + if (! empty($this->cloud_init_script)) { + $params['user_data'] = $this->cloud_init_script; + } + + return $vultrService->createInstance($params); + } + + public function submit(): mixed + { + $this->validate(); + if (! $this->hasValidPublicNetworkConfiguration()) { + return null; + } + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) { + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->cloud_init_script_name, + 'script' => $this->cloud_init_script, + ]); + } + + $vultrService = new VultrService($this->getVultrToken()); + $vultrInstance = $this->createVultrServer($this->getVultrToken()); + $ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP; + + $server = Server::create([ + 'name' => strtolower(trim($this->server_name)), + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $this->private_key_id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'vultr_instance_id' => $vultrInstance['id'], + 'vultr_instance_status' => $vultrInstance['status'] ?? null, + ]); + + try { + $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); + $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); + if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { + $server->update([ + 'ip' => $assignedIpAddress, + 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, + ]); + } + } catch (\Throwable $e) { + // Non-fatal: the server page polling backfills the IP later. + report($e); + } + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + if ($this->from_onboarding) { + currentTeam()->update([ + 'show_boarding' => false, + ]); + refreshSession(); + } + + return redirectRoute($this, 'server.show', [$server->uuid]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.new.by-vultr'); + } + + private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array + { + $normalizedPublicKey = $this->normalizePublicKey($publicKey); + + foreach ($sshKeys as $sshKey) { + if ($this->normalizePublicKey($sshKey['ssh_key'] ?? '') === $normalizedPublicKey) { + return $sshKey; + } + } + + return null; + } + + private function normalizePublicKey(string $publicKey): string + { + $parts = preg_split('/\s+/', trim($publicKey)); + + return implode(' ', array_slice($parts ?: [], 0, 2)); + } + + private function hasValidPublicNetworkConfiguration(): bool + { + if (! $this->disable_public_ipv4 || $this->enable_ipv6) { + return true; + } + + $this->addError('enable_ipv6', 'Enable IPv6 when disabling public IPv4.'); + + return false; + } +} diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index 810b95ed4..54f3436dc 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -55,6 +55,33 @@ public function setPrivateKey($privateKeyId) } } + public function generatePrivateKey(string $type): void + { + try { + $this->authorize('create', PrivateKey::class); + + if (! in_array($type, ['ed25519', 'rsa'], true)) { + $this->dispatch('error', 'Invalid private key type.'); + + return; + } + + $keyData = PrivateKey::generateNewKeyPair($type); + $privateKey = PrivateKey::createAndStore([ + 'name' => $keyData['name'], + 'description' => $keyData['description'], + 'private_key' => $keyData['private_key'], + 'team_id' => currentTeam()->id, + ]); + + $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false); + $this->dispatch('copyPublicKeyToClipboard', publicKey: $privateKey->public_key); + $this->dispatch('success', 'Private key created successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function checkConnection() { try { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index d14046ed1..6cd004362 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -8,7 +8,9 @@ use App\Models\CloudProviderToken; use App\Models\Server; use App\Rules\ValidServerIp; +use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\VultrService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; @@ -75,8 +77,16 @@ class Show extends Component public ?string $hetznerServerStatus = null; + public ?string $vultrInstanceStatus = null; + + public ?string $digitalOceanDropletStatus = null; + public bool $hetznerServerManuallyStarted = false; + public bool $vultrInstanceManuallyStarted = false; + + public bool $digitalOceanDropletManuallyStarted = false; + public bool $isValidating = false; // Hetzner linking properties @@ -92,6 +102,30 @@ class Show extends Component public bool $hetznerNoMatchFound = false; + public Collection $availableVultrTokens; + + public ?int $selectedVultrTokenId = null; + + public ?string $manualVultrInstanceId = null; + + public ?array $matchedVultrInstance = null; + + public ?string $vultrSearchError = null; + + public bool $vultrNoMatchFound = false; + + public Collection $availableDigitalOceanTokens; + + public ?int $selectedDigitalOceanTokenId = null; + + public ?string $manualDigitalOceanDropletId = null; + + public ?array $matchedDigitalOceanDroplet = null; + + public ?string $digitalOceanSearchError = null; + + public bool $digitalOceanNoMatchFound = false; + public function getListeners() { $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; @@ -173,10 +207,14 @@ public function mount(string $server_uuid) } // Load saved Hetzner status and validation state $this->hetznerServerStatus = $this->server->hetzner_server_status; + $this->vultrInstanceStatus = $this->server->vultr_instance_status; + $this->digitalOceanDropletStatus = $this->server->digitalocean_droplet_status; $this->isValidating = $this->server->is_validating ?? false; - // Load Hetzner tokens for linking + // Load cloud provider tokens for linking $this->loadHetznerTokens(); + $this->loadVultrTokens(); + $this->loadDigitalOceanTokens(); } catch (\Throwable $e) { return handleError($e, $this); @@ -289,6 +327,22 @@ public function validateServer($install = true) { try { $this->authorize('update', $this->server); + if ($this->server->vultr_instance_id) { + $status = $this->server->refreshVultrState(); + $this->server->refresh(); + $this->vultrInstanceStatus = $this->server->vultr_instance_status; + $this->ip = $this->server->ip; + + if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) { + $message = $status === 'deleted' + ? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.' + : 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.'; + $this->dispatch('error', $message); + + return; + } + } + $this->validationLogs = $this->server->validation_logs = null; $this->server->save(); $this->dispatch('init', $install); @@ -434,6 +488,11 @@ public function checkHetznerServerStatus(bool $manual = false) $this->server->hetzner_server_status = $this->hetznerServerStatus; $this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]); } + + $assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? data_get($serverData, 'public_net.ipv6.ip'); + if ($this->server->backfillPlaceholderIp($assignedIp)) { + $this->ip = $this->server->ip; + } if ($manual) { $this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown')); } @@ -458,6 +517,49 @@ public function checkHetznerServerStatus(bool $manual = false) } } + public function checkVultrInstanceStatus(bool $manual = false) + { + try { + if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Vultr instance or token.'); + + return; + } + + $this->vultrInstanceStatus = $this->server->refreshVultrState(); + $this->server->refresh(); + $this->ip = $this->server->ip; + + if ($manual) { + $this->dispatch('success', 'Instance status refreshed: '.ucfirst($this->vultrInstanceStatus ?? 'unknown')); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function checkDigitalOceanDropletStatus(bool $manual = false) + { + try { + $this->authorize('view', $this->server); + if (! $this->server->digitalocean_droplet_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a DigitalOcean droplet or token.'); + + return; + } + + $this->digitalOceanDropletStatus = $this->server->refreshDigitalOceanState(); + $this->server->refresh(); + $this->ip = $this->server->ip; + + if ($manual) { + $this->dispatch('success', 'Droplet status refreshed: '.ucfirst($this->digitalOceanDropletStatus ?? 'unknown')); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function handleServerValidated($event = null) { // Check if event is for this server @@ -477,6 +579,8 @@ public function handleServerValidated($event = null) // Reload Hetzner tokens in case the linking section should now be shown $this->loadHetznerTokens(); + $this->loadVultrTokens(); + $this->loadDigitalOceanTokens(); $this->dispatch('refreshServerShow'); $this->dispatch('refreshServer'); @@ -504,6 +608,50 @@ public function startHetznerServer() } } + public function startVultrInstance() + { + try { + $this->authorize('update', $this->server); + if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Vultr instance or token.'); + + return; + } + + $vultrService = new VultrService($this->server->cloudProviderToken->token); + $vultrService->startInstance($this->server->vultr_instance_id); + + $this->vultrInstanceStatus = 'starting'; + $this->server->update(['vultr_instance_status' => 'starting']); + $this->vultrInstanceManuallyStarted = true; + $this->dispatch('success', 'Vultr instance is starting...'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function startDigitalOceanDroplet() + { + try { + $this->authorize('update', $this->server); + if (! $this->server->digitalocean_droplet_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a DigitalOcean droplet or token.'); + + return; + } + + $digitalOceanService = new DigitalOceanService($this->server->cloudProviderToken->token); + $digitalOceanService->powerOnDroplet((int) $this->server->digitalocean_droplet_id); + + $this->digitalOceanDropletStatus = 'new'; + $this->server->update(['digitalocean_droplet_status' => 'new']); + $this->digitalOceanDropletManuallyStarted = true; + $this->dispatch('success', 'DigitalOcean droplet is starting...'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function refreshServerMetadata(): void { try { @@ -537,6 +685,34 @@ public function loadHetznerTokens(): void ->get(); } + public function loadVultrTokens(): void + { + $this->availableVultrTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'vultr') + ->get(); + } + + public function loadDigitalOceanTokens(): void + { + $this->availableDigitalOceanTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'digitalocean') + ->get(); + } + + #[Computed] + public function limaStartCommand(): ?string + { + if (! isDev()) { + return null; + } + + return match ($this->server->uuid) { + 'lima-ubuntu-2404' => 'limactl start --yes --name=coolify-lima-ubuntu-2404 docker/lima/ubuntu-2404.yaml', + 'lima-ubuntu-2604' => 'limactl start --yes --name=coolify-lima-ubuntu-2604 docker/lima/ubuntu-2604.yaml', + default => null, + }; + } + public function searchHetznerServer(): void { $this->hetznerSearchError = null; @@ -658,6 +834,263 @@ public function linkToHetzner() $this->hetznerSearchError = null; $this->dispatch('success', 'Server successfully linked to Hetzner Cloud!'); + $this->dispatch('close-modal'); + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function searchDigitalOceanDroplet(): void + { + $this->digitalOceanSearchError = null; + $this->digitalOceanNoMatchFound = false; + $this->matchedDigitalOceanDroplet = null; + + if (! $this->selectedDigitalOceanTokenId) { + $this->digitalOceanSearchError = 'Please select a DigitalOcean token.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId); + if (! $token) { + $this->digitalOceanSearchError = 'Invalid token selected.'; + + return; + } + + $digitalOceanService = new DigitalOceanService($token->token); + $matched = $digitalOceanService->findDropletByIp($this->server->ip); + + if ($matched) { + $this->matchedDigitalOceanDroplet = $matched; + } else { + $this->digitalOceanNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->digitalOceanSearchError = 'Failed to search DigitalOcean droplets: '.$e->getMessage(); + } + } + + public function searchDigitalOceanDropletById(): void + { + $this->digitalOceanSearchError = null; + $this->digitalOceanNoMatchFound = false; + $this->matchedDigitalOceanDroplet = null; + + if (! $this->selectedDigitalOceanTokenId) { + $this->digitalOceanSearchError = 'Please select a DigitalOcean token first.'; + + return; + } + + if (! $this->manualDigitalOceanDropletId) { + $this->digitalOceanSearchError = 'Please enter a DigitalOcean Droplet ID.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId); + if (! $token) { + $this->digitalOceanSearchError = 'Invalid token selected.'; + + return; + } + + $digitalOceanService = new DigitalOceanService($token->token); + $dropletData = $digitalOceanService->getDroplet((int) $this->manualDigitalOceanDropletId); + + if (! empty($dropletData)) { + $this->matchedDigitalOceanDroplet = $dropletData; + } else { + $this->digitalOceanNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->digitalOceanSearchError = 'Failed to fetch DigitalOcean droplet: '.$e->getMessage(); + } + } + + public function linkToDigitalOcean() + { + if (! $this->matchedDigitalOceanDroplet) { + $this->dispatch('error', 'No DigitalOcean droplet selected.'); + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId); + if (! $token) { + $this->dispatch('error', 'Invalid token selected.'); + + return; + } + + $digitalOceanService = new DigitalOceanService($token->token); + $dropletData = $digitalOceanService->getDroplet((int) $this->matchedDigitalOceanDroplet['id']); + + if (empty($dropletData)) { + $this->dispatch('error', 'Could not find DigitalOcean droplet with ID: '.$this->matchedDigitalOceanDroplet['id']); + + return; + } + + $ip = $digitalOceanService->getPublicIpAddress($dropletData); + $updates = [ + 'cloud_provider_token_id' => $this->selectedDigitalOceanTokenId, + 'digitalocean_droplet_id' => $this->matchedDigitalOceanDroplet['id'], + 'digitalocean_droplet_status' => $dropletData['status'] ?? null, + ]; + + if ($ip) { + $updates['ip'] = $ip; + } + + $this->server->update($updates); + $this->digitalOceanDropletStatus = $dropletData['status'] ?? null; + + $this->matchedDigitalOceanDroplet = null; + $this->selectedDigitalOceanTokenId = null; + $this->manualDigitalOceanDropletId = null; + $this->digitalOceanNoMatchFound = false; + $this->digitalOceanSearchError = null; + + $this->dispatch('success', 'Server successfully linked to DigitalOcean!'); + $this->dispatch('close-modal'); + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function searchVultrInstance(): void + { + $this->vultrSearchError = null; + $this->vultrNoMatchFound = false; + $this->matchedVultrInstance = null; + + if (! $this->selectedVultrTokenId) { + $this->vultrSearchError = 'Please select a Vultr token.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId); + if (! $token) { + $this->vultrSearchError = 'Invalid token selected.'; + + return; + } + + $vultrService = new VultrService($token->token); + $matched = $vultrService->findInstanceByIp($this->server->ip); + + if ($matched) { + $this->matchedVultrInstance = $matched; + } else { + $this->vultrNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->vultrSearchError = 'Failed to search Vultr instances: '.$e->getMessage(); + } + } + + public function searchVultrInstanceById(): void + { + $this->vultrSearchError = null; + $this->vultrNoMatchFound = false; + $this->matchedVultrInstance = null; + + if (! $this->selectedVultrTokenId) { + $this->vultrSearchError = 'Please select a Vultr token first.'; + + return; + } + + if (! $this->manualVultrInstanceId) { + $this->vultrSearchError = 'Please enter a Vultr Instance ID.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId); + if (! $token) { + $this->vultrSearchError = 'Invalid token selected.'; + + return; + } + + $vultrService = new VultrService($token->token); + $instanceData = $vultrService->getInstance($this->manualVultrInstanceId); + + if (! empty($instanceData)) { + $this->matchedVultrInstance = $instanceData; + } else { + $this->vultrNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->vultrSearchError = 'Failed to fetch Vultr instance: '.$e->getMessage(); + } + } + + public function linkToVultr() + { + if (! $this->matchedVultrInstance) { + $this->dispatch('error', 'No Vultr instance selected.'); + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId); + if (! $token) { + $this->dispatch('error', 'Invalid token selected.'); + + return; + } + + $vultrService = new VultrService($token->token); + $instanceData = $vultrService->getInstance($this->matchedVultrInstance['id']); + + if (empty($instanceData)) { + $this->dispatch('error', 'Could not find Vultr instance with ID: '.$this->matchedVultrInstance['id']); + + return; + } + + $this->server->update([ + 'cloud_provider_token_id' => $this->selectedVultrTokenId, + 'vultr_instance_id' => $this->matchedVultrInstance['id'], + 'vultr_instance_status' => $instanceData['status'] ?? null, + ]); + + $this->vultrInstanceStatus = $instanceData['status'] ?? null; + + $this->matchedVultrInstance = null; + $this->selectedVultrTokenId = null; + $this->manualVultrInstanceId = null; + $this->vultrNoMatchFound = false; + $this->vultrSearchError = null; + + $this->dispatch('success', 'Server successfully linked to Vultr!'); + $this->dispatch('close-modal'); $this->dispatch('refreshServerShow'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index afcc918a6..c7181ebcf 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -92,6 +92,38 @@ public function validateConnection() { try { $this->authorize('update', $this->server); + if ($this->server->vultr_instance_id) { + $status = $this->server->refreshVultrState(); + $this->server->refresh(); + + if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) { + $this->error = $status === 'deleted' + ? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.' + : 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.'; + $this->server->update([ + 'validation_logs' => $this->error, + ]); + + return; + } + } + + if ($this->server->digitalocean_droplet_id) { + $status = $this->server->refreshDigitalOceanState(); + $this->server->refresh(); + + if (in_array($status, ['off', 'archive', 'deleted'], true)) { + $this->error = $status === 'deleted' + ? 'DigitalOcean droplet is deleted or no longer accessible. Relink this server before validating.' + : 'DigitalOcean droplet is '.($status ?? 'not running').'. Power it on before validating.'; + $this->server->update([ + 'validation_logs' => $this->error, + ]); + + return; + } + } + ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); if (! $this->uptime) { $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 8bc7ed306..4e23abed7 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -46,6 +46,11 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_mcp_server_enabled; + public ?string $webhook_allowed_internal_hosts = null; + + #[Validate('boolean')] + public bool $webhook_allow_localhost; + public function rules() { return [ @@ -60,6 +65,8 @@ public function rules() 'disable_two_step_confirmation' => 'boolean', 'is_wire_navigate_enabled' => 'boolean', 'is_mcp_server_enabled' => 'boolean', + 'webhook_allowed_internal_hosts' => 'nullable|string', + 'webhook_allow_localhost' => 'boolean', ]; } @@ -80,6 +87,8 @@ public function mount() $this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled; $this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true; $this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false; + $this->webhook_allowed_internal_hosts = collect($this->settings->webhook_allowed_internal_hosts ?? [])->implode(','); + $this->webhook_allow_localhost = $this->settings->webhook_allow_localhost ?? false; } public function submit() @@ -146,13 +155,21 @@ public function submit() $this->allowed_ips = implode(',', $validEntries); } - $this->instantSave(); + $webhookAllowedInternalHosts = $this->normalizeWebhookAllowedInternalHosts(); + if ($webhookAllowedInternalHosts === false) { + return; + } + + $this->instantSave($webhookAllowedInternalHosts); } catch (\Exception $e) { return handleError($e, $this); } } - public function instantSave() + /** + * @param array|null $webhookAllowedInternalHosts + */ + public function instantSave(?array $webhookAllowedInternalHosts = null) { try { $this->authorize('update', $this->settings); @@ -167,6 +184,8 @@ public function instantSave() $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation; $this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled; $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; + $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; + $this->settings->webhook_allow_localhost = $this->webhook_allow_localhost; $this->settings->save(); $this->dispatch('success', 'Settings updated!'); } catch (\Exception $e) { @@ -174,6 +193,49 @@ public function instantSave() } } + /** + * @return array|false + */ + private function normalizeWebhookAllowedInternalHosts(): array|false + { + $entries = collect(preg_split('/[,\r\n]+/', $this->webhook_allowed_internal_hosts ?? '') ?: []) + ->map(fn (string $entry): string => rtrim(strtolower(trim($entry)), '.')) + ->filter() + ->unique() + ->values(); + + $invalidEntries = $entries->reject(fn (string $entry): bool => $this->isValidWebhookAllowlistEntry($entry)); + if ($invalidEntries->isNotEmpty()) { + $this->dispatch('error', 'Invalid webhook internal allowlist entries: '.$invalidEntries->implode(', ')); + + return false; + } + + $this->webhook_allowed_internal_hosts = $entries->implode(','); + + return $entries->all(); + } + + private function isValidWebhookAllowlistEntry(string $entry): bool + { + if (filter_var($entry, FILTER_VALIDATE_IP)) { + return true; + } + + if (str_contains($entry, '/')) { + [$ip, $mask] = array_pad(explode('/', $entry, 2), 2, null); + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + $maxMask = $isIpv6 ? 128 : 32; + + return filter_var($ip, FILTER_VALIDATE_IP) !== false + && is_numeric($mask) + && (int) $mask >= 0 + && (int) $mask <= $maxMask; + } + + return filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; + } + public function toggleRegistration($password): bool { if (! verifyPasswordConfirmation($password, $this)) { diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 9cfc4f6c8..6ed8c7ae8 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -47,8 +47,6 @@ class Index extends Component public bool $forceSaveDomains = false; - public $buildActivityId = null; - protected array $messages = [ 'fqdn.url' => 'Invalid instance URL.', 'fqdn.max' => 'URL must not exceed 255 characters.', diff --git a/app/Livewire/Settings/Updates.php b/app/Livewire/Settings/Updates.php index 88a8945d0..856b7e2a4 100644 --- a/app/Livewire/Settings/Updates.php +++ b/app/Livewire/Settings/Updates.php @@ -6,6 +6,8 @@ use App\Models\InstanceSettings; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Log; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Validate; use Livewire\Component; @@ -26,6 +28,9 @@ class Updates extends Component #[Validate('boolean')] public bool $is_auto_update_enabled; + #[Validate('required|string|in:docker.io,ghcr.io')] + public string $docker_registry_url; + public function mount() { if (! isInstanceAdmin()) { @@ -39,6 +44,7 @@ public function mount() $this->auto_update_frequency = $this->settings->auto_update_frequency; $this->update_check_frequency = $this->settings->update_check_frequency; $this->is_auto_update_enabled = $this->settings->is_auto_update_enabled; + $this->docker_registry_url = $this->settings->docker_registry_url ?: 'docker.io'; } public function instantSave() @@ -50,16 +56,51 @@ public function instantSave() 'auto_update_frequency' => ['required', 'string'], ]); } + $validated = $this->validate([ + 'docker_registry_url' => ['required', 'string', 'in:docker.io,ghcr.io'], + ]); $this->settings->auto_update_frequency = $this->auto_update_frequency; $this->settings->update_check_frequency = $this->update_check_frequency; $this->settings->is_auto_update_enabled = $this->is_auto_update_enabled; + $this->settings->docker_registry_url = $validated['docker_registry_url']; + $this->syncRegistryUrlToEnv($validated['docker_registry_url']); $this->settings->save(); $this->dispatch('success', 'Settings updated!'); + } catch (ValidationException $e) { + throw $e; } catch (\Exception $e) { return handleError($e, $this); } } + protected function syncRegistryUrlToEnv(string $registryUrl): void + { + if (! $this->server) { + return; + } + + try { + instant_remote_process([ + $this->registryEnvSyncCommand($registryUrl), + ], $this->server); + } catch (\Exception $e) { + Log::warning('Failed to sync REGISTRY_URL to .env', [ + 'error' => $e->getMessage(), + ]); + + throw new \RuntimeException('Failed to sync REGISTRY_URL to .env. Settings were not saved.', previous: $e); + } + } + + private function registryEnvSyncCommand(string $registryUrl): string + { + $envFile = '/data/coolify/source/.env'; + $sedExpression = escapeshellarg("s|^REGISTRY_URL=.*|REGISTRY_URL={$registryUrl}|"); + $registryLine = escapeshellarg("REGISTRY_URL={$registryUrl}"); + + return "if grep -q '^REGISTRY_URL=' {$envFile}; then sed -i {$sedExpression} {$envFile}; else printf '%s\\n' {$registryLine} >> {$envFile}; fi"; + } + public function submit() { try { @@ -89,6 +130,8 @@ public function submit() if ($this->server) { $this->server->setupDynamicProxyConfiguration(); } + } catch (ValidationException $e) { + throw $e; } catch (\Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 648bfe6ee..a24ed9ce3 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -8,11 +8,8 @@ use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Signer\Rsa\Sha256; +use Illuminate\Validation\ValidationException; use Livewire\Component; class Change extends Component @@ -82,11 +79,13 @@ class Change extends Component public string $activeTab = 'general'; + private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false; + protected function rules(): array { return [ 'name' => 'required|string', - 'organization' => 'nullable|string', + 'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'], 'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl], 'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl], 'customUser' => 'required|string', @@ -107,6 +106,19 @@ protected function rules(): array ]; } + public function updatingHtmlUrl(): void + { + $this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->apiUrl) + || $this->apiUrl === githubApiUrlFromHtmlUrl($this->htmlUrl); + } + + public function updatedHtmlUrl(): void + { + if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) { + $this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl); + } + } + public function boot() { if ($this->github_app) { @@ -123,6 +135,11 @@ private function syncData(bool $toModel = false): void { if ($toModel) { // Sync TO model (before save) + $this->organization = normalizeGithubOrganization($this->organization); + $this->apiUrl = filled($this->apiUrl) + ? $this->apiUrl + : githubApiUrlFromHtmlUrl($this->htmlUrl); + $this->github_app->name = $this->name; $this->github_app->organization = $this->organization; $this->github_app->api_url = $this->apiUrl; @@ -208,6 +225,8 @@ public function checkPermissions() return; } + syncGithubAppName($this->github_app); + GithubAppPermissionJob::dispatchSync($this->github_app); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->syncData(false); @@ -296,31 +315,14 @@ public function mount() public function getGithubAppNameUpdatePath() { - if (str($this->github_app->organization)->isNotEmpty()) { - return "{$this->github_app->html_url}/organizations/{$this->github_app->organization}/settings/apps/{$this->github_app->name}"; + $name = encodeGithubPathSegment($this->github_app->name); + $organization = normalizeGithubOrganization($this->github_app->organization); + + if (filled($organization)) { + return rtrim($this->github_app->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}"; } - return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}"; - } - - private function generateGithubJwt($private_key, $app_id): string - { - $configuration = Configuration::forAsymmetricSigner( - new Sha256, - InMemory::plainText($private_key), - InMemory::plainText($private_key) - ); - - $now = time(); - - return $configuration->builder() - ->issuedBy((string) $app_id) - ->permittedFor('https://api.github.com') - ->identifiedBy((string) $now) - ->issuedAt(new \DateTimeImmutable("@{$now}")) - ->expiresAt(new \DateTimeImmutable('@'.($now + 600))) - ->getToken($configuration->signer(), $configuration->signingKey()) - ->toString(); + return rtrim($this->github_app->html_url, '/')."/settings/apps/{$name}"; } public function updateGithubAppName() @@ -328,39 +330,29 @@ public function updateGithubAppName() try { $this->authorize('update', $this->github_app); - $privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id); + $this->github_app->app_id = $this->appId; + $this->github_app->private_key_id = $this->privateKeyId; + $this->github_app->unsetRelation('privateKey'); - if (! $privateKey) { + if (! $this->appId) { + $this->dispatch('error', 'App ID is required before synchronizing the GitHub App name.'); + + return; + } + + if (! PrivateKey::ownedByCurrentTeam()->find($this->privateKeyId)) { $this->dispatch('error', 'No private key found for this GitHub App.'); return; } - $jwt = $this->generateGithubJwt($privateKey->private_key, $this->github_app->app_id); + $appSlug = syncGithubAppName($this->github_app, true); - $response = Http::withHeaders([ - 'Accept' => 'application/vnd.github+json', - 'X-GitHub-Api-Version' => '2022-11-28', - 'Authorization' => "Bearer {$jwt}", - ])->get("{$this->github_app->api_url}/app"); - - if ($response->successful()) { - $app_data = $response->json(); - $app_slug = $app_data['slug'] ?? null; - - if ($app_slug) { - $this->github_app->name = $app_slug; - $this->name = str($app_slug)->kebab(); - $privateKey->name = "github-app-{$app_slug}"; - $privateKey->save(); - $this->github_app->save(); - $this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.'); - } else { - $this->dispatch('info', 'Could not find App Name (slug) in GitHub response.'); - } + if ($appSlug) { + $this->name = str($appSlug)->kebab(); + $this->dispatch('success', 'GitHub App name and private key name synchronized successfully.'); } else { - $error_message = $response->json()['message'] ?? 'Unknown error'; - $this->dispatch('error', "Failed to fetch GitHub App information: {$error_message}"); + $this->dispatch('info', 'Could not find App Name (slug) in GitHub response.'); } } catch (\Throwable $e) { return handleError($e, $this); @@ -373,11 +365,17 @@ public function submit() $this->authorize('update', $this->github_app); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->organization = normalizeGithubOrganization($this->organization); + $this->apiUrl = filled($this->apiUrl) + ? $this->apiUrl + : githubApiUrlFromHtmlUrl($this->htmlUrl); $this->validate(); $this->syncData(true); $this->github_app->save(); $this->dispatch('success', 'Github App updated.'); + } catch (ValidationException $e) { + throw $e; } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index ec2ba3f08..6a5bf6e60 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -5,6 +5,7 @@ use App\Models\GithubApp; use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Component; class Create extends Component @@ -25,19 +26,39 @@ class Create extends Component public bool $is_system_wide = false; + private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false; + public function mount() { $this->name = substr(generate_random_name(), 0, 30); } + public function updatingHtmlUrl(): void + { + $this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->api_url) + || $this->api_url === githubApiUrlFromHtmlUrl($this->html_url); + } + + public function updatedHtmlUrl(): void + { + if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) { + $this->api_url = githubApiUrlFromHtmlUrl($this->html_url); + } + } + public function createGitHubApp() { try { $this->authorize('createAnyResource'); + $this->organization = normalizeGithubOrganization($this->organization); + $this->api_url = filled($this->api_url) + ? $this->api_url + : githubApiUrlFromHtmlUrl($this->html_url); + $this->validate([ 'name' => 'required|string', - 'organization' => 'nullable|string', + 'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'], 'api_url' => ['required', 'string', 'url', new SafeExternalUrl], 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], 'custom_user' => 'required|string', @@ -60,6 +81,8 @@ public function createGitHubApp() } return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]); + } catch (ValidationException $e) { + throw $e; } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index 140d9f5cc..406d385da 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -24,11 +24,14 @@ class Index extends Component public ?string $description = null; + public bool $is_mcp_server_enabled = true; + protected function rules(): array { return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), + 'is_mcp_server_enabled' => 'boolean', ]; } @@ -58,10 +61,12 @@ private function syncData(bool $toModel = false): void // Sync TO model (before save) $this->team->name = $this->name; $this->team->description = $this->description; + $this->team->is_mcp_server_enabled = $this->is_mcp_server_enabled; } else { // Sync FROM model (on load/refresh) $this->name = $this->team->name; $this->description = $this->team->description; + $this->is_mcp_server_enabled = $this->team->is_mcp_server_enabled; } } diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index 5b040db71..a93bf8dd9 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -39,6 +39,16 @@ public function viaLink() $this->generateInviteLink(sendEmail: false); } + private function invitationUrl(string $routeName, array $parameters): string + { + $fqdn = instanceSettings()->fqdn; + if (filled($fqdn)) { + return rtrim($fqdn, '/').route($routeName, $parameters, false); + } + + return route($routeName, $parameters); + } + private function generateInviteLink(bool $sendEmail = false) { try { @@ -61,7 +71,7 @@ private function generateInviteLink(bool $sendEmail = false) return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); } $uuid = new_public_id(32); - $link = url('/').config('constants.invitation.link.base_url').$uuid; + $link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]); $user = User::whereEmail($this->email)->first(); if (is_null($user)) { @@ -73,7 +83,7 @@ private function generateInviteLink(bool $sendEmail = false) 'force_password_reset' => true, ]); $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); - $link = route('auth.link', ['token' => $token]); + $link = $this->invitationUrl('auth.link', ['token' => $token]); } $invitation = TeamInvitation::whereEmail($this->email)->first(); if (! is_null($invitation)) { diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index 10d87ae92..1473d8994 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -24,7 +24,7 @@ trait BuildsResponse // raw IDs / morph types (uuid is the public identifier) 'id', 'team_id', 'tokenable_id', 'tokenable_type', 'server_id', 'private_key_id', 'cloud_provider_token_id', - 'hetzner_server_id', 'environment_id', 'destination_id', + 'hetzner_server_id', 'digitalocean_droplet_id', 'environment_id', 'destination_id', 'source_id', 'repository_project_id', 'application_id', 'service_id', 'project_id', 'parent_id', 'resourceable', 'resourceable_id', 'resourceable_type', diff --git a/app/Models/Application.php b/app/Models/Application.php index 2c408483e..ca247c8e8 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -111,6 +111,7 @@ 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'settings' => new OA\Property(ref: '#/components/schemas/ApplicationSetting'), ] )] @@ -176,11 +177,8 @@ class Application extends BaseModel 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', - 'docker_compose_pr_location', 'docker_compose', - 'docker_compose_pr', 'docker_compose_raw', - 'docker_compose_pr_raw', 'docker_compose_domains', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', @@ -218,6 +216,24 @@ class Application extends BaseModel protected $appends = ['server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. Internal serializers (deployment + * job, compose generation) must makeVisible explicitly before toArray(). + */ + protected $hidden = [ + 'http_basic_auth_password', + 'manual_webhook_secret_github', + 'manual_webhook_secret_gitlab', + 'manual_webhook_secret_bitbucket', + 'manual_webhook_secret_gitea', + 'dockerfile', + 'docker_compose', + 'docker_compose_raw', + 'custom_labels', + ]; + protected function casts(): array { return [ @@ -1266,9 +1282,9 @@ private function legacyConfigurationHash(): string { $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { - $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort()); } else { - $newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); + $newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort()); } return md5($newConfigHash); diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 53fb8337f..ee190532c 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -84,6 +84,7 @@ class ApplicationDeploymentQueue extends Model * @var array */ protected $hidden = [ + 'logs', 'configuration_snapshot', 'configuration_diff', ]; diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index ef09c0c48..91c38b879 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -4,7 +4,49 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Application settings.', + type: 'object', + properties: [ + 'is_static' => ['type' => 'boolean'], + 'is_git_submodules_enabled' => ['type' => 'boolean'], + 'is_git_lfs_enabled' => ['type' => 'boolean'], + 'is_auto_deploy_enabled' => ['type' => 'boolean'], + 'is_force_https_enabled' => ['type' => 'boolean'], + 'is_debug_enabled' => ['type' => 'boolean'], + 'is_preview_deployments_enabled' => ['type' => 'boolean'], + 'is_log_drain_enabled' => ['type' => 'boolean'], + 'is_gpu_enabled' => ['type' => 'boolean'], + 'gpu_driver' => ['type' => 'string', 'nullable' => true], + 'gpu_count' => ['type' => 'string', 'nullable' => true], + 'gpu_device_ids' => ['type' => 'string', 'nullable' => true], + 'gpu_options' => ['type' => 'string', 'nullable' => true], + 'is_include_timestamps' => ['type' => 'boolean'], + 'is_swarm_only_worker_nodes' => ['type' => 'boolean'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean'], + 'is_build_server_enabled' => ['type' => 'boolean'], + 'is_consistent_container_name_enabled' => ['type' => 'boolean'], + 'is_gzip_enabled' => ['type' => 'boolean'], + 'is_stripprefix_enabled' => ['type' => 'boolean'], + 'connect_to_docker_network' => ['type' => 'boolean'], + 'custom_internal_name' => ['type' => 'string', 'nullable' => true], + 'is_container_label_escape_enabled' => ['type' => 'boolean'], + 'is_env_sorting_enabled' => ['type' => 'boolean'], + 'is_container_label_readonly_enabled' => ['type' => 'boolean'], + 'is_preserve_repository_enabled' => ['type' => 'boolean'], + 'disable_build_cache' => ['type' => 'boolean'], + 'is_spa' => ['type' => 'boolean'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean'], + 'use_build_secrets' => ['type' => 'boolean'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean'], + 'include_source_commit_in_build' => ['type' => 'boolean'], + 'docker_images_to_keep' => ['type' => 'integer'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true], + ] +)] class ApplicationSetting extends Model { protected $casts = [ @@ -27,6 +69,17 @@ class ApplicationSetting extends Model 'is_git_shallow_clone_enabled' => 'boolean', 'docker_images_to_keep' => 'integer', 'stop_grace_period' => 'integer', + 'is_log_drain_enabled' => 'boolean', + 'is_gpu_enabled' => 'boolean', + 'is_include_timestamps' => 'boolean', + 'is_swarm_only_worker_nodes' => 'boolean', + 'is_raw_compose_deployment_enabled' => 'boolean', + 'is_consistent_container_name_enabled' => 'boolean', + 'is_gzip_enabled' => 'boolean', + 'is_stripprefix_enabled' => 'boolean', + 'connect_to_docker_network' => 'boolean', + 'is_env_sorting_enabled' => 'boolean', + 'disable_build_cache' => 'boolean', ]; protected $fillable = [ diff --git a/app/Models/CloudInitScript.php b/app/Models/CloudInitScript.php index 2c78cc582..671c5e76c 100644 --- a/app/Models/CloudInitScript.php +++ b/app/Models/CloudInitScript.php @@ -2,9 +2,7 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; - -class CloudInitScript extends Model +class CloudInitScript extends BaseModel { protected $fillable = [ 'team_id', @@ -12,6 +10,10 @@ class CloudInitScript extends Model 'script', ]; + protected $hidden = [ + 'script', + ]; + protected function casts(): array { return [ diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 35452553b..ab9897f9a 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -13,6 +13,11 @@ class CloudProviderToken extends BaseModel 'provider', 'token', 'name', + 'description', + ]; + + protected $hidden = [ + 'token', ]; protected $casts = [ diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php index e86598126..135c921f6 100644 --- a/app/Models/DiscordNotificationSettings.php +++ b/app/Models/DiscordNotificationSettings.php @@ -34,6 +34,10 @@ class DiscordNotificationSettings extends Model 'discord_ping_enabled', ]; + protected $hidden = [ + 'discord_webhook_url', + ]; + protected $casts = [ 'discord_enabled' => 'boolean', 'discord_webhook_url' => 'encrypted', diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 1277e45d9..7368bafbf 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -43,6 +43,16 @@ class EmailNotificationSettings extends Model 'traefik_outdated_email_notifications', ]; + protected $hidden = [ + 'smtp_from_address', + 'smtp_from_name', + 'smtp_recipients', + 'smtp_host', + 'smtp_username', + 'smtp_password', + 'resend_api_key', + ]; + protected $casts = [ 'smtp_enabled' => 'boolean', 'smtp_from_address' => 'encrypted', diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 55830f889..1364d874a 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -47,6 +47,11 @@ public static function ownedByCurrentTeam() return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name'); } + public static function ownedByCurrentTeamAPI(int $teamId) + { + return Environment::whereRelation('project.team', 'id', $teamId)->orderBy('name'); + } + public function isEmpty() { return $this->applications()->count() == 0 && diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index bfb02a470..89188b31b 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -80,6 +80,16 @@ class EnvironmentVariable extends BaseModel protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_buildpack_control', 'is_coolify']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'value', + 'real_value', + ]; + protected static function booted() { static::created(function (ModelsEnvironmentVariable $environment_variable) { @@ -356,7 +366,7 @@ private function get_environment_variables(?string $environment_variable = null) private function set_environment_variables(?string $environment_variable = null): ?string { - if (is_null($environment_variable) && $environment_variable === '') { + if (is_null($environment_variable)) { return null; } $environment_variable = trim($environment_variable); diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 65fcd1486..2ddbff733 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -47,6 +47,19 @@ class InstanceSettings extends Model 'dev_helper_version', 'is_wire_navigate_enabled', 'is_mcp_server_enabled', + 'webhook_allowed_internal_hosts', + 'webhook_allow_localhost', + ]; + + protected $hidden = [ + 'smtp_from_address', + 'smtp_from_name', + 'smtp_recipients', + 'smtp_host', + 'smtp_username', + 'smtp_password', + 'resend_api_key', + 'sentinel_token', ]; protected $casts = [ @@ -72,10 +85,16 @@ class InstanceSettings extends Model 'sentinel_token' => 'encrypted', 'is_wire_navigate_enabled' => 'boolean', 'is_mcp_server_enabled' => 'boolean', + 'webhook_allowed_internal_hosts' => 'array', + 'webhook_allow_localhost' => 'boolean', ]; protected static function booted(): void { + static::created(function () { + Once::flush(); + }); + static::updated(function ($settings) { // Clear once() cache so subsequent calls get fresh data Once::flush(); diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index b521ede3d..968e6c3d0 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -25,6 +25,10 @@ class LocalFileVolume extends BaseModel 'is_preview_suffix_enabled' => 'boolean', ]; + protected $hidden = [ + 'content', + ]; + use HasFactory; protected $fillable = [ @@ -366,7 +370,6 @@ public function isReadOnlyVolume(): bool return false; } catch (\Throwable $e) { - ray($e->getMessage(), 'Error checking read-only volume'); return false; } diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 2f0f482b0..d44c86c0c 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -187,7 +187,6 @@ public function isReadOnlyVolume(): bool return false; } catch (\Throwable $e) { - ray($e->getMessage(), 'Error checking read-only persistent volume'); return false; } diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index bc6fc06c1..7765e4116 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -25,6 +25,10 @@ protected function casts(): array ]; } + protected $hidden = [ + 'client_secret', + ]; + protected function clientSecret(): Attribute { return Attribute::make( diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index bf42f21c7..3f72642a5 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -42,6 +42,10 @@ class PrivateKey extends BaseModel 'fingerprint', ]; + protected $hidden = [ + 'private_key', + ]; + protected $casts = [ 'private_key' => 'encrypted', ]; @@ -287,7 +291,7 @@ protected function ensureStorageDirectoryExists() public function getKeyLocation() { - return "/var/www/html/storage/app/ssh/keys/ssh_key@{$this->uuid}"; + return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}"); } public function updatePrivateKey(array $data) diff --git a/app/Models/Project.php b/app/Models/Project.php index b47e7cf04..5c821b017 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -5,6 +5,7 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Support\Collection; use OpenApi\Attributes as OA; #[OA\Schema( @@ -155,9 +156,16 @@ public function isEmpty() $this->services()->count() == 0; } - public function databases() + public function databases(array $with = []): Collection { - 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()); + return $this->postgresqls()->with($with)->get() + ->merge($this->redis()->with($with)->get()) + ->merge($this->mongodbs()->with($with)->get()) + ->merge($this->mysqls()->with($with)->get()) + ->merge($this->mariadbs()->with($with)->get()) + ->merge($this->keydbs()->with($with)->get()) + ->merge($this->dragonflies()->with($with)->get()) + ->merge($this->clickhouses()->with($with)->get()); } public function navigateTo() diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php index 5ad617ad6..dd0d81cc0 100644 --- a/app/Models/PushoverNotificationSettings.php +++ b/app/Models/PushoverNotificationSettings.php @@ -34,6 +34,11 @@ class PushoverNotificationSettings extends Model 'traefik_outdated_pushover_notifications', ]; + protected $hidden = [ + 'pushover_user_key', + 'pushover_api_token', + ]; + protected $casts = [ 'pushover_enabled' => 'boolean', 'pushover_user_key' => 'encrypted', diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 3ffac87e1..70703cd52 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -32,6 +32,11 @@ class S3Storage extends BaseModel 'unusable_email_sent', ]; + protected $hidden = [ + 'key', + 'secret', + ]; + protected $casts = [ 'is_usable' => 'boolean', 'key' => 'encrypted', @@ -173,11 +178,10 @@ public function testConnection(bool $shouldSave = false) 'bucket' => $this['bucket'], 'endpoint' => $this['endpoint'], 'use_path_style_endpoint' => true, - 'http' => [ + 'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint']), [ 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, 'timeout' => self::REQUEST_TIMEOUT_SECONDS, - 'allow_redirects' => false, - ], + ]), ]); // Test the connection by listing files with ListObjectsV2 (S3) $disk->files(); diff --git a/app/Models/Server.php b/app/Models/Server.php index 0102b327e..d55df0179 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -17,6 +17,9 @@ use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; +use App\Services\DigitalOceanService; +use App\Services\HetznerService; +use App\Services\VultrService; use App\Support\ValidationPatterns; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasMetrics; @@ -110,6 +113,15 @@ class Server extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; + /** + * Sentinel IP for servers that do not have a real address yet + * (cloud provisioning in progress or parked as unreachable). + * Scheduled jobs skip these servers via skipServer(). + */ + public const PLACEHOLDER_IP = '1.2.3.4'; + + public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::']; + public static $batch_counter = 0; /** @@ -254,6 +266,16 @@ public static function flushIdentityMap(): void 'force_disabled' => 'boolean', ]; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]; + protected $schemalessAttributes = [ 'proxy', ]; @@ -269,7 +291,12 @@ public static function flushIdentityMap(): void 'team_id', 'hetzner_server_id', 'hetzner_server_status', + 'vultr_instance_id', + 'vultr_instance_status', + 'digitalocean_droplet_id', + 'digitalocean_droplet_status', 'is_validating', + 'validation_logs', 'detected_traefik_version', 'traefik_outdated_info', 'server_metadata', @@ -290,6 +317,162 @@ public function type() return 'server'; } + public function hasPlaceholderIp(): bool + { + // Cast: the saving hook stores the ip as a Stringable in memory. + return self::isPlaceholderIp((string) $this->ip); + } + + public static function isPlaceholderIp(?string $ip): bool + { + return blank($ip) || in_array($ip, self::PLACEHOLDER_IPS, true); + } + + /** + * Replace a placeholder IP with the real address once the cloud + * provider reports one. Returns true when the IP was updated. + */ + public function backfillPlaceholderIp(?string $ip): bool + { + if (self::isPlaceholderIp($ip)) { + return false; + } + + $updated = static::query() + ->whereKey($this->getKey()) + ->where(function (Builder $query): void { + $query->whereNull('ip') + ->orWhere('ip', '') + ->orWhereIn('ip', self::PLACEHOLDER_IPS); + }) + ->update(['ip' => $ip]); + + if ($updated === 0) { + return false; + } + + $this->forceFill(['ip' => $ip]); + $this->syncOriginalAttribute('ip'); + static::flushIdentityMap(); + + return true; + } + + /** + * Persist provider status without saving a stale in-memory IP value. + * + * @param array $updates + */ + private function persistProviderState(array $updates): void + { + if (empty($updates)) { + return; + } + + static::query()->whereKey($this->getKey())->update($updates); + $this->forceFill($updates); + $this->syncOriginalAttributes(array_keys($updates)); + static::flushIdentityMap(); + } + + public function refreshHetznerState(): ?string + { + if (! $this->hetzner_server_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hetzner') { + return $this->hetzner_server_status; + } + + $hetznerService = new HetznerService($this->cloudProviderToken->token); + $server = $hetznerService->getServer($this->hetzner_server_id); + $status = $server['status'] ?? null; + $assignedIp = data_get($server, 'public_net.ipv4.ip') ?? data_get($server, 'public_net.ipv6.ip'); + + $updates = []; + if ($this->hetzner_server_status !== $status) { + $updates['hetzner_server_status'] = $status; + } + $this->persistProviderState($updates); + $this->backfillPlaceholderIp($assignedIp); + + return $status; + } + + public function refreshVultrState(): ?string + { + if (! $this->vultr_instance_id || ! $this->cloudProviderToken) { + return null; + } + + $vultrService = new VultrService($this->cloudProviderToken->token); + try { + $instance = $vultrService->getInstance($this->vultr_instance_id); + } catch (\Throwable $e) { + if ((int) $e->getCode() !== 404) { + throw $e; + } + + if ($this->vultr_instance_status !== 'deleted') { + $this->persistProviderState(['vultr_instance_status' => 'deleted']); + } + + return 'deleted'; + } + + $status = ($instance['power_status'] ?? null) === 'stopped' + ? 'stopped' + : ($instance['status'] ?? null); + $publicIp = $vultrService->getPublicIp($instance); + + $updates = []; + if ($this->vultr_instance_status !== $status) { + $updates['vultr_instance_status'] = $status; + } + $this->persistProviderState($updates); + $this->backfillPlaceholderIp($publicIp); + + return $status; + } + + public function refreshDigitalOceanState(): ?string + { + if (! $this->digitalocean_droplet_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'digitalocean') { + return $this->digitalocean_droplet_status; + } + + $digitalOceanService = new DigitalOceanService($this->cloudProviderToken->token); + + try { + $droplet = $digitalOceanService->getDroplet((int) $this->digitalocean_droplet_id); + } catch (RequestException $e) { + if ($e->response?->status() === 404) { + $this->persistProviderState(['digitalocean_droplet_status' => 'deleted']); + + return 'deleted'; + } + + throw $e; + } catch (\Throwable $e) { + if ((int) $e->getCode() === 404) { + $this->persistProviderState(['digitalocean_droplet_status' => 'deleted']); + + return 'deleted'; + } + + throw $e; + } + + if (empty($droplet)) { + return $this->digitalocean_droplet_status; + } + + $status = $droplet['status'] ?? null; + $ip = $digitalOceanService->getPublicIpAddress($droplet); + + $this->persistProviderState(['digitalocean_droplet_status' => $status]); + $this->backfillPlaceholderIp($ip); + + return $status; + } + protected function isCoolifyHost(): Attribute { return Attribute::make( @@ -1069,7 +1252,7 @@ public function isProxyShouldRun() public function skipServer() { - if ($this->ip === '1.2.3.4') { + if ($this->hasPlaceholderIp()) { return true; } if ($this->settings->force_disabled === true) { @@ -1081,7 +1264,7 @@ public function skipServer() public function isFunctional() { - $isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && $this->ip !== '1.2.3.4'; + $isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && ! $this->hasPlaceholderIp(); if ($isFunctional === false) { Storage::disk('ssh-mux')->delete($this->muxFilename()); @@ -1523,7 +1706,6 @@ private function disableSshMux(): void public function generateCaCertificate() { try { - ray('Generating CA certificate for server', $this->id); SslHelper::generateSslCertificate( commonName: 'Coolify CA Certificate', serverId: $this->id, @@ -1531,7 +1713,6 @@ public function generateCaCertificate() validityDays: 10 * 365 ); $caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first(); - ray('CA certificate generated', $caCertificate); if ($caCertificate) { $certificateContent = $caCertificate->ssl_certificate; $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 79f62f4b7..e96aab4a3 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -114,6 +114,20 @@ class ServerSetting extends Model 'connection_timeout' => 'integer', ]; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]; + protected static function booted() { static::creating(function ($setting) { diff --git a/app/Models/Service.php b/app/Models/Service.php index 72b467657..89438053d 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -66,6 +66,17 @@ class Service extends BaseModel protected $appends = ['server_status', 'status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. Internal compose generators + * must makeVisible explicitly before toArray(). + */ + protected $hidden = [ + 'docker_compose', + 'docker_compose_raw', + ]; + protected static function booted() { static::creating(function ($service) { @@ -94,7 +105,7 @@ public function isConfigurationChanged(bool $save = false) $storages = $applicationStorages->merge($databaseStorages)->implode('updated_at'); $newConfigHash = $images.$domains.$images.$storages; - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { @@ -1583,7 +1594,6 @@ public function saveComposeConfigs() $envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName); } } catch (\Exception $e) { - ray($e->getMessage()); } } diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index eadc33ec2..8bb241240 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -30,6 +30,10 @@ class SharedEnvironmentVariable extends Model 'version', ]; + protected $hidden = [ + 'value', + ]; + protected $casts = [ 'key' => 'string', 'value' => 'encrypted', diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php index d4f125fb5..62603685e 100644 --- a/app/Models/SlackNotificationSettings.php +++ b/app/Models/SlackNotificationSettings.php @@ -33,6 +33,10 @@ class SlackNotificationSettings extends Model 'traefik_outdated_slack_notifications', ]; + protected $hidden = [ + 'slack_webhook_url', + ]; + protected $casts = [ 'slack_enabled' => 'boolean', 'slack_webhook_url' => 'encrypted', diff --git a/app/Models/SslCertificate.php b/app/Models/SslCertificate.php index eb2175d44..2311cea72 100644 --- a/app/Models/SslCertificate.php +++ b/app/Models/SslCertificate.php @@ -20,6 +20,10 @@ class SslCertificate extends Model 'is_ca_certificate', ]; + protected $hidden = [ + 'ssl_private_key', + ]; + protected $casts = [ 'ssl_certificate' => 'encrypted', 'ssl_private_key' => 'encrypted', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index b104be642..9db5f21b7 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -54,6 +54,17 @@ class StandaloneClickhouse extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'clickhouse_admin_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -123,7 +134,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 1c5cfd342..604a245fc 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -7,7 +7,22 @@ use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; +use OpenApi\Attributes as OA; +#[OA\Schema( + schema: 'Destination', + description: 'A Docker network destination attached to a server.', + type: 'object', + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'network', type: 'string'), + new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']), + new OA\Property(property: 'server_uuid', type: 'string'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ], +)] class StandaloneDocker extends BaseModel { use HasFactory; @@ -23,6 +38,10 @@ protected static function boot() { parent::boot(); static::created(function ($newStandaloneDocker) { + if (app()->runningUnitTests()) { + return; + } + $server = $newStandaloneDocker->server; $safeNetwork = escapeshellarg($newStandaloneDocker->network); instant_remote_process([ @@ -144,6 +163,6 @@ public function databases(): Collection public function attachedTo() { - return $this->applications?->count() > 0 || $this->databases()->count() > 0; + return $this->applications()->exists() || $this->databases()->count() > 0 || $this->services()->exists(); } } diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 2232ec772..769d9f00c 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -53,6 +53,17 @@ class StandaloneDragonfly extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'dragonfly_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -122,7 +133,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index b9f9f765b..15a1fe2f8 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -54,6 +54,17 @@ class StandaloneKeydb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'keydb_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -123,7 +134,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index cd94b6c9b..378d36395 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -57,6 +57,18 @@ class StandaloneMariadb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'mariadb_password', + 'mariadb_root_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -126,7 +138,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 7d2ffbd74..1010ca5f3 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -57,6 +57,17 @@ class StandaloneMongodb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'mongo_initdb_root_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -132,7 +143,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index f752312d3..90828bf01 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -58,6 +58,18 @@ class StandaloneMysql extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'mysql_password', + 'mysql_root_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -128,7 +140,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 04d2291b3..e7db81285 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -60,6 +60,18 @@ class StandalonePostgresql extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'postgres_password', + 'init_scripts', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -170,7 +182,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index efb0254fb..326261190 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -53,6 +53,17 @@ class StandaloneRedis extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'redis_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -127,7 +138,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php index 0e9620457..02b8381d9 100644 --- a/app/Models/SwarmDocker.php +++ b/app/Models/SwarmDocker.php @@ -124,6 +124,6 @@ public function databases() public function attachedTo() { - return $this->applications?->count() > 0 || $this->databases()->count() > 0; + return $this->applications()->exists() || $this->databases()->count() > 0 || $this->services()->exists(); } } diff --git a/app/Models/Tag.php b/app/Models/Tag.php index e6fbd3a06..d5cccabd8 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -3,7 +3,19 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; +use Illuminate\Support\Facades\DB; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Tag model', + type: 'object', + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'created_at', type: 'string'), + new OA\Property(property: 'updated_at', type: 'string'), + ] +)] class Tag extends BaseModel { use HasSafeStringAttribute; @@ -23,6 +35,13 @@ public static function ownedByCurrentTeam() return Tag::whereTeamId(currentTeam()->id)->orderBy('name'); } + public function deleteIfOrphaned(): void + { + if (DB::table('taggables')->where('tag_id', $this->id)->doesntExist()) { + $this->delete(); + } + } + public function applications() { return $this->morphedByMany(Application::class, 'taggable'); diff --git a/app/Models/Team.php b/app/Models/Team.php index 23e2badb3..a979b44fb 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -47,10 +47,12 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen 'personal_team', 'show_boarding', 'custom_server_limit', + 'is_mcp_server_enabled', ]; protected $casts = [ 'personal_team' => 'boolean', + 'is_mcp_server_enabled' => 'boolean', ]; protected static function booted() diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php index 4930f45d4..8c644f9bc 100644 --- a/app/Models/TelegramNotificationSettings.php +++ b/app/Models/TelegramNotificationSettings.php @@ -49,6 +49,25 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_traefik_outdated_thread_id', ]; + protected $hidden = [ + 'telegram_token', + 'telegram_chat_id', + 'telegram_notifications_deployment_success_thread_id', + 'telegram_notifications_deployment_failure_thread_id', + 'telegram_notifications_status_change_thread_id', + 'telegram_notifications_backup_success_thread_id', + 'telegram_notifications_backup_failure_thread_id', + 'telegram_notifications_scheduled_task_success_thread_id', + 'telegram_notifications_scheduled_task_failure_thread_id', + 'telegram_notifications_docker_cleanup_success_thread_id', + 'telegram_notifications_docker_cleanup_failure_thread_id', + 'telegram_notifications_server_disk_usage_thread_id', + 'telegram_notifications_server_reachable_thread_id', + 'telegram_notifications_server_unreachable_thread_id', + 'telegram_notifications_server_patch_thread_id', + 'telegram_notifications_traefik_outdated_thread_id', + ]; + protected $casts = [ 'telegram_enabled' => 'boolean', 'telegram_token' => 'encrypted', @@ -75,7 +94,8 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_backup_failure_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_success_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_failure_thread_id' => 'encrypted', - 'telegram_notifications_docker_cleanup_thread_id' => 'encrypted', + 'telegram_notifications_docker_cleanup_success_thread_id' => 'encrypted', + 'telegram_notifications_docker_cleanup_failure_thread_id' => 'encrypted', 'telegram_notifications_server_disk_usage_thread_id' => 'encrypted', 'telegram_notifications_server_reachable_thread_id' => 'encrypted', 'telegram_notifications_server_unreachable_thread_id' => 'encrypted', diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php index 731006181..c6a81b50a 100644 --- a/app/Models/WebhookNotificationSettings.php +++ b/app/Models/WebhookNotificationSettings.php @@ -33,6 +33,10 @@ class WebhookNotificationSettings extends Model 'traefik_outdated_webhook_notifications', ]; + protected $hidden = [ + 'webhook_url', + ]; + protected function casts(): array { return [ diff --git a/app/Notifications/Channels/WebhookChannel.php b/app/Notifications/Channels/WebhookChannel.php index 8c3e74b17..e735b88bb 100644 --- a/app/Notifications/Channels/WebhookChannel.php +++ b/app/Notifications/Channels/WebhookChannel.php @@ -15,23 +15,11 @@ public function send($notifiable, Notification $notification): void $webhookSettings = $notifiable->webhookNotificationSettings; if (! $webhookSettings || ! $webhookSettings->isEnabled() || ! $webhookSettings->webhook_url) { - if (isDev()) { - ray('Webhook notification skipped - not enabled or no URL configured'); - } - return; } $payload = $notification->toWebhook(); - if (isDev()) { - ray('Dispatching webhook notification', [ - 'notification' => get_class($notification), - 'url' => $webhookSettings->webhook_url, - 'payload' => $payload, - ]); - } - SendWebhookJob::dispatch($payload, $webhookSettings->webhook_url); } } diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index de894331b..bb452b054 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -17,8 +17,6 @@ public function __construct(public int $hetznerServerId, public int $teamId, pub public function via(object $notifiable): array { - ray('hello'); - ray($notifiable); return $notifiable->getEnabledChannels('hetzner_deletion_failed'); } diff --git a/app/Policies/ServiceApplicationPolicy.php b/app/Policies/ServiceApplicationPolicy.php index c730ab0c6..491b9e424 100644 --- a/app/Policies/ServiceApplicationPolicy.php +++ b/app/Policies/ServiceApplicationPolicy.php @@ -32,6 +32,14 @@ public function update(User $user, ServiceApplication $serviceApplication): bool return Gate::allows('update', $serviceApplication->service); } + /** + * Determine whether the user can deploy or run lifecycle actions on the parent service stack. + */ + public function deploy(User $user, ServiceApplication $serviceApplication): bool + { + return Gate::allows('deploy', $serviceApplication->service); + } + /** * Determine whether the user can delete the model. */ diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index d65be87eb..bf6fa4c4b 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -131,7 +131,16 @@ public function boot(): void // Use real client IP (not spoofable forwarded headers) $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); - return Limit::perMinute(5)->by($realIp); + $limits = [ + Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1($realIp)), + ]; + + $emailIdentity = normalize_email_identity($request->input('email')); + if ($emailIdentity !== null) { + $limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity)); + } + + return $limits; }); RateLimiter::for('login', function (Request $request) { diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index ead03e9c0..478b05197 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -2,9 +2,13 @@ namespace App\Rules; +use App\Models\InstanceSettings; use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Facades\Log; +use PurplePixie\PhpDns\DNSQuery; +use PurplePixie\PhpDns\DNSTypes; +use Throwable; class SafeWebhookUrl implements ValidationRule { @@ -18,8 +22,8 @@ public function __construct(private ?Closure $resolver = null) {} * * Validates that a webhook URL is safe for server-side requests. * Blocks loopback addresses, cloud metadata endpoints (link-local), - * and dangerous hostnames while allowing private network IPs - * for self-hosted deployments. + * private/reserved ranges, and dangerous hostnames unless the + * instance operator explicitly allowlists the intranet target. */ public function validate(string $attribute, mixed $value, Closure $fail): void { @@ -43,12 +47,17 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } + if (str_ends_with($host, '.')) { + $fail('The :attribute host must not end with a trailing dot.'); + + return; + } + $host = strtolower($host); $hostForIpCheck = $this->normalizeHostForIpCheck($host); $hostForDns = rtrim($hostForIpCheck, '.'); - $blockedHosts = ['localhost', '0.0.0.0', '::1']; - if (in_array($hostForDns, $blockedHosts, true) || str_ends_with($hostForDns, '.internal')) { + if ($this->isBlockedHostname($hostForDns) && ! $this->isAllowedHostname($hostForDns)) { $this->logBlockedHost($attribute, $host); $fail('The :attribute must not point to localhost or internal hosts.'); @@ -56,9 +65,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) { - if ($this->isBlockedIp($hostForIpCheck)) { + if (! $this->isAllowedIp($hostForIpCheck, $hostForDns)) { $this->logBlockedIp($attribute, $host, $hostForIpCheck); - $fail('The :attribute must not point to loopback or link-local addresses.'); + $fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.'); return; } @@ -67,16 +76,124 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } $resolvedIps = $this->resolveHost($hostForDns); + if ($resolvedIps === []) { + $fail('The :attribute host could not be resolved.'); + + return; + } + foreach ($resolvedIps as $resolvedIp) { - if ($this->isBlockedIp($resolvedIp)) { + if (! $this->isAllowedIp($resolvedIp, $hostForDns)) { $this->logBlockedIp($attribute, $host, $resolvedIp); - $fail('The :attribute must not point to loopback or link-local addresses.'); + $fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.'); return; } } } + /** + * Build HTTP client options that pin the validated host to the resolved IPs. + * + * @return array + */ + public static function httpClientOptions(string $url): array + { + $options = ['allow_redirects' => false]; + + if (! defined('CURLOPT_RESOLVE')) { + throw new \RuntimeException('Webhook URL DNS pinning is unavailable.'); + } + + $target = self::resolveUrlForRequest($url); + + if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) { + return $options; + } + + $options['curl'] = [ + CURLOPT_RESOLVE => array_map( + fn (string $ip): string => sprintf('%s:%d:%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip), + $target['ips'], + ), + ]; + + return $options; + } + + /** + * Build mc --resolve mappings that pin the endpoint host for S3 backups. + * + * @return array + */ + public static function minioClientResolveOptions(string $url): array + { + $target = self::resolveUrlForRequest($url); + + if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) { + return []; + } + + return array_map( + fn (string $ip): string => sprintf('%s:%d=%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip), + $target['ips'], + ); + } + + public static function redactedUrlForLog(string $url): string + { + $scheme = parse_url($url, PHP_URL_SCHEME); + $host = parse_url($url, PHP_URL_HOST); + $port = parse_url($url, PHP_URL_PORT); + + if (! is_string($scheme) || ! is_string($host)) { + return '[invalid-url]'; + } + + return strtolower($scheme).'://'.strtolower($host).($port ? ':'.$port : ''); + } + + /** + * @return array{host: string, port: int, ips: array} + */ + private static function resolveUrlForRequest(string $url): array + { + $rule = new self; + $host = parse_url($url, PHP_URL_HOST); + if (! is_string($host) || $host === '') { + throw new \RuntimeException('Webhook URL host could not be resolved.'); + } + + if (str_ends_with($host, '.')) { + throw new \RuntimeException('Webhook URL host must not end with a trailing dot.'); + } + + $scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? ''); + $port = parse_url($url, PHP_URL_PORT) ?: ($scheme === 'https' ? 443 : 80); + $hostForDns = rtrim($rule->normalizeHostForIpCheck(strtolower($host)), '.'); + + if (filter_var($hostForDns, FILTER_VALIDATE_IP)) { + if (! $rule->isAllowedIp($hostForDns, $hostForDns)) { + throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.'); + } + + return ['host' => $hostForDns, 'port' => $port, 'ips' => []]; + } + + $resolvedIps = $rule->resolveHost($hostForDns); + if ($resolvedIps === []) { + throw new \RuntimeException('Webhook URL host could not be resolved.'); + } + + foreach ($resolvedIps as $resolvedIp) { + if (! $rule->isAllowedIp($resolvedIp, $hostForDns)) { + throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.'); + } + } + + return ['host' => $hostForDns, 'port' => $port, 'ips' => $resolvedIps]; + } + private function normalizeHostForIpCheck(string $host): string { return (str_starts_with($host, '[') && str_ends_with($host, ']')) @@ -93,6 +210,15 @@ private function resolveHost(string $host): array return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false)); } + if ($host === 'localhost') { + return ['127.0.0.1', '::1']; + } + + $customDnsServers = $this->customDnsServers(); + if ($customDnsServers !== []) { + return $this->resolveHostWithCustomDnsServers($host, $customDnsServers); + } + $records = @dns_get_record($host, DNS_A | DNS_AAAA); if ($records === false) { $records = []; @@ -119,60 +245,320 @@ private function resolveHost(string $host): array return array_values(array_unique($ips)); } - private function isBlockedIp(string $ip): bool + /** + * @param array $dnsServers + * @return array + */ + private function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array + { + $ips = []; + + foreach ($dnsServers as $dnsServer) { + foreach ([DNSTypes::NAME_A, DNSTypes::NAME_AAAA] as $type) { + try { + $query = new DNSQuery($dnsServer, 53, 5); + $records = $query->query($host, $type); + + if ($records === false || $query->hasError()) { + continue; + } + + foreach ($records as $record) { + if ($record->getType() === $type && filter_var($record->getData(), FILTER_VALIDATE_IP)) { + $ips[] = $record->getData(); + } + } + } catch (Throwable) { + continue; + } + } + } + + return array_values(array_unique($ips)); + } + + /** + * @return array + */ + private function customDnsServers(): array + { + $servers = $this->instanceSettings()?->custom_dns_servers ?? ''; + + if (! is_string($servers)) { + return []; + } + + return array_values(array_filter(array_map( + fn (string $server): string => trim($server), + explode(',', $servers), + ), fn (string $server): bool => filter_var($server, FILTER_VALIDATE_IP) !== false)); + } + + private function isAllowedIp(string $ip, string $host): bool { $embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip); if ($embeddedIpv4 !== null) { - return $this->isBlockedIpv4($embeddedIpv4); + $ip = $embeddedIpv4; + } + + if ($this->isPublicIp($ip)) { + return true; + } + + if ($this->isLocalhostIp($ip)) { + return $this->allowLocalhost() + && ($this->isAllowedHostname($host) || $this->isAllowlistedIp($ip)); + } + + if ($this->isPrivateIp($ip)) { + return $this->isAllowedHostname($host) || $this->isAllowlistedIp($ip); + } + + return $this->isAllowlistedIp($ip); + } + + private function isPublicIp(string $ip): bool + { + $embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip); + if ($embeddedIpv4 !== null) { + return filter_var($embeddedIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false + && ! $this->isSpecialUseIpv4($embeddedIpv4); + } + + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false + && ! $this->isSpecialUseIp($ip); + } + + private function isLocalhostIp(string $ip): bool + { + $embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip); + if ($embeddedIpv4 !== null) { + return $this->isLocalhostIp($embeddedIpv4); } if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - return $this->isBlockedIpv4($ip); + return $this->ipv4InCidr($ip, '127.0.0.0/8'); } - return $this->isBlockedIpv6($ip); + return @inet_pton($ip) === @inet_pton('::1'); } - private function isBlockedIpv4(string $ip): bool + private function isPrivateIp(string $ip): bool { - if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) { + $embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip); + if ($embeddedIpv4 !== null) { + $ip = $embeddedIpv4; + } + + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false + && filter_var($ip, FILTER_VALIDATE_IP) !== false; + } + + private function isSpecialUseIp(string $ip): bool + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $this->isSpecialUseIpv4($ip); + } + + return $this->isSpecialUseIpv6($ip); + } + + private function isSpecialUseIpv4(string $ip): bool + { + foreach ([ + '0.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '192.0.0.0/24', + '192.0.2.0/24', + '198.18.0.0/15', + '198.51.100.0/24', + '203.0.113.0/24', + '224.0.0.0/4', + '240.0.0.0/4', + '255.255.255.255/32', + ] as $cidr) { + if ($this->ipv4InCidr($ip, $cidr)) { + return true; + } + } + + return false; + } + + private function isSpecialUseIpv6(string $ip): bool + { + $ipBytes = @inet_pton($ip); + if ($ipBytes === false) { + return false; + } + + foreach ([ + '::/128', + '::1/128', + '::ffff:0:0/96', + '64:ff9b::/96', + '100::/64', + '2001::/23', + '2001:2::/48', + '2001:db8::/32', + '2002::/16', + 'fc00::/7', + 'fe80::/10', + 'ff00::/8', + ] as $cidr) { + [$network, $prefix] = explode('/', $cidr, 2); + $networkBytes = @inet_pton($network); + if ($networkBytes !== false && $this->binaryInCidr($ipBytes, $networkBytes, (int) $prefix)) { + return true; + } + } + + return false; + } + + private function isBlockedHostname(string $host): bool + { + return in_array($host, ['localhost'], true) + || str_ends_with($host, '.local') + || str_ends_with($host, '.internal') + || str_ends_with($host, '.cluster.local'); + } + + private function isAllowedHostname(string $host): bool + { + foreach ($this->allowlistEntries() as $entry) { + if (! str_contains($entry, '/') && strtolower($entry) === $host) { + return true; + } + } + + return false; + } + + private function isAllowlistedIp(string $ip): bool + { + foreach ($this->allowlistEntries() as $entry) { + if (str_contains($entry, '/')) { + if ($this->ipInCidr($ip, $entry)) { + return true; + } + + continue; + } + + if (filter_var($entry, FILTER_VALIDATE_IP) && @inet_pton($entry) === @inet_pton($ip)) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function allowlistEntries(): array + { + $entries = $this->instanceSettings()?->webhook_allowed_internal_hosts ?? []; + + if (is_string($entries)) { + $entries = explode(',', $entries); + } + + if (! is_array($entries)) { + return []; + } + + return array_values(array_filter(array_map( + fn (mixed $entry): string => rtrim(strtolower(trim((string) $entry)), '.'), + $entries, + ))); + } + + private function allowLocalhost(): bool + { + return (bool) ($this->instanceSettings()?->webhook_allow_localhost ?? false); + } + + private function instanceSettings(): ?InstanceSettings + { + try { + return InstanceSettings::query()->find(0); + } catch (Throwable) { + return null; + } + } + + private function ipInCidr(string $ip, string $cidr): bool + { + [$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null); + if ($network === null || $prefix === null || ! is_numeric($prefix)) { + return false; + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $this->ipv4InCidr($ip, $cidr); + } + + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) || ! filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return false; + } + + $prefix = (int) $prefix; + if ($prefix < 0 || $prefix > 128) { + return false; + } + + $ipBytes = @inet_pton($ip); + $networkBytes = @inet_pton($network); + if ($ipBytes === false || $networkBytes === false) { + return false; + } + + return $this->binaryInCidr($ipBytes, $networkBytes, $prefix); + } + + private function ipv4InCidr(string $ip, string $cidr): bool + { + [$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null); + if ($network === null || $prefix === null || ! is_numeric($prefix)) { + return false; + } + + $prefix = (int) $prefix; + if ($prefix < 0 || $prefix > 32) { + return false; + } + + $ipLong = ip2long($ip); + $networkLong = ip2long($network); + if ($ipLong === false || $networkLong === false) { + return false; + } + + $mask = $prefix === 0 ? 0 : (-1 << (32 - $prefix)); + + return ($ipLong & $mask) === ($networkLong & $mask); + } + + private function binaryInCidr(string $ipBytes, string $networkBytes, int $prefix): bool + { + $bytes = intdiv($prefix, 8); + $bits = $prefix % 8; + + if ($bytes > 0 && substr($ipBytes, 0, $bytes) !== substr($networkBytes, 0, $bytes)) { + return false; + } + + if ($bits === 0) { return true; } - $long = ip2long($ip); - if ($long === false) { - return false; - } + $mask = 0xFF << (8 - $bits) & 0xFF; - $unsigned = sprintf('%u', $long); - $linkLocalStart = sprintf('%u', ip2long('169.254.0.0')); - $linkLocalEnd = sprintf('%u', ip2long('169.254.255.255')); - - return $unsigned >= $linkLocalStart && $unsigned <= $linkLocalEnd; - } - - private function isBlockedIpv6(string $ip): bool - { - $packed = @inet_pton($ip); - if ($packed === false) { - return false; - } - - if ($packed === inet_pton('::1') || $packed === inet_pton('::')) { - return true; - } - - $bytes = unpack('C16', $packed); - if ($bytes === false) { - return false; - } - - $firstByte = $bytes[1]; - $secondByte = $bytes[2]; - - // fe80::/10 link-local and fc00::/7 unique local addresses. - return ($firstByte === 0xFE && ($secondByte & 0xC0) === 0x80) - || (($firstByte & 0xFE) === 0xFC); + return (ord($ipBytes[$bytes]) & $mask) === (ord($networkBytes[$bytes]) & $mask); } private function extractIpv4FromMappedIpv6(string $ip): ?string diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 365708758..aeb40364a 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -102,6 +102,8 @@ private function sourceItems(): array $this->item('git_repository', 'Repository', $this->application->git_repository, 'build'), $this->item('git_branch', 'Branch', $this->application->git_branch, 'build'), $this->item('git_commit_sha', 'Commit SHA', $this->application->git_commit_sha, 'build'), + $this->item('source_id', 'Source ID', $this->application->source_id, 'build'), + $this->item('source_type', 'Source type', $this->application->source_type, 'build'), $this->item('private_key_id', 'Private key', $this->application->private_key_id, 'build'), ]; } @@ -113,6 +115,8 @@ private function buildItems(): array { return [ $this->item('build_pack', 'Build pack', $this->application->build_pack, 'build'), + $this->item('is_static', 'Static site', data_get($this->application, 'settings.is_static'), 'build'), + $this->item('is_spa', 'Single-page application', data_get($this->application, 'settings.is_spa'), 'build'), $this->item('static_image', 'Static image', $this->application->static_image, 'build'), $this->item('base_directory', 'Base directory', $this->application->base_directory, 'build'), $this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'), @@ -127,7 +131,11 @@ private function buildItems(): array // so comparing it would flag a permanent change for git-based compose apps. $this->item('docker_compose_raw', 'Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw), displayFull: $this->application->docker_compose_raw, diffMode: 'lines'), $this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'), - $this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'), + $this->item('is_git_submodules_enabled', 'Git submodules', data_get($this->application, 'settings.is_git_submodules_enabled'), 'build'), + $this->item('is_git_lfs_enabled', 'Git LFS', data_get($this->application, 'settings.is_git_lfs_enabled'), 'build'), + $this->item('is_git_shallow_clone_enabled', 'Shallow clone', data_get($this->application, 'settings.is_git_shallow_clone_enabled'), 'build'), + $this->item('is_env_sorting_enabled', 'Sort environment variables', data_get($this->application, 'settings.is_env_sorting_enabled'), 'build'), + $this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'redeploy'), $this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'), $this->item('inject_build_args_to_dockerfile', 'Inject build args to Dockerfile', data_get($this->application, 'settings.inject_build_args_to_dockerfile'), 'build'), $this->item('include_source_commit_in_build', 'Include source commit in build', data_get($this->application, 'settings.include_source_commit_in_build'), 'build'), @@ -142,13 +150,26 @@ private function buildItems(): array private function runtimeItems(): array { return [ + $this->item('docker_registry_image_name', 'Docker image', $this->application->docker_registry_image_name, 'redeploy'), + $this->item('docker_registry_image_tag', 'Docker image tag or hash', $this->application->docker_registry_image_tag, 'redeploy'), $this->item('start_command', 'Start command', $this->application->start_command, 'redeploy'), + $this->item('pre_deployment_command', 'Pre-deployment command', $this->application->pre_deployment_command, 'redeploy'), + $this->item('pre_deployment_command_container', 'Pre-deployment command container', $this->application->pre_deployment_command_container, 'redeploy'), + $this->item('post_deployment_command', 'Post-deployment command', $this->application->post_deployment_command, 'redeploy'), + $this->item('post_deployment_command_container', 'Post-deployment command container', $this->application->post_deployment_command_container, 'redeploy'), $this->item('docker_compose_custom_start_command', 'Docker Compose custom start command', $this->application->docker_compose_custom_start_command, 'redeploy'), $this->item('ports_exposes', 'Exposed ports', $this->application->ports_exposes, 'redeploy'), $this->item('ports_mappings', 'Port mappings', $this->application->ports_mappings, 'redeploy'), $this->item('custom_network_aliases', 'Network aliases', $this->application->custom_network_aliases, 'redeploy'), $this->item('connect_to_docker_network', 'Connect to Docker network', data_get($this->application, 'settings.connect_to_docker_network'), 'redeploy'), $this->item('custom_internal_name', 'Custom container name', data_get($this->application, 'settings.custom_internal_name'), 'redeploy'), + $this->item('is_consistent_container_name_enabled', 'Consistent container name', data_get($this->application, 'settings.is_consistent_container_name_enabled'), 'redeploy'), + $this->item('is_container_label_escape_enabled', 'Escape container labels', data_get($this->application, 'settings.is_container_label_escape_enabled'), 'redeploy'), + $this->item('is_container_label_readonly_enabled', 'Read-only container labels', data_get($this->application, 'settings.is_container_label_readonly_enabled'), 'redeploy'), + $this->item('is_log_drain_enabled', 'Log drain', data_get($this->application, 'settings.is_log_drain_enabled'), 'redeploy'), + $this->item('is_swarm_only_worker_nodes', 'Swarm worker nodes only', data_get($this->application, 'settings.is_swarm_only_worker_nodes'), 'redeploy'), + $this->item('stop_grace_period', 'Stop grace period', $this->normalizedStopGracePeriod(), 'redeploy'), + $this->item('is_preserve_repository_enabled', 'Preserve repository', data_get($this->application, 'settings.is_preserve_repository_enabled'), 'redeploy'), $this->item('is_raw_compose_deployment_enabled', 'Raw Compose deployment', data_get($this->application, 'settings.is_raw_compose_deployment_enabled'), 'redeploy'), $this->item('is_gpu_enabled', 'GPU enabled', data_get($this->application, 'settings.is_gpu_enabled'), 'redeploy'), $this->item('gpu_driver', 'GPU driver', data_get($this->application, 'settings.gpu_driver'), 'redeploy'), @@ -170,7 +191,7 @@ private function domainItems(): array $this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'), $this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'), $this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->decodeCustomLabels($this->application->custom_labels)), displayFull: $this->decodeCustomLabels($this->application->custom_labels), diffMode: 'lines'), - $this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration), + $this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'build', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration), $this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'), $this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'), $this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'), @@ -327,6 +348,17 @@ private function environmentDisplayValue(EnvironmentVariable $environmentVariabl return $flags ? "Hidden ({$flags})" : 'Hidden'; } + private function normalizedStopGracePeriod(): ?int + { + $stopGracePeriod = data_get($this->application, 'settings.stop_grace_period'); + + if ($stopGracePeriod === null || (int) $stopGracePeriod === DEFAULT_STOP_GRACE_PERIOD_SECONDS) { + return null; + } + + return (int) $stopGracePeriod; + } + private function environmentFlags(EnvironmentVariable $environmentVariable): string { return collect([ diff --git a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php index 6960a8f1b..8eedf0920 100644 --- a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php +++ b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php @@ -9,7 +9,7 @@ trait SummarizesDiffText * worth expanding. Kept as one constant so the snapshot summary and the * differ's expand decision never drift apart. */ - private const SINGLE_LINE_LIMIT = 120; + private const SINGLE_LINE_LIMIT = 40; /** * Returns the value only when it is worth expanding (multi-line or longer diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index e9707edbe..94c87b13c 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -17,6 +17,28 @@ class ConfigurationDiffer */ private const IGNORED_KEYS = ['build.docker_compose']; + /** + * Defaults for fields introduced after configuration snapshots were first + * stored. Older snapshots omitted these keys, which should not make an + * unchanged default look like a pending configuration change. + * + * @var array> + */ + private const INTRODUCED_DEFAULTS = [ + 'build.is_static' => false, + 'build.is_spa' => false, + 'build.is_git_submodules_enabled' => true, + 'build.is_git_lfs_enabled' => true, + 'build.is_git_shallow_clone_enabled' => true, + 'build.is_env_sorting_enabled' => [false, true], + 'runtime.is_consistent_container_name_enabled' => false, + 'runtime.is_container_label_escape_enabled' => true, + 'runtime.is_container_label_readonly_enabled' => true, + 'runtime.is_log_drain_enabled' => false, + 'runtime.is_swarm_only_worker_nodes' => true, + 'runtime.is_preserve_repository_enabled' => false, + ]; + /** * @param array $previousSnapshot * @param array $currentSnapshot @@ -36,6 +58,14 @@ public function diff(array $previousSnapshot, array $currentSnapshot): Configura $previous = $previousItems[$key] ?? null; $current = $currentItems[$key] ?? null; + if ( + $previous === null + && array_key_exists($key, self::INTRODUCED_DEFAULTS) + && in_array((bool) data_get($current, 'compare_value'), (array) self::INTRODUCED_DEFAULTS[$key], true) + ) { + continue; + } + if (($previous['compare_value'] ?? null) === ($current['compare_value'] ?? null)) { continue; } diff --git a/app/Services/DigitalOceanService.php b/app/Services/DigitalOceanService.php new file mode 100644 index 000000000..c8292e864 --- /dev/null +++ b/app/Services/DigitalOceanService.php @@ -0,0 +1,221 @@ +token) + ->acceptJson() + ->timeout(30) + ->connectTimeout(10) + ->retry(3, function (int $attempt, \Exception $exception) { + if ($exception instanceof RequestException && $exception->response?->status() === 429) { + $resetTime = $exception->response->header('RateLimit-Reset'); + + if ($resetTime) { + return min(max(0, (int) $resetTime - time()), 60) * 1000; + } + } + + return $attempt * 100; + }) + ->{$method}($this->baseUrl.$endpoint, $data); + + if (! $response->successful()) { + if ($response->status() === 429) { + $retryAfter = $response->header('Retry-After'); + if ($retryAfter === null) { + $resetTime = $response->header('RateLimit-Reset'); + $retryAfter = $resetTime ? max(0, (int) $resetTime - time()) : null; + } + + throw new RateLimitException( + 'Rate limit exceeded. Please try again later.', + $retryAfter !== null ? (int) $retryAfter : null + ); + } + + throw new \Exception('DigitalOcean API error: '.$response->json('message', 'Unknown error'), $response->status()); + } + + return $response->json() ?? []; + } + + private function requestPaginated(string $endpoint, string $resourceKey, array $data = []): array + { + $allResults = []; + $page = 1; + + do { + $response = $this->request('get', $endpoint, array_merge($data, [ + 'page' => $page, + 'per_page' => 50, + ])); + + if (isset($response[$resourceKey])) { + $allResults = array_merge($allResults, $response[$resourceKey]); + } + + $hasNextPage = filled(data_get($response, 'links.pages.next')); + $page++; + } while ($hasNextPage); + + return $allResults; + } + + public function getAccount(): array + { + return $this->request('get', '/account')['account'] ?? []; + } + + public function getRegions(): array + { + return array_values(array_filter( + $this->requestPaginated('/regions', 'regions'), + fn (array $region) => ($region['available'] ?? true) === true + )); + } + + public function getSizes(): array + { + return array_values(array_filter( + $this->requestPaginated('/sizes', 'sizes'), + fn (array $size) => ($size['available'] ?? true) === true + )); + } + + public function getImages(): array + { + return array_values(array_filter( + $this->requestPaginated('/images', 'images', ['type' => 'distribution']), + fn (array $image) => ($image['public'] ?? true) === true + )); + } + + public function getSshKeys(): array + { + return $this->requestPaginated('/account/keys', 'ssh_keys'); + } + + public function uploadSshKey(string $name, string $publicKey): array + { + $response = $this->request('post', '/account/keys', [ + 'name' => $name, + 'public_key' => $publicKey, + ]); + + return $response['ssh_key'] ?? []; + } + + public function createDroplet(array $params): array + { + $response = $this->request('post', '/droplets', $params); + + return $response['droplet'] ?? []; + } + + public function getDroplet(int $dropletId): array + { + $response = $this->request('get', $this->dropletEndpoint($dropletId)); + + return $response['droplet'] ?? []; + } + + public function waitForPublicIp(array $droplet, bool $enableIpv4 = true, bool $enableIpv6 = true, int $attempts = 30, int $sleepMilliseconds = 1000): array + { + if ($this->getPublicIpAddress($droplet, $enableIpv4, $enableIpv6) || empty($droplet['id'])) { + return $droplet; + } + + for ($attempt = 0; $attempt < $attempts; $attempt++) { + usleep($sleepMilliseconds * 1000); + + $droplet = $this->getDroplet((int) $droplet['id']); + + if ($this->getPublicIpAddress($droplet, $enableIpv4, $enableIpv6)) { + return $droplet; + } + } + + return $droplet; + } + + public function powerOnDroplet(int $dropletId): array + { + $response = $this->request('post', $this->dropletEndpoint($dropletId).'/actions', [ + 'type' => 'power_on', + ]); + + return $response['action'] ?? []; + } + + public function deleteDroplet(int $dropletId): void + { + $this->request('delete', $this->dropletEndpoint($dropletId)); + } + + public function getDroplets(): array + { + return $this->requestPaginated('/droplets', 'droplets'); + } + + public function findDropletByIp(string $ip): ?array + { + foreach ($this->getDroplets() as $droplet) { + if ($this->dropletHasIp($droplet, $ip)) { + return $droplet; + } + } + + return null; + } + + public function getPublicIpAddress(array $droplet, bool $enableIpv4 = true, bool $enableIpv6 = true): ?string + { + if ($enableIpv4) { + foreach (data_get($droplet, 'networks.v4', []) as $network) { + if (($network['type'] ?? null) === 'public' && filled($network['ip_address'] ?? null)) { + return $network['ip_address']; + } + } + } + + if ($enableIpv6) { + foreach (data_get($droplet, 'networks.v6', []) as $network) { + if (($network['type'] ?? null) === 'public' && filled($network['ip_address'] ?? null)) { + return $network['ip_address']; + } + } + } + + return null; + } + + private function dropletEndpoint(int $dropletId): string + { + return '/droplets/'.$dropletId; + } + + private function dropletHasIp(array $droplet, string $ip): bool + { + foreach (['v4', 'v6'] as $version) { + foreach (data_get($droplet, "networks.{$version}", []) as $network) { + if (($network['ip_address'] ?? null) === $ip) { + return true; + } + } + } + + return false; + } +} diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index 1de7eb2b1..27dbc4a83 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Exceptions\RateLimitException; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; class HetznerService @@ -24,7 +25,7 @@ private function request(string $method, string $endpoint, array $data = []) ->timeout(30) ->retry(3, function (int $attempt, \Exception $exception) { // Handle rate limiting (429 Too Many Requests) - if ($exception instanceof \Illuminate\Http\Client\RequestException) { + if ($exception instanceof RequestException) { $response = $exception->response; if ($response && $response->status() === 429) { @@ -117,6 +118,16 @@ public function getSshKeys(): array return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys'); } + public function getFirewalls(): array + { + return $this->requestPaginated('get', '/firewalls', 'firewalls'); + } + + public function getNetworks(): array + { + return $this->requestPaginated('get', '/networks', 'networks'); + } + public function uploadSshKey(string $name, string $publicKey): array { $response = $this->request('post', '/ssh_keys', [ @@ -129,20 +140,19 @@ public function uploadSshKey(string $name, string $publicKey): array public function createServer(array $params): array { - ray('Hetzner createServer request', [ - 'endpoint' => '/servers', - 'params' => $params, - ]); $response = $this->request('post', '/servers', $params); - ray('Hetzner createServer response', [ - 'response' => $response, - ]); - return $response['server'] ?? []; } + public function enableServerBackup(int $serverId): array + { + $response = $this->request('post', "/servers/{$serverId}/actions/enable_backup"); + + return $response['action'] ?? []; + } + public function getServer(int $serverId): array { $response = $this->request('get', "/servers/{$serverId}"); diff --git a/app/Services/VultrService.php b/app/Services/VultrService.php new file mode 100644 index 000000000..0e335d3e0 --- /dev/null +++ b/app/Services/VultrService.php @@ -0,0 +1,191 @@ + 'Bearer '.$this->token, + ]) + ->timeout(30) + ->retry(3, fn (int $attempt) => $attempt * 100) + ->{$method}($this->baseUrl.$endpoint, $data); + + if (! $response->successful()) { + if ($response->status() === 429) { + throw new RateLimitException( + 'Rate limit exceeded. Please try again later.', + $response->header('Retry-After') !== null ? (int) $response->header('Retry-After') : null + ); + } + + throw new \Exception('Vultr API error: '.$response->json('error', 'Unknown error'), $response->status()); + } + + return $response->json() ?? []; + } + + private function requestPaginated(string $endpoint, string $resourceKey, array $data = []): array + { + $allResults = []; + $cursor = null; + + do { + $query = $data; + $query['per_page'] = 100; + + if ($cursor !== null) { + $query['cursor'] = $cursor; + } + + $response = $this->request('get', $endpoint, $query); + + if (isset($response[$resourceKey])) { + $allResults = array_merge($allResults, $response[$resourceKey]); + } + + $next = $response['meta']['links']['next'] ?? null; + $cursor = $this->cursorFromNextLink($next); + } while ($cursor !== null); + + return $allResults; + } + + private function cursorFromNextLink(?string $next): ?string + { + if (blank($next)) { + return null; + } + + parse_str((string) parse_url($next, PHP_URL_QUERY), $query); + + return $query['cursor'] ?? null; + } + + public function getRegions(): array + { + return $this->requestPaginated('/regions', 'regions'); + } + + public function getPlans(): array + { + return $this->requestPaginated('/plans', 'plans'); + } + + public function getOperatingSystems(): array + { + return $this->requestPaginated('/os', 'os'); + } + + public function getSshKeys(): array + { + return $this->requestPaginated('/ssh-keys', 'ssh_keys'); + } + + public function uploadSshKey(string $name, string $publicKey): array + { + $response = $this->request('post', '/ssh-keys', [ + 'name' => $name, + 'ssh_key' => $publicKey, + ]); + + return $response['ssh_key'] ?? []; + } + + public function createInstance(array $params): array + { + if (! empty($params['user_data'])) { + $params['user_data'] = base64_encode($params['user_data']); + } + + $response = $this->request('post', '/instances', $params); + + return $response['instance'] ?? []; + } + + public function waitForPublicIp(array $instance, bool $disablePublicIpv4 = false, bool $enableIpv6 = true, int $attempts = 6, int $sleepMilliseconds = 1000): array + { + if ($this->getPublicIp($instance, $disablePublicIpv4, $enableIpv6) || empty($instance['id'])) { + return $instance; + } + + for ($attempt = 0; $attempt < $attempts; $attempt++) { + usleep($sleepMilliseconds * 1000); + + $instance = $this->getInstance($instance['id']); + + if ($this->getPublicIp($instance, $disablePublicIpv4, $enableIpv6)) { + return $instance; + } + } + + return $instance; + } + + public function getPublicIp(array $instance, bool $disablePublicIpv4 = false, bool $enableIpv6 = true): ?string + { + $ipv4 = $instance['main_ip'] ?? null; + if (! $disablePublicIpv4 && $this->isUsableIp($ipv4)) { + return $ipv4; + } + + $ipv6 = $instance['v6_main_ip'] ?? null; + if ($enableIpv6 && $this->isUsableIp($ipv6)) { + return $ipv6; + } + + return null; + } + + private function isUsableIp(?string $ip): bool + { + return ! blank($ip) && ! in_array($ip, ['0.0.0.0', '::'], true); + } + + public function getInstance(string $instanceId): array + { + $response = $this->request('get', $this->instanceEndpoint($instanceId)); + + return $response['instance'] ?? []; + } + + public function startInstance(string $instanceId): array + { + return $this->request('post', $this->instanceEndpoint($instanceId).'/start'); + } + + public function deleteInstance(string $instanceId): void + { + $this->request('delete', $this->instanceEndpoint($instanceId)); + } + + public function getInstances(): array + { + return $this->requestPaginated('/instances', 'instances'); + } + + public function findInstanceByIp(string $ip): ?array + { + foreach ($this->getInstances() as $instance) { + if (($instance['main_ip'] ?? null) === $ip || ($instance['v6_main_ip'] ?? null) === $ip) { + return $instance; + } + } + + return null; + } + + private function instanceEndpoint(string $instanceId): string + { + return '/instances/'.rawurlencode($instanceId); + } +} diff --git a/app/Support/ServiceComposeUrl.php b/app/Support/ServiceComposeUrl.php new file mode 100644 index 000000000..cdeb75e58 --- /dev/null +++ b/app/Support/ServiceComposeUrl.php @@ -0,0 +1,55 @@ +, normalized: ?string} + */ + public static function validateUrlString(?string $urlValue, bool $forceDomainOverride = false): array + { + $errors = []; + + if ($urlValue === null || $urlValue === '') { + return ['errors' => [], 'normalized' => null]; + } + + $urls = str($urlValue) + ->replaceStart(',', '') + ->replaceEnd(',', '') + ->trim() + ->explode(',') + ->map(fn ($url) => trim((string) $url)) + ->filter(); + + foreach ($urls as $url) { + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = "Invalid URL: {$url}"; + } + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'], true)) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; + } + } + + $duplicates = $urls->duplicates()->unique()->values(); + if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { + $errors[] = 'The current request contains duplicate URLs: '.implode(', ', $duplicates->toArray()).'. Use force_domain_override=true to proceed.'; + } + + if (count($errors) > 0) { + return ['errors' => $errors, 'normalized' => null]; + } + + $normalized = $urls + ->map(fn ($u) => str($u)->lower()->value()) + ->unique() + ->filter(fn ($u) => filled($u)) + ->implode(','); + + return ['errors' => [], 'normalized' => $normalized !== '' ? $normalized : null]; + } +} diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index d781c7416..af75098b5 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -108,6 +108,12 @@ class ValidationPatterns */ public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u'; + /** + * Characters that are valid in some URL positions but unsafe for values + * that are later reused in shell assignment contexts. + */ + public const APPLICATION_DOMAIN_FORBIDDEN_PATTERN = '/[`$;&|<>()\\\\\r\n]/'; + /** * Pattern for SQL-safe unquoted database identifiers (usernames, database names). * Allows letters, digits, underscore; first char must be letter or underscore. @@ -511,6 +517,156 @@ public static function shellSafeCommandRules(int $maxLength = 1000): array return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN]; } + /** + * Get validation rules for comma-separated application URL fields. + */ + public static function applicationDomainRules(int $maxLength = 2048): array + { + return [ + 'nullable', + 'string', + 'max:'.$maxLength, + function (string $attribute, mixed $value, \Closure $fail): void { + foreach (self::validateApplicationDomains($value) as $error) { + $fail($error); + } + }, + ]; + } + + /** + * Validate a comma-separated list of application URLs. + * + * @return array + */ + public static function validateApplicationDomains(mixed $value): array + { + if (blank($value)) { + return []; + } + + if (! is_string($value)) { + return ['The domains field must be a string.']; + } + + $errors = []; + foreach (self::applicationDomainList($value) as $url) { + if (preg_match(self::APPLICATION_DOMAIN_FORBIDDEN_PATTERN, $url) === 1) { + $errors[] = "Invalid URL: {$url}"; + + continue; + } + + if (! isValidDomainUrl($url)) { + $errors[] = "Invalid URL: {$url}"; + + continue; + } + + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'], true)) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; + + continue; + } + + if (blank(parse_url($url, PHP_URL_HOST))) { + $errors[] = "Invalid URL: {$url}"; + } + } + + return $errors; + } + + /** + * Normalize a comma-separated application URL list for storage. + */ + public static function normalizeApplicationDomains(?string $value): ?string + { + $urls = self::applicationDomainList($value); + + if ($urls === []) { + return null; + } + + return collect($urls) + ->map(fn (string $url) => self::normalizeApplicationDomainUrl($url)) + ->implode(','); + } + + /** + * Normalize URL components that are case-insensitive while preserving + * case-sensitive path, query, and fragment components. + */ + private static function normalizeApplicationDomainUrl(string $url): string + { + $components = parse_url($url); + + if ($components === false) { + return $url; + } + + $normalized = ''; + + if (isset($components['scheme'])) { + $normalized .= strtolower($components['scheme']).'://'; + } + + if (isset($components['user'])) { + $normalized .= $components['user']; + + if (isset($components['pass'])) { + $normalized .= ':'.$components['pass']; + } + + $normalized .= '@'; + } + + if (isset($components['host'])) { + $normalized .= strtolower($components['host']); + } + + if (isset($components['port'])) { + $normalized .= ':'.$components['port']; + } + + if (isset($components['path'])) { + $normalized .= $components['path']; + } + + if (array_key_exists('query', $components)) { + $normalized .= '?'.$components['query']; + } + + if (array_key_exists('fragment', $components)) { + $normalized .= '#'.$components['fragment']; + } + + return $normalized; + } + + /** + * Split a comma-separated application URL list into trimmed URL strings. + * + * @return array + */ + public static function applicationDomainList(?string $value): array + { + if (blank($value)) { + return []; + } + + return str($value) + ->replaceStart(',', '') + ->replaceEnd(',', '') + ->trim() + ->explode(',') + ->map(fn (string $url) => trim($url)) + ->filter(fn (string $url) => filled($url)) + ->values() + ->all(); + } + /** * Get validation rules for Docker volume name fields */ diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php index b9af70aba..e935bfb6d 100644 --- a/app/Traits/ClearsGlobalSearchCache.php +++ b/app/Traits/ClearsGlobalSearchCache.php @@ -3,6 +3,11 @@ namespace App\Traits; use App\Livewire\GlobalSearch; +use App\Models\Application; +use App\Models\Environment; +use App\Models\Project; +use App\Models\Server; +use App\Models\Service; use Illuminate\Database\Eloquent\Model; trait ClearsGlobalSearchCache @@ -20,7 +25,6 @@ protected static function bootClearsGlobalSearchCache() } } catch (\Throwable $e) { // Silently fail cache clearing - don't break the save operation - ray('Failed to clear global search cache on saving: '.$e->getMessage()); } }); @@ -33,7 +37,6 @@ protected static function bootClearsGlobalSearchCache() } } catch (\Throwable $e) { // Silently fail cache clearing - don't break the create operation - ray('Failed to clear global search cache on creation: '.$e->getMessage()); } }); @@ -46,7 +49,6 @@ protected static function bootClearsGlobalSearchCache() } } catch (\Throwable $e) { // Silently fail cache clearing - don't break the delete operation - ray('Failed to clear global search cache on deletion: '.$e->getMessage()); } }); } @@ -58,14 +60,14 @@ private function hasSearchableChanges(): bool $searchableFields = ['name', 'description']; // Add model-specific searchable fields - if ($this instanceof \App\Models\Application) { + if ($this instanceof Application) { $searchableFields[] = 'fqdn'; $searchableFields[] = 'docker_compose_domains'; - } elseif ($this instanceof \App\Models\Server) { + } elseif ($this instanceof Server) { $searchableFields[] = 'ip'; - } elseif ($this instanceof \App\Models\Service) { + } elseif ($this instanceof Service) { // Services don't have direct fqdn, but name and description are covered - } elseif ($this instanceof \App\Models\Project || $this instanceof \App\Models\Environment) { + } elseif ($this instanceof Project || $this instanceof Environment) { // Projects and environments only have name and description as searchable } // Database models only have name and description as searchable @@ -81,7 +83,6 @@ private function hasSearchableChanges(): bool return false; } catch (\Throwable $e) { // If checking changes fails, assume changes exist to be safe - ray('Failed to check searchable changes: '.$e->getMessage()); return true; } @@ -91,18 +92,18 @@ private function getTeamIdForCache() { try { // For Project models (has direct team_id) - if ($this instanceof \App\Models\Project) { + if ($this instanceof Project) { return $this->team_id ?? null; } // For Environment models (get team_id through project) - if ($this instanceof \App\Models\Environment) { + if ($this instanceof Environment) { return $this->project?->team_id; } // For database models, team is accessed through environment.project.team if (method_exists($this, 'team')) { - if ($this instanceof \App\Models\Server) { + if ($this instanceof Server) { $team = $this->team; } else { $team = $this->team(); @@ -120,7 +121,6 @@ private function getTeamIdForCache() return null; } catch (\Throwable $e) { // If we can't determine team ID, return null - ray('Failed to get team ID for cache: '.$e->getMessage()); return null; } diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index 37303c7e6..901e957b2 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -82,7 +82,6 @@ protected function executeWithSshRetry(callable $callback, array $context = [], $lastErrorMessage = ''; // Randomly fail the command with a key exchange error for testing // if (random_int(1, 10) === 1) { // 10% chance to fail - // ray('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer'); // throw new \RuntimeException('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer'); // } for ($attempt = 0; $attempt < $maxRetries; $attempt++) { diff --git a/app/View/Components/Forms/Button.php b/app/View/Components/Forms/Button.php index 8511c87db..f03b36f0e 100644 --- a/app/View/Components/Forms/Button.php +++ b/app/View/Components/Forms/Button.php @@ -22,6 +22,7 @@ public function __construct( public ?string $canGate = null, public mixed $canResource = null, public bool $autoDisable = true, + public ?string $tooltip = null, ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 6a288a064..e314ead82 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -3,10 +3,15 @@ use App\Enums\BuildPackTypes; use App\Enums\RedirectTypes; use App\Enums\StaticImageTypes; +use App\Models\Environment; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; function getTeamIdFromToken() @@ -87,6 +92,20 @@ function serializeApiResponse($data) } } +/** + * Re-expose a model's `$hidden` sensitive fields when the current API request + * carries the `read:sensitive` or `root` token ability (set by the + * ApiSensitiveData middleware). + */ +function exposeSensitiveFields(Model $model): Model +{ + if (request()->attributes->get('can_read_sensitive', false) === true && filled($model->getHidden())) { + $model->makeVisible($model->getHidden()); + } + + return $model; +} + function sharedDataApplications() { return [ @@ -97,8 +116,23 @@ function sharedDataApplications() 'is_spa' => 'boolean', 'is_auto_deploy_enabled' => 'boolean', 'is_force_https_enabled' => 'boolean', + 'is_preview_deployments_enabled' => 'boolean', + 'use_build_secrets' => 'boolean', + 'is_git_submodules_enabled' => 'boolean', + 'is_git_lfs_enabled' => 'boolean', + 'is_git_shallow_clone_enabled' => 'boolean', + 'disable_build_cache' => 'boolean', + 'inject_build_args_to_dockerfile' => 'boolean', + 'include_source_commit_in_build' => 'boolean', + 'is_env_sorting_enabled' => 'boolean', + 'is_pr_deployments_public_enabled' => 'boolean', + 'is_gzip_enabled' => 'boolean', + 'is_stripprefix_enabled' => 'boolean', + 'is_raw_compose_deployment_enabled' => 'boolean', + 'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS, + 'docker_images_to_keep' => 'integer|min:0|max:100', 'static_image' => Rule::enum(StaticImageTypes::class), - 'domains' => 'string|nullable', + 'domains' => ValidationPatterns::applicationDomainRules(), 'redirect' => Rule::enum(RedirectTypes::class), 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(), @@ -156,6 +190,64 @@ function sharedDataApplications() ]; } +function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): JsonResponse +{ + + $validator = Validator::make($request->all(), [ + 'environment_uuid' => 'required|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $extraFields = array_diff(array_keys($request->all()), ['environment_uuid']); + if (! empty($extraFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(), + ], 422); + } + + $newEnvironment = Environment::ownedByCurrentTeamAPI($teamId) + ->whereUuid($request->environment_uuid) + ->first(); + + if (! $newEnvironment) { + return response()->json(['message' => 'Target environment not found or not owned by your team.'], 404); + } + + Gate::authorize('update', $newEnvironment); + + if ($resource->environment_id === $newEnvironment->id) { + return response()->json(['message' => "$resourceType is already in this environment."], 400); + } + + $oldEnvironment = $resource->environment()->with('project')->first(); + + $resource->update(['environment_id' => $newEnvironment->id]); + + auditLog('api.'.str($resourceType)->lower()->value().'.moved', [ + 'team_id' => $teamId, + 'resource_uuid' => $resource->uuid, + 'resource_type' => str($resourceType)->lower()->value(), + 'from_project_uuid' => $oldEnvironment?->project?->uuid, + 'from_environment_uuid' => $oldEnvironment?->uuid, + 'to_project_uuid' => $newEnvironment->project->uuid, + 'to_environment_uuid' => $newEnvironment->uuid, + ]); + + return response()->json([ + 'message' => "$resourceType moved successfully.", + 'uuid' => $resource->uuid, + 'project_uuid' => $newEnvironment->project->uuid, + 'environment_uuid' => $newEnvironment->uuid, + ]); +} + function validateIncomingRequest(Request $request) { // check if request is json @@ -194,14 +286,30 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('github_app_uuid'); $request->offsetUnset('private_key_uuid'); $request->offsetUnset('use_build_server'); + $request->offsetUnset('use_build_secrets'); $request->offsetUnset('is_static'); $request->offsetUnset('is_spa'); $request->offsetUnset('is_auto_deploy_enabled'); $request->offsetUnset('is_force_https_enabled'); + $request->offsetUnset('is_preview_deployments_enabled'); $request->offsetUnset('connect_to_docker_network'); $request->offsetUnset('force_domain_override'); $request->offsetUnset('autogenerate_domain'); $request->offsetUnset('is_container_label_escape_enabled'); $request->offsetUnset('is_preserve_repository_enabled'); + $request->offsetUnset('include_source_commit_in_build'); + $request->offsetUnset('is_git_submodules_enabled'); + $request->offsetUnset('is_git_lfs_enabled'); + $request->offsetUnset('is_git_shallow_clone_enabled'); + $request->offsetUnset('disable_build_cache'); + $request->offsetUnset('inject_build_args_to_dockerfile'); + $request->offsetUnset('is_env_sorting_enabled'); + $request->offsetUnset('is_pr_deployments_public_enabled'); + $request->offsetUnset('stop_grace_period'); + $request->offsetUnset('docker_images_to_keep'); + $request->offsetUnset('is_gzip_enabled'); + $request->offsetUnset('is_stripprefix_enabled'); + $request->offsetUnset('is_raw_compose_deployment_enabled'); $request->offsetUnset('docker_compose_raw'); + $request->offsetUnset('tags'); } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 2f7cc95ef..0440ae352 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -72,6 +72,36 @@ function getCurrentServiceContainerStatus(Server $server, int $id): Collection return $containers; } +function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection +{ + $containers = collect([]); + if (! $server->isSwarm()) { + $containers = instant_remote_process(["docker ps -a --filter='label=coolify.databaseId={$id}' --format '{{json .}}' "], $server); + $containers = format_docker_command_output_to_json($containers); + + return $containers->filter(); + } + + return $containers; +} + +function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection +{ + return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $id), $name); +} + +function filterServiceSubContainersByName(Collection $containers, string $name): Collection +{ + return $containers->filter(function ($container) use ($name) { + $labels = data_get($container, 'Labels', []); + if (is_string($labels)) { + $labels = format_docker_labels_to_json($labels); + } + + return collect($labels)->get('coolify.name') === $name; + })->values(); +} + function format_docker_command_output_to_json($rawOutput): Collection { $outputLines = explode(PHP_EOL, $rawOutput); @@ -148,13 +178,18 @@ function executeInDocker(string $containerId, string $command) return "docker exec {$containerId} bash -c '{$escapedCommand}'"; } -function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false) +function buildContainerStatusCommand(Server $server, string $container_id): string { if ($server->isSwarm()) { - $container = instant_remote_process(["docker service ls --filter 'name={$container_id}' --format '{{json .}}' "], $server, $throwError); - } else { - $container = instant_remote_process(["docker inspect --format '{{json .}}' {$container_id}"], $server, $throwError); + return 'docker service ls --filter '.escapeshellarg("name={$container_id}")." --format '{{json .}}' "; } + + return "docker inspect --format '{{json .}}' ".escapeshellarg($container_id); +} + +function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false) +{ + $container = instant_remote_process([buildContainerStatusCommand($server, $container_id)], $server, $throwError); if (! $container) { return 'exited'; } @@ -1247,18 +1282,38 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable } } -function getContainerLogs(Server $server, string $container_id, int $lines = 100): string +function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int { - if ($server->isSwarm()) { - $output = instant_remote_process([ - "docker service logs -n {$lines} {$container_id} 2>&1", - ], $server); - } else { - $output = instant_remote_process([ - "docker logs -n {$lines} {$container_id} 2>&1", - ], $server); + $lines = filter_var($lines, FILTER_VALIDATE_INT); + if ($lines === false || $lines <= 0) { + return $default; } + return min($lines, $max); +} + +function parseLogTimestampFlag(mixed $showTimestamps): bool +{ + return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; +} + +function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string +{ + $command = "docker logs -n {$lines}"; + if ($server->isSwarm()) { + $command = "docker service logs -n {$lines}"; + } + + if ($showTimestamps) { + $command .= ' --timestamps'; + } + + return "{$command} ".escapeshellarg($container_id).' 2>&1'; +} + +function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string +{ + $output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server); $output = removeAnsiColors($output); return $output; diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index ff77a78e2..f3c5359f7 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -4,6 +4,54 @@ use App\Models\ServiceApplication; use Illuminate\Support\Collection; +function isValidDomainUrl(string $url): bool +{ + $components = parse_url($url); + + if ($components === false) { + return false; + } + + $scheme = $components['scheme'] ?? ''; + $host = $components['host'] ?? ''; + + if (! in_array(strtolower($scheme), ['http', 'https'], true) || $host === '') { + return false; + } + + $urlToValidate = $scheme.'://'; + + if (isset($components['user'])) { + $urlToValidate .= $components['user']; + + if (isset($components['pass'])) { + $urlToValidate .= ':'.$components['pass']; + } + + $urlToValidate .= '@'; + } + + $urlToValidate .= str_replace('_', '-', $host); + + if (isset($components['port'])) { + $urlToValidate .= ':'.$components['port']; + } + + if (isset($components['path'])) { + $urlToValidate .= $components['path']; + } + + if (isset($components['query'])) { + $urlToValidate .= '?'.$components['query']; + } + + if (isset($components['fragment'])) { + $urlToValidate .= '#'.$components['fragment']; + } + + return filter_var($urlToValidate, FILTER_VALIDATE_URL) !== false; +} + function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null) { $conflicts = []; diff --git a/bootstrap/helpers/email.php b/bootstrap/helpers/email.php new file mode 100644 index 000000000..a0b8ba67f --- /dev/null +++ b/bootstrap/helpers/email.php @@ -0,0 +1,20 @@ +api_url}/zen"); + if (blank($url)) { + return null; + } + + $host = parse_url($url, PHP_URL_HOST); + + if (! is_string($host) || blank($host)) { + return null; + } + + return strtolower($host); +} + +/** + * Build the scheme://host[:port] origin for a GitHub URL. + * + * This helper fails explicitly for blank, scheme-less, or malformed input when + * githubUrlHost() cannot parse a host, because returning the original input + * would not be a valid origin. Callers should pass already-validated URLs. + * + * @param string $url The URL to derive the origin from + * @return string The normalized origin + * + * @throws InvalidArgumentException When the URL does not contain a parseable scheme and host + */ +function githubUrlOrigin(string $url): string +{ + $scheme = parse_url($url, PHP_URL_SCHEME); + $host = githubUrlHost($url); + $port = parse_url($url, PHP_URL_PORT); + + if (! is_string($scheme) || blank($scheme) || ! $host) { + throw new InvalidArgumentException('GitHub URL must include a valid scheme and host.'); + } + + return $scheme.'://'.$host.($port ? ":{$port}" : ''); +} + +/** + * Determine whether the URL points at github.com. + * + * @param string|null $htmlUrl The GitHub HTML URL to check + */ +function isGithubDotComHost(?string $htmlUrl): bool +{ + return githubUrlHost($htmlUrl) === 'github.com'; +} + +/** + * Determine whether the URL points at a *.ghe.com GitHub Enterprise Cloud host. + * + * @param string|null $htmlUrl The GitHub HTML URL to check + */ +function isGheDotComHost(?string $htmlUrl): bool +{ + $host = githubUrlHost($htmlUrl); + + return is_string($host) + && Str::endsWith($host, '.ghe.com') + && ! Str::startsWith($host, 'api.'); +} + +/** + * Determine whether the URL belongs to GitHub's cloud family (github.com or *.ghe.com). + * + * @param string|null $htmlUrl The GitHub HTML URL to check + */ +function isGithubCloudFamilyHost(?string $htmlUrl): bool +{ + return isGithubDotComHost($htmlUrl) || isGheDotComHost($htmlUrl); +} + +/** + * Determine whether the URL belongs to a self-hosted GitHub Enterprise Server. + * + * @param string|null $htmlUrl The GitHub HTML URL to check + */ +function isGithubEnterpriseServerHost(?string $htmlUrl): bool +{ + return filled($htmlUrl) && ! isGithubCloudFamilyHost($htmlUrl); +} + +/** + * Derive the GitHub REST API base URL from a GitHub HTML URL. + * + * @param string $htmlUrl The GitHub HTML URL + * @return string The API base URL (api.github.com, api. for *.ghe.com, or /api/v3 for GHES) + */ +function githubApiUrlFromHtmlUrl(string $htmlUrl): string +{ + if (isGithubDotComHost($htmlUrl)) { + return 'https://api.github.com'; + } + + if (isGheDotComHost($htmlUrl)) { + return 'https://api.'.githubUrlHost($htmlUrl); + } + + return githubUrlOrigin($htmlUrl).'/api/v3'; +} + +/** + * Normalize a GitHub organization slug by trimming surrounding slashes and whitespace. + * + * @param string|null $organization The raw organization value + * @return string|null The trimmed organization, or null when blank + */ +function normalizeGithubOrganization(?string $organization): ?string +{ + if (blank($organization)) { + return null; + } + + return trim((string) $organization, "/ \t\n\r\0\x0B"); +} + +/** + * URL-encode a single GitHub path segment. + * + * @param string $segment The raw path segment + * @return string The raw-URL-encoded segment + */ +function encodeGithubPathSegment(string $segment): string +{ + return rawurlencode($segment); +} + +function assertGithubClockInSync(string $apiUrl): void +{ + $response = Http::get("{$apiUrl}/zen"); $serverTime = CarbonImmutable::now()->setTimezone('UTC'); $githubTime = Carbon::parse($response->header('date')); $timeDiff = abs($serverTime->diffInSeconds($githubTime)); @@ -29,6 +165,11 @@ function generateGithubToken(GithubApp $source, string $type) 'Please synchronize your system clock.' ); } +} + +function generateGithubToken(GithubApp $source, string $type) +{ + assertGithubClockInSync($source->api_url); $signingKey = InMemory::plainText($source->privateKey->private_key); $algorithm = new Sha256; @@ -117,11 +258,86 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m ]; } +function generateGithubAppJwt(string $privateKey, string|int $appId): string +{ + $algorithm = new Sha256; + $tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default())); + $now = CarbonImmutable::now()->setTimezone('UTC'); + $now = $now->setTime($now->format('H'), $now->format('i'), $now->format('s')); + + return $tokenBuilder + ->issuedBy((string) $appId) + ->issuedAt($now->modify('-1 minute')) + ->expiresAt($now->modify('+8 minutes')) + ->getToken($algorithm, InMemory::plainText($privateKey)) + ->toString(); +} + +function syncGithubAppName(GithubApp $source, bool $throw = false): ?string +{ + try { + if (blank($source->app_id) || blank($source->private_key_id)) { + return null; + } + + $privateKey = $source->privateKey ?: PrivateKey::find($source->private_key_id); + + if (! $privateKey) { + return null; + } + + assertGithubClockInSync($source->api_url); + + $jwt = generateGithubAppJwt($privateKey->private_key, $source->app_id); + + $response = Http::withHeaders([ + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + 'Authorization' => "Bearer {$jwt}", + ])->get("{$source->api_url}/app"); + + if (! $response->successful()) { + throw new RuntimeException(data_get($response->json(), 'message', 'Failed to fetch GitHub App information.')); + } + + $appSlug = data_get($response->json(), 'slug'); + + if (blank($appSlug)) { + return null; + } + + $source->name = $appSlug; + + if ($source->exists) { + $source->save(); + } + + $privateKey->name = "github-app-{$appSlug}"; + $privateKey->save(); + + return $appSlug; + } catch (Throwable $e) { + if ($throw) { + throw $e; + } + + return null; + } +} + function getInstallationPath(GithubApp $source): string { - $name = str(Str::kebab($source->name)); - $installation_path = $source->html_url === 'https://github.com' ? 'apps' : 'github-apps'; + $name = encodeGithubPathSegment(Str::kebab($source->name)); $state = Str::random(64); + $organization = normalizeGithubOrganization($source->organization); + + if (isGithubEnterpriseServerHost($source->html_url)) { + $path = "github-apps/{$name}"; + } elseif (isGheDotComHost($source->html_url) && filled($organization)) { + $path = 'apps/'.encodeGithubPathSegment($organization)."/{$name}"; + } else { + $path = "apps/{$name}"; + } Cache::put('github-app-setup-state:'.hash('sha256', $state), [ 'action' => 'install', @@ -129,15 +345,19 @@ function getInstallationPath(GithubApp $source): string 'team_id' => $source->team_id, ], now()->addMinutes(60)); - return "$source->html_url/$installation_path/$name/installations/new?".http_build_query(['state' => $state]); + return rtrim($source->html_url, '/')."/{$path}/installations/new?".http_build_query(['state' => $state]); } function getPermissionsPath(GithubApp $source) { - $github = GithubApp::where('uuid', $source->uuid)->first(); - $name = str(Str::kebab($github->name)); + $name = encodeGithubPathSegment(Str::kebab($source->name)); + $organization = normalizeGithubOrganization($source->organization); - return "$github->html_url/settings/apps/$name/permissions"; + if (filled($organization)) { + return rtrim($source->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}/permissions"; + } + + return rtrim($source->html_url, '/')."/settings/apps/{$name}/permissions"; } function loadRepositoryByPage(GithubApp $source, string $token, int $page) @@ -189,12 +409,33 @@ function getGithubCommitRangeFiles(?GithubApp $source, string $owner, string $re return $files->pluck('filename')->filter()->values()->toArray(); } catch (Exception $e) { - ray('Error fetching GitHub commit range files: '.$e->getMessage()); return []; } } +function getGithubCommitMessage(?GithubApp $source, string $owner, string $repo, string $commitSha): ?string +{ + try { + if (! $source) { + return null; + } + + if (blank($owner) || blank($repo) || blank($commitSha) || $commitSha === 'HEAD') { + return null; + } + + $endpoint = "/repos/{$owner}/{$repo}/commits/{$commitSha}"; + $response = githubApi($source, $endpoint, 'get', null, false); + + $message = data_get($response, 'data.commit.message'); + + return is_string($message) ? $message : null; + } catch (Exception $e) { + return null; + } +} + function getGithubPullRequestFiles(?GithubApp $source, string $owner, string $repo, int $pullRequestId): array { try { @@ -215,7 +456,6 @@ function getGithubPullRequestFiles(?GithubApp $source, string $owner, string $re return $files->pluck('filename')->filter()->values()->toArray(); } catch (Exception $e) { - ray('Error fetching GitHub PR files: '.$e->getMessage()); return []; } diff --git a/bootstrap/helpers/notifications.php b/bootstrap/helpers/notifications.php index bee39ef01..f64535ea8 100644 --- a/bootstrap/helpers/notifications.php +++ b/bootstrap/helpers/notifications.php @@ -18,8 +18,7 @@ function send_internal_notification(string $message): void try { $team = Team::find(0); $team?->notify(new GeneralNotification($message)); - } catch (\Throwable $e) { - ray($e->getMessage()); + } catch (Throwable) { } } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 6632e1fd5..ff1d6563e 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -370,8 +370,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $pullRequestId = $pull_request_id; $isPullRequest = $pullRequestId == 0 ? false : true; $server = data_get($resource, 'destination.server'); - $fileStorages = $resource->fileStorages(); - try { $yaml = Yaml::parse($compose); } catch (Exception) { @@ -503,6 +501,40 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, ]); } + + } + + // Also populate docker_compose_domains for dockercompose apps from direct SERVICE_* declarations. + if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) { + $parsed = parseServiceEnvironmentVariable($key->value()); + $normalizedServiceName = str($parsed['service_name'])->replace('-', '_')->replace('.', '_')->value(); + $serviceExists = false; + foreach (array_keys($services) as $serviceNameKey) { + if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedServiceName) { + $serviceExists = true; + break; + } + } + if ($serviceExists) { + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]')); + $domainExists = data_get($domains->get($normalizedServiceName), 'domain'); + if (is_null($domainExists)) { + $serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value(); + $domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid"); + if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) { + $path = $value->value(); + if ($path !== '/') { + $domainValue = "$domainValue$path"; + } + } + if ($parsed['port'] && is_numeric($parsed['port'])) { + $domainValue = "$domainValue:{$parsed['port']}"; + } + $domains->put($normalizedServiceName, ['domain' => $domainValue]); + $resource->docker_compose_domains = $domains->toJson(); + $resource->save(); + } + } } } @@ -610,7 +642,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Only add domain if the service exists if ($serviceExists) { - $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]')); $domainExists = data_get($domains->get($serviceName), 'domain'); // Update domain using URL with port if applicable @@ -703,14 +735,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $source = $parsed['source']; $target = $parsed['target']; // Mode is available in $parsed['mode'] if needed - $foundConfig = $fileStorages->whereMountPath($target)->first(); + $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first(); if (sourceIsLocal($source)) { $type = str('bind'); if ($foundConfig) { - $contentNotNull_temp = data_get($foundConfig, 'content'); - if ($contentNotNull_temp) { - $content = $contentNotNull_temp; - } + $content = data_get($foundConfig, 'content'); $isDirectory = data_get($foundConfig, 'is_directory'); } else { // By default, we cannot determine if the bind is a directory or not, so we set it to directory @@ -756,12 +785,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } - $foundConfig = $fileStorages->whereMountPath($target)->first(); + $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first(); if ($foundConfig) { - $contentNotNull_temp = data_get($foundConfig, 'content'); - if ($contentNotNull_temp) { - $content = $contentNotNull_temp; - } + $content = data_get($foundConfig, 'content'); $isDirectory = data_get($foundConfig, 'is_directory'); } else { // if isDirectory is not set (or false) & content is also not set, we assume it is a directory @@ -1488,9 +1514,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); - } catch (Exception $e) { + } catch (Exception) { // If parsing fails, keep the original docker_compose_raw unchanged - ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage()); } data_forget($resource, 'environment_variables'); @@ -2070,7 +2095,6 @@ function serviceParser(Service $resource): Collection 'service_id' => $resource->id, ]); } - $fileStorages = $savedService->fileStorages(); if ($savedService->image !== $image) { $savedService->image = $image; $savedService->save(); @@ -2090,14 +2114,11 @@ function serviceParser(Service $resource): Collection $source = $parsed['source']; $target = $parsed['target']; // Mode is available in $parsed['mode'] if needed - $foundConfig = $fileStorages->whereMountPath($target)->first(); + $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first(); if (sourceIsLocal($source)) { $type = str('bind'); if ($foundConfig) { - $contentNotNull_temp = data_get($foundConfig, 'content'); - if ($contentNotNull_temp) { - $content = $contentNotNull_temp; - } + $content = data_get($foundConfig, 'content'); $isDirectory = data_get($foundConfig, 'is_directory'); } else { // By default, we cannot determine if the bind is a directory or not, so we set it to directory @@ -2143,12 +2164,9 @@ function serviceParser(Service $resource): Collection } } - $foundConfig = $fileStorages->whereMountPath($target)->first(); + $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first(); if ($foundConfig) { - $contentNotNull_temp = data_get($foundConfig, 'content'); - if ($contentNotNull_temp) { - $content = $contentNotNull_temp; - } + $content = data_get($foundConfig, 'content'); $isDirectory = data_get($foundConfig, 'is_directory'); } else { // if isDirectory is not set (or false) & content is also not set, we assume it is a directory @@ -2747,7 +2765,6 @@ function serviceParser(Service $resource): Collection $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); } catch (Exception $e) { // If parsing fails, keep the original docker_compose_raw unchanged - ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage()); } data_forget($resource, 'environment_variables'); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index ab47c067a..10de1f86f 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1764,7 +1764,6 @@ function validateDNSEntry(string $fqdn, Server $server) $query = new DNSQuery($dns_server); $results = $query->query($host, $type); if ($results === false || $query->hasError()) { - ray('Error: '.$query->getLasterror()); } else { foreach ($results as $result) { if ($result->getType() == $type) { @@ -3749,6 +3748,27 @@ function redirectRoute(Component $component, string $name, array $parameters = [ return $component->redirectRoute($name, $parameters, navigate: $navigate); } +function coolifyRegistryUrl(): string +{ + try { + return instanceSettings()->docker_registry_url ?: 'docker.io'; + } catch (Throwable) { + return config('constants.coolify.registry_url', 'docker.io'); + } +} + +function coolifyHelperImage(): string +{ + $configuredHelperImage = config('constants.coolify.helper_image'); + $configuredDefaultHelperImage = config('constants.coolify.registry_url', 'docker.io').'/coollabsio/coolify-helper'; + + if ($configuredHelperImage !== $configuredDefaultHelperImage) { + return $configuredHelperImage; + } + + return coolifyRegistryUrl().'/coollabsio/coolify-helper'; +} + function getHelperVersion(): string { $settings = instanceSettings(); @@ -3766,9 +3786,6 @@ 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'); } diff --git a/composer.json b/composer.json index 923bc841b..25f406d81 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,6 @@ "spatie/laravel-activitylog": "^4.11.0", "spatie/laravel-data": "^4.19.1", "spatie/laravel-markdown": "^2.7.1", - "spatie/laravel-ray": "^1.43.5", "spatie/laravel-schemaless-attributes": "^2.5.1", "spatie/url": "^2.4", "stevebauman/purify": "^6.3.1", diff --git a/composer.lock b/composer.lock index 8c11e8759..228800c22 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": "115a84e56a483f0c336c220b4d0246d7", + "content-hash": "0cf904aa97d276a2eb2eefc9c7bbd0f7", "packages": [ { "name": "aws/aws-crt-php", @@ -4755,134 +4755,6 @@ }, "time": "2020-10-15T08:29:30+00:00" }, - { - "name": "php-di/invoker", - "version": "2.3.7", - "source": { - "type": "git", - "url": "https://github.com/PHP-DI/Invoker.git", - "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1", - "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1", - "shasum": "" - }, - "require": { - "php": ">=7.3", - "psr/container": "^1.0|^2.0" - }, - "require-dev": { - "athletic/athletic": "~0.1.8", - "mnapoli/hard-mode": "~0.3.0", - "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12" - }, - "type": "library", - "autoload": { - "psr-4": { - "Invoker\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Generic and extensible callable invoker", - "homepage": "https://github.com/PHP-DI/Invoker", - "keywords": [ - "callable", - "dependency", - "dependency-injection", - "injection", - "invoke", - "invoker" - ], - "support": { - "issues": "https://github.com/PHP-DI/Invoker/issues", - "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7" - }, - "funding": [ - { - "url": "https://github.com/mnapoli", - "type": "github" - } - ], - "time": "2025-08-30T10:22:22+00:00" - }, - { - "name": "php-di/php-di", - "version": "7.1.1", - "source": { - "type": "git", - "url": "https://github.com/PHP-DI/PHP-DI.git", - "reference": "f88054cc052e40dbe7b383c8817c19442d480352" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352", - "reference": "f88054cc052e40dbe7b383c8817c19442d480352", - "shasum": "" - }, - "require": { - "laravel/serializable-closure": "^1.0 || ^2.0", - "php": ">=8.0", - "php-di/invoker": "^2.0", - "psr/container": "^1.1 || ^2.0" - }, - "provide": { - "psr/container-implementation": "^1.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3", - "friendsofphp/proxy-manager-lts": "^1", - "mnapoli/phpunit-easymock": "^1.3", - "phpunit/phpunit": "^9.6 || ^10 || ^11", - "vimeo/psalm": "^5|^6" - }, - "suggest": { - "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "DI\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "The dependency injection container for humans", - "homepage": "https://php-di.org/", - "keywords": [ - "PSR-11", - "container", - "container-interop", - "dependency injection", - "di", - "ioc", - "psr11" - ], - "support": { - "issues": "https://github.com/PHP-DI/PHP-DI/issues", - "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1" - }, - "funding": [ - { - "url": "https://github.com/mnapoli", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/php-di/php-di", - "type": "tidelift" - } - ], - "time": "2025-08-16T11:10:48+00:00" - }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -7028,70 +6900,6 @@ }, "time": "2024-11-07T21:57:40+00:00" }, - { - "name": "spatie/backtrace", - "version": "1.8.2", - "source": { - "type": "git", - "url": "https://github.com/spatie/backtrace.git", - "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/spatie/backtrace/zipball/8ffe78be5ed355b5009e3dd989d183433e9a5adc", - "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc", - "shasum": "" - }, - "require": { - "php": "^7.3 || ^8.0" - }, - "require-dev": { - "ext-json": "*", - "laravel/serializable-closure": "^1.3 || ^2.0", - "phpunit/phpunit": "^9.3 || ^11.4.3", - "spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6", - "symfony/var-dumper": "^5.1|^6.0|^7.0|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Spatie\\Backtrace\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Freek Van de Herten", - "email": "freek@spatie.be", - "homepage": "https://spatie.be", - "role": "Developer" - } - ], - "description": "A better backtrace", - "homepage": "https://github.com/spatie/backtrace", - "keywords": [ - "Backtrace", - "spatie" - ], - "support": { - "issues": "https://github.com/spatie/backtrace/issues", - "source": "https://github.com/spatie/backtrace/tree/1.8.2" - }, - "funding": [ - { - "url": "https://github.com/sponsors/spatie", - "type": "github" - }, - { - "url": "https://spatie.be/open-source/support-us", - "type": "other" - } - ], - "time": "2026-03-11T13:48:28+00:00" - }, { "name": "spatie/commonmark-shiki-highlighter", "version": "2.5.2", @@ -7464,95 +7272,6 @@ ], "time": "2026-05-19T14:06:37+00:00" }, - { - "name": "spatie/laravel-ray", - "version": "1.43.9", - "source": { - "type": "git", - "url": "https://github.com/spatie/laravel-ray.git", - "reference": "85137a6ea1d3ecd5ad3adcb43512fff9a5529e72" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/85137a6ea1d3ecd5ad3adcb43512fff9a5529e72", - "reference": "85137a6ea1d3ecd5ad3adcb43512fff9a5529e72", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2.2", - "ext-json": "*", - "illuminate/contracts": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", - "illuminate/database": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", - "illuminate/queue": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", - "php": "^7.4|^8.0", - "spatie/backtrace": "^1.7.1", - "spatie/ray": "^1.45.0", - "symfony/stopwatch": "4.2|^5.1|^6.0|^7.0|^8.0", - "zbateson/mail-mime-parser": "^1.3.1|^2.0|^3.0|^4.0" - }, - "require-dev": { - "guzzlehttp/guzzle": "^7.3", - "laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0", - "laravel/pint": "^1.29", - "orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", - "pestphp/pest": "^1.22|^2.0|^3.0|^4.0", - "phpstan/phpstan": "^1.10.57|^2.0.2", - "phpunit/phpunit": "^9.3|^10.1|^11.0.10|^12.4", - "rector/rector": "^0.19.2|^1.0.1|^2.0.0", - "spatie/pest-plugin-snapshots": "^1.1|^2.0", - "symfony/var-dumper": "^4.2|^5.1|^6.0|^7.0.3|^8.0" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Spatie\\LaravelRay\\RayServiceProvider" - ] - }, - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Spatie\\LaravelRay\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Freek Van der Herten", - "email": "freek@spatie.be", - "homepage": "https://spatie.be", - "role": "Developer" - } - ], - "description": "Easily debug Laravel apps", - "homepage": "https://github.com/spatie/laravel-ray", - "keywords": [ - "laravel-ray", - "spatie" - ], - "support": { - "issues": "https://github.com/spatie/laravel-ray/issues", - "source": "https://github.com/spatie/laravel-ray/tree/1.43.9" - }, - "funding": [ - { - "url": "https://github.com/sponsors/spatie", - "type": "github" - }, - { - "url": "https://spatie.be/open-source/support-us", - "type": "other" - } - ], - "time": "2026-04-28T06:07:04+00:00" - }, { "name": "spatie/laravel-schemaless-attributes", "version": "2.6.0", @@ -7759,91 +7478,6 @@ ], "time": "2026-04-28T06:26:02+00:00" }, - { - "name": "spatie/ray", - "version": "1.48.0", - "source": { - "type": "git", - "url": "https://github.com/spatie/ray.git", - "reference": "974ac9c6e315033ab8ace883d60e094522f88ede" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/spatie/ray/zipball/974ac9c6e315033ab8ace883d60e094522f88ede", - "reference": "974ac9c6e315033ab8ace883d60e094522f88ede", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-json": "*", - "php": "^7.4|^8.0", - "ramsey/uuid": "^3.0|^4.1", - "spatie/backtrace": "^1.7.1", - "spatie/macroable": "^1.0|^2.0", - "symfony/stopwatch": "^4.2|^5.1|^6.0|^7.0|^8.0", - "symfony/var-dumper": "^4.2|^5.1|^6.0|^7.0.3|^8.0" - }, - "require-dev": { - "illuminate/support": "^7.20|^8.18|^9.0|^10.0|^11.0|^12.0|^13.0", - "nesbot/carbon": "^2.63|^3.8.4", - "pestphp/pest": "^1.22", - "phpstan/phpstan": "^1.10.57|^2.0.3", - "phpunit/phpunit": "^9.5", - "rector/rector": "^0.19.2|^1.0.1|^2.0.0", - "spatie/phpunit-snapshot-assertions": "^4.2", - "spatie/test-time": "^1.2" - }, - "bin": [ - "bin/remove-ray.sh" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "files": [ - "src/helpers.php" - ], - "psr-4": { - "Spatie\\Ray\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Freek Van der Herten", - "email": "freek@spatie.be", - "homepage": "https://spatie.be", - "role": "Developer" - } - ], - "description": "Debug with Ray to fix problems faster", - "homepage": "https://github.com/spatie/ray", - "keywords": [ - "ray", - "spatie" - ], - "support": { - "issues": "https://github.com/spatie/ray/issues", - "source": "https://github.com/spatie/ray/tree/1.48.0" - }, - "funding": [ - { - "url": "https://github.com/sponsors/spatie", - "type": "github" - }, - { - "url": "https://spatie.be/open-source/support-us", - "type": "other" - } - ], - "time": "2026-03-31T12:44:31+00:00" - }, { "name": "spatie/shiki-php", "version": "2.4.0", @@ -9505,90 +9139,6 @@ ], "time": "2026-04-10T16:19:22+00:00" }, - { - "name": "symfony/polyfill-iconv", - "version": "v1.37.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-iconv.git", - "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/2c5729fd241b4b22f6e4b436bc3354a4f262df57", - "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "provide": { - "ext-iconv": "*" - }, - "suggest": { - "ext-iconv": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Iconv\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the Iconv extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "iconv", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-iconv/tree/v1.37.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-04-10T16:19:22+00:00" - }, { "name": "symfony/polyfill-intl-grapheme", "version": "v1.38.1", @@ -10924,72 +10474,6 @@ ], "time": "2026-03-28T09:44:51+00:00" }, - { - "name": "symfony/stopwatch", - "version": "v8.0.8", - "source": { - "type": "git", - "url": "https://github.com/symfony/stopwatch.git", - "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3", - "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "symfony/service-contracts": "^2.5|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Stopwatch\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides a way to profile code", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/stopwatch/tree/v8.0.8" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-03-30T15:14:47+00:00" - }, { "name": "symfony/string", "version": "v8.0.13", @@ -12194,214 +11678,6 @@ }, "time": "2018-08-08T15:08:14+00:00" }, - { - "name": "zbateson/mail-mime-parser", - "version": "4.0.1", - "source": { - "type": "git", - "url": "https://github.com/zbateson/mail-mime-parser.git", - "reference": "3db681988a48fdffdba551dcc6b2f4c2da574540" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/zbateson/mail-mime-parser/zipball/3db681988a48fdffdba551dcc6b2f4c2da574540", - "reference": "3db681988a48fdffdba551dcc6b2f4c2da574540", - "shasum": "" - }, - "require": { - "guzzlehttp/psr7": "^2.5", - "php": ">=8.1", - "php-di/php-di": "^6.0|^7.0", - "psr/log": "^1|^2|^3", - "zbateson/mb-wrapper": "^2.0 || ^3.0", - "zbateson/stream-decorators": "^2.1 || ^3.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.0", - "monolog/monolog": "^2|^3", - "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^10.5" - }, - "suggest": { - "ext-iconv": "For best support/performance", - "ext-mbstring": "For best support/performance" - }, - "type": "library", - "autoload": { - "psr-4": { - "ZBateson\\MailMimeParser\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "authors": [ - { - "name": "Zaahid Bateson" - }, - { - "name": "Contributors", - "homepage": "https://github.com/zbateson/mail-mime-parser/graphs/contributors" - } - ], - "description": "MIME email message parser", - "homepage": "https://mail-mime-parser.org", - "keywords": [ - "MimeMailParser", - "email", - "mail", - "mailparse", - "mime", - "mimeparse", - "parser", - "php-imap" - ], - "support": { - "docs": "https://mail-mime-parser.org/#usage-guide", - "issues": "https://github.com/zbateson/mail-mime-parser/issues", - "source": "https://github.com/zbateson/mail-mime-parser" - }, - "funding": [ - { - "url": "https://github.com/zbateson", - "type": "github" - } - ], - "time": "2026-03-11T18:03:41+00:00" - }, - { - "name": "zbateson/mb-wrapper", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/zbateson/mb-wrapper.git", - "reference": "f0ee6af2712e92e52ee2552588cd69d21ab3363f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/zbateson/mb-wrapper/zipball/f0ee6af2712e92e52ee2552588cd69d21ab3363f", - "reference": "f0ee6af2712e92e52ee2552588cd69d21ab3363f", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/polyfill-iconv": "^1.9", - "symfony/polyfill-mbstring": "^1.9" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "*", - "phpstan/phpstan": "*", - "phpunit/phpunit": "^10.0|^11.0" - }, - "suggest": { - "ext-iconv": "For best support/performance", - "ext-mbstring": "For best support/performance" - }, - "type": "library", - "autoload": { - "psr-4": { - "ZBateson\\MbWrapper\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "authors": [ - { - "name": "Zaahid Bateson" - } - ], - "description": "Wrapper for mbstring with fallback to iconv for encoding conversion and string manipulation", - "keywords": [ - "charset", - "encoding", - "http", - "iconv", - "mail", - "mb", - "mb_convert_encoding", - "mbstring", - "mime", - "multibyte", - "string" - ], - "support": { - "issues": "https://github.com/zbateson/mb-wrapper/issues", - "source": "https://github.com/zbateson/mb-wrapper/tree/3.0.0" - }, - "funding": [ - { - "url": "https://github.com/zbateson", - "type": "github" - } - ], - "time": "2026-02-13T19:33:26+00:00" - }, - { - "name": "zbateson/stream-decorators", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/zbateson/stream-decorators.git", - "reference": "0c0e79a8c960055c0e2710357098eedc07e6697a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/zbateson/stream-decorators/zipball/0c0e79a8c960055c0e2710357098eedc07e6697a", - "reference": "0c0e79a8c960055c0e2710357098eedc07e6697a", - "shasum": "" - }, - "require": { - "guzzlehttp/psr7": "^2.5", - "php": ">=8.1", - "zbateson/mb-wrapper": "^2.0 || ^3.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "*", - "phpstan/phpstan": "*", - "phpunit/phpunit": "^10.0 || ^11.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "ZBateson\\StreamDecorators\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "authors": [ - { - "name": "Zaahid Bateson" - } - ], - "description": "PHP psr7 stream decorators for mime message part streams", - "keywords": [ - "base64", - "charset", - "decorators", - "mail", - "mime", - "psr7", - "quoted-printable", - "stream", - "uuencode" - ], - "support": { - "issues": "https://github.com/zbateson/stream-decorators/issues", - "source": "https://github.com/zbateson/stream-decorators/tree/3.0.0" - }, - "funding": [ - { - "url": "https://github.com/zbateson", - "type": "github" - } - ], - "time": "2026-02-13T19:45:34+00:00" - }, { "name": "zircote/swagger-php", "version": "5.8.3", @@ -17403,6 +16679,70 @@ ], "time": "2026-04-16T21:33:58+00:00" }, + { + "name": "spatie/backtrace", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/backtrace.git", + "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/backtrace/zipball/8ffe78be5ed355b5009e3dd989d183433e9a5adc", + "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc", + "shasum": "" + }, + "require": { + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "ext-json": "*", + "laravel/serializable-closure": "^1.3 || ^2.0", + "phpunit/phpunit": "^9.3 || ^11.4.3", + "spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6", + "symfony/var-dumper": "^5.1|^6.0|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Backtrace\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van de Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "A better backtrace", + "homepage": "https://github.com/spatie/backtrace", + "keywords": [ + "Backtrace", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/backtrace/issues", + "source": "https://github.com/spatie/backtrace/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/sponsors/spatie", + "type": "github" + }, + { + "url": "https://spatie.be/open-source/support-us", + "type": "other" + } + ], + "time": "2026-03-11T13:48:28+00:00" + }, { "name": "spatie/error-solutions", "version": "1.1.3", diff --git a/config/constants.php b/config/constants.php index 4a956b31e..290ce3f95 100644 --- a/config/constants.php +++ b/config/constants.php @@ -9,14 +9,14 @@ 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), - 'registry_url' => env('REGISTRY_URL', 'ghcr.io'), - 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'), - 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'), + 'registry_url' => env('REGISTRY_URL', 'docker.io'), + 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-helper'), + 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), - 'releases_url' => env('RELEASES_URL', 'https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json'), + 'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'), ], 'urls' => [ @@ -25,9 +25,7 @@ ], 'services' => [ - // Temporary disabled until cache is implemented - // 'official' => 'https://cdn.coollabs.io/coolify/service-templates.json', - 'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/v4.x/templates/service-templates-latest.json', + 'official' => 'https://cdn.coollabs.io/coolify/service-templates-latest.json', 'file_name' => 'service-templates-latest.json', ], @@ -86,7 +84,6 @@ 'invitation' => [ 'link' => [ - 'base_url' => '/invitations/', 'expiration_days' => 3, ], ], diff --git a/config/ray.php b/config/ray.php deleted file mode 100644 index 08598c4e8..000000000 --- a/config/ray.php +++ /dev/null @@ -1,108 +0,0 @@ - env('RAY_ENABLED', true), - - /* - * When enabled, all cache events will automatically be sent to Ray. - */ - 'send_cache_to_ray' => env('SEND_CACHE_TO_RAY', false), - - /* - * When enabled, all things passed to `dump` or `dd` - * will be sent to Ray as well. - */ - 'send_dumps_to_ray' => env('SEND_DUMPS_TO_RAY', true), - - /* - * When enabled all job events will automatically be sent to Ray. - */ - 'send_jobs_to_ray' => env('SEND_JOBS_TO_RAY', false), - - /* - * When enabled, all things logged to the application log - * will be sent to Ray as well. - */ - 'send_log_calls_to_ray' => env('SEND_LOG_CALLS_TO_RAY', true), - - /* - * When enabled, all queries will automatically be sent to Ray. - */ - 'send_queries_to_ray' => env('SEND_QUERIES_TO_RAY', false), - - /** - * When enabled, all duplicate queries will automatically be sent to Ray. - */ - 'send_duplicate_queries_to_ray' => env('SEND_DUPLICATE_QUERIES_TO_RAY', false), - - /* - * When enabled, slow queries will automatically be sent to Ray. - */ - 'send_slow_queries_to_ray' => env('SEND_SLOW_QUERIES_TO_RAY', false), - - /** - * Queries that are longer than this number of milliseconds will be regarded as slow. - */ - 'slow_query_threshold_in_ms' => env('RAY_SLOW_QUERY_THRESHOLD_IN_MS', 500), - - /* - * When enabled, all requests made to this app will automatically be sent to Ray. - */ - 'send_requests_to_ray' => env('SEND_REQUESTS_TO_RAY', false), - - /** - * When enabled, all Http Client requests made by this app will be automatically sent to Ray. - */ - 'send_http_client_requests_to_ray' => env('SEND_HTTP_CLIENT_REQUESTS_TO_RAY', false), - - /* - * When enabled, all views that are rendered automatically be sent to Ray. - */ - 'send_views_to_ray' => env('SEND_VIEWS_TO_RAY', false), - - /* - * When enabled, all exceptions will be automatically sent to Ray. - */ - 'send_exceptions_to_ray' => env('SEND_EXCEPTIONS_TO_RAY', true), - - /* - * When enabled, all deprecation notices will be automatically sent to Ray. - */ - 'send_deprecated_notices_to_ray' => env('SEND_DEPRECATED_NOTICES_TO_RAY', false), - - /* - * The host used to communicate with the Ray app. - * When using Docker on Mac or Windows, you can replace localhost with 'host.docker.internal' - * When using Docker on Linux, you can replace localhost with '172.17.0.1' - * When using Homestead with the VirtualBox provider, you can replace localhost with '10.0.2.2' - * When using Homestead with the Parallels provider, you can replace localhost with '10.211.55.2' - */ - 'host' => env('RAY_HOST', 'host.docker.internal'), - - /* - * The port number used to communicate with the Ray app. - */ - 'port' => env('RAY_PORT', 23517), - - /* - * Absolute base path for your sites or projects in Homestead, - * Vagrant, Docker, or another remote development server. - */ - 'remote_path' => env('RAY_REMOTE_PATH', null), - - /* - * Absolute base path for your sites or projects on your local - * computer where your IDE or code editor is running on. - */ - 'local_path' => env('RAY_LOCAL_PATH', null), - - /* - * When this setting is enabled, the package will not try to format values sent to Ray. - */ - 'always_send_raw_values' => false, -]; diff --git a/database/factories/CloudProviderTokenFactory.php b/database/factories/CloudProviderTokenFactory.php index 4da7a2d08..689c26826 100644 --- a/database/factories/CloudProviderTokenFactory.php +++ b/database/factories/CloudProviderTokenFactory.php @@ -13,6 +13,11 @@ class CloudProviderTokenFactory extends Factory { protected $model = CloudProviderToken::class; + /** + * Define the model's default state. + * + * @return array + */ public function definition(): array { return [ diff --git a/database/migrations/2026_03_17_073223_add_docker_registry_url_to_instance_settings.php b/database/migrations/2026_03_17_073223_add_docker_registry_url_to_instance_settings.php new file mode 100644 index 000000000..267956f47 --- /dev/null +++ b/database/migrations/2026_03_17_073223_add_docker_registry_url_to_instance_settings.php @@ -0,0 +1,25 @@ +string('docker_registry_url')->default('docker.io')->after('is_auto_update_enabled'); + }); + } + + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('docker_registry_url'); + }); + } +}; diff --git a/database/migrations/2026_06_01_210459_add_vultr_instance_fields_to_servers_table.php b/database/migrations/2026_06_01_210459_add_vultr_instance_fields_to_servers_table.php new file mode 100644 index 000000000..2693011f1 --- /dev/null +++ b/database/migrations/2026_06_01_210459_add_vultr_instance_fields_to_servers_table.php @@ -0,0 +1,44 @@ +string('vultr_instance_id')->nullable()->after('hetzner_server_status'); + }); + } + + if (! Schema::hasColumn('servers', 'vultr_instance_status')) { + Schema::table('servers', function (Blueprint $table) { + $table->string('vultr_instance_status')->nullable()->after('vultr_instance_id'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('servers', 'vultr_instance_status')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('vultr_instance_status'); + }); + } + + if (Schema::hasColumn('servers', 'vultr_instance_id')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('vultr_instance_id'); + }); + } + } +}; diff --git a/database/migrations/2026_06_25_000000_add_is_mcp_server_enabled_to_teams_table.php b/database/migrations/2026_06_25_000000_add_is_mcp_server_enabled_to_teams_table.php new file mode 100644 index 000000000..3162a8613 --- /dev/null +++ b/database/migrations/2026_06_25_000000_add_is_mcp_server_enabled_to_teams_table.php @@ -0,0 +1,22 @@ +boolean('is_mcp_server_enabled')->default(true); + }); + } + + public function down(): void + { + Schema::table('teams', function (Blueprint $table) { + $table->dropColumn('is_mcp_server_enabled'); + }); + } +}; diff --git a/database/migrations/2026_07_02_142003_add_webhook_internal_allowlist_to_instance_settings_table.php b/database/migrations/2026_07_02_142003_add_webhook_internal_allowlist_to_instance_settings_table.php new file mode 100644 index 000000000..ea34d74fa --- /dev/null +++ b/database/migrations/2026_07_02_142003_add_webhook_internal_allowlist_to_instance_settings_table.php @@ -0,0 +1,23 @@ +json('webhook_allowed_internal_hosts')->nullable(); + $table->boolean('webhook_allow_localhost')->default(false); + }); + } + + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn(['webhook_allowed_internal_hosts', 'webhook_allow_localhost']); + }); + } +}; diff --git a/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php b/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php new file mode 100644 index 000000000..a25fdc18b --- /dev/null +++ b/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php @@ -0,0 +1,76 @@ +indexExists()) { + return; + } + + DB::table('tags') + ->select('team_id', 'name', DB::raw('MIN(id) as keep_id'), DB::raw('COUNT(*) as tag_count')) + ->whereNotNull('team_id') + ->groupBy('team_id', 'name') + ->havingRaw('COUNT(*) > 1') + ->orderBy('keep_id') + ->cursor() + ->each(function ($duplicate): void { + DB::table('tags') + ->select('id') + ->where('team_id', $duplicate->team_id) + ->where('name', $duplicate->name) + ->where('id', '!=', $duplicate->keep_id) + ->orderBy('id') + ->cursor() + ->each(function ($duplicateTag) use ($duplicate): void { + DB::table('taggables') + ->where('tag_id', $duplicateTag->id) + ->orderBy('taggable_id') + ->cursor() + ->each(function ($taggable) use ($duplicate): void { + DB::table('taggables')->updateOrInsert([ + 'tag_id' => $duplicate->keep_id, + 'taggable_id' => $taggable->taggable_id, + 'taggable_type' => $taggable->taggable_type, + ]); + }); + + DB::table('taggables')->where('tag_id', $duplicateTag->id)->delete(); + DB::table('tags')->where('id', $duplicateTag->id)->delete(); + }); + }); + + Schema::table('tags', function (Blueprint $table) { + $table->unique(['team_id', 'name'], 'tags_team_id_name_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! $this->indexExists()) { + return; + } + + Schema::table('tags', function (Blueprint $table) { + $table->dropUnique('tags_team_id_name_unique'); + }); + } + + private function indexExists(): bool + { + return collect(Schema::getIndexes('tags')) + ->contains(fn (array $index): bool => $index['name'] === 'tags_team_id_name_unique'); + } +}; diff --git a/database/migrations/2026_07_07_114840_add_digitalocean_droplet_fields_to_servers_table.php b/database/migrations/2026_07_07_114840_add_digitalocean_droplet_fields_to_servers_table.php new file mode 100644 index 000000000..d2845c047 --- /dev/null +++ b/database/migrations/2026_07_07_114840_add_digitalocean_droplet_fields_to_servers_table.php @@ -0,0 +1,40 @@ +bigInteger('digitalocean_droplet_id')->nullable()->after('hetzner_server_status'); + } + + if (! Schema::hasColumn('servers', 'digitalocean_droplet_status')) { + $table->string('digitalocean_droplet_status')->nullable()->after('digitalocean_droplet_id'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + if (Schema::hasColumn('servers', 'digitalocean_droplet_status')) { + $table->dropColumn('digitalocean_droplet_status'); + } + + if (Schema::hasColumn('servers', 'digitalocean_droplet_id')) { + $table->dropColumn('digitalocean_droplet_id'); + } + }); + } +}; diff --git a/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php b/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php new file mode 100644 index 000000000..5d194cd7d --- /dev/null +++ b/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php @@ -0,0 +1,28 @@ +text('description')->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('cloud_provider_tokens', function (Blueprint $table) { + $table->dropColumn('description'); + }); + } +}; diff --git a/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php new file mode 100644 index 000000000..98b0c73c2 --- /dev/null +++ b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php @@ -0,0 +1,33 @@ +string('uuid')->nullable()->unique()->after('id'); + }); + + DB::table('cloud_init_scripts') + ->whereNull('uuid') + ->lazyById() + ->each(function (object $script): void { + DB::table('cloud_init_scripts') + ->where('id', $script->id) + ->update(['uuid' => new_public_id()]); + }); + } + + public function down(): void + { + Schema::table('cloud_init_scripts', function (Blueprint $table) { + $table->dropUnique(['uuid']); + $table->dropColumn('uuid'); + }); + } +}; diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php index e736c5ecd..ebe510745 100644 --- a/database/seeders/DevelopmentRailpackExamplesSeeder.php +++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php @@ -20,14 +20,33 @@ class DevelopmentRailpackExamplesSeeder extends Seeder { public const PROJECT_UUID = 'railpack-examples'; - public const ENVIRONMENT_UUID = 'railpack-examples-production'; - public const GIT_REPOSITORY = 'coollabsio/coolify-examples'; public const GIT_BRANCH = 'next'; public const REPOSITORY_PROJECT_ID = 603035348; + public const LIMA_SERVERS = [ + [ + 'server_uuid' => 'lima-ubuntu-2404', + 'server_name' => 'lima-ubuntu-2404', + 'port' => 2222, + 'environment_name' => 'ubuntu24', + 'environment_uuid' => 'railpack-examples-ubuntu24', + 'uuid_prefix' => 'ubuntu24-', + ], + [ + 'server_uuid' => 'lima-ubuntu-2604', + 'server_name' => 'lima-ubuntu-2604', + 'port' => 2223, + 'environment_name' => 'ubuntu26', + 'environment_uuid' => 'railpack-examples-ubuntu26', + 'uuid_prefix' => 'ubuntu26-', + ], + ]; + + private const LIMA_SENTINEL_URL = 'http://host.lima.internal:8000'; + public function run(): void { if (! $this->isDevelopmentEnvironment()) { @@ -37,16 +56,22 @@ public function run(): void } $this->ensureDevelopmentPrerequisitesExist(); - $destination = StandaloneDocker::query()->find(0); - if (! $destination) { + if (! StandaloneDocker::query()->find(0)) { throw new RuntimeException('StandaloneDocker with id=0 is required before running DevelopmentRailpackExamplesSeeder.'); } - $environment = $this->prepareEnvironment(); + $this->cleanupLegacyLimaProjects(); + $this->cleanupLegacyProductionExamples(); - foreach (self::examples() as $example) { - $this->upsertApplication($environment, $destination, $example); + foreach (self::LIMA_SERVERS as $limaServer) { + $this->seedEnvironment( + environmentUuid: $limaServer['environment_uuid'], + environmentName: $limaServer['environment_name'], + destination: $this->limaDestination($limaServer['server_uuid']), + uuidPrefix: $limaServer['uuid_prefix'], + nameSuffix: " ({$limaServer['environment_name']})", + ); } } @@ -440,6 +465,37 @@ private function ensureDevelopmentPrerequisitesExist(): void ], ); + foreach (self::LIMA_SERVERS as $limaServer) { + $server = Server::query()->firstOrCreate( + ['uuid' => $limaServer['server_uuid']], + [ + 'name' => $limaServer['server_name'], + 'description' => 'This is a Lima VM for local development testing', + 'ip' => 'host.docker.internal', + 'port' => $limaServer['port'], + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ], + ); + + $server->settings->forceFill([ + 'sentinel_custom_url' => self::LIMA_SENTINEL_URL, + ])->saveQuietly(); + + StandaloneDocker::query()->firstOrCreate( + ['server_id' => $server->id], + [ + 'uuid' => "{$limaServer['server_uuid']}-docker", + 'name' => "{$limaServer['server_name']} Docker", + 'network' => 'coolify', + ], + ); + } + StandaloneDocker::query()->firstOrCreate( ['id' => 0], [ @@ -489,7 +545,71 @@ private function isDevelopmentEnvironment(): bool return in_array(config('app.env'), ['local', 'development', 'dev'], true); } - private function prepareEnvironment(): Environment + private function limaDestination(string $serverUuid): StandaloneDocker + { + $limaDestination = Server::query() + ->where('uuid', $serverUuid) + ->first() + ?->standaloneDockers() + ->first(); + + if (! $limaDestination) { + throw new RuntimeException("Lima StandaloneDocker destination is required for {$serverUuid} before running DevelopmentRailpackExamplesSeeder."); + } + + return $limaDestination; + } + + private function cleanupLegacyLimaProjects(): void + { + Project::query() + ->whereIn('uuid', [ + 'railpack-examples-lima-ubuntu-2404', + 'railpack-examples-lima-ubuntu-2604', + ]) + ->get() + ->each(function (Project $project): void { + Application::withTrashed() + ->whereIn('environment_id', $project->environments()->pluck('id')) + ->get() + ->each + ->forceDelete(); + + $project->delete(); + }); + } + + private function cleanupLegacyProductionExamples(): void + { + $project = Project::query()->where('uuid', self::PROJECT_UUID)->first(); + + if (! $project) { + return; + } + + Application::withTrashed() + ->whereIn('environment_id', $project->environments()->pluck('id')) + ->whereIn('uuid', collect(self::examples())->pluck('uuid')) + ->get() + ->each + ->forceDelete(); + } + + private function seedEnvironment( + string $environmentUuid, + string $environmentName, + StandaloneDocker $destination, + string $uuidPrefix = '', + string $nameSuffix = '', + ): void { + $environment = $this->prepareEnvironment($environmentUuid, $environmentName); + + foreach (self::examples() as $example) { + $this->upsertApplication($environment, $destination, $example, $uuidPrefix, $nameSuffix); + } + } + + private function prepareEnvironment(string $environmentUuid, string $environmentName): Environment { $project = Project::query()->firstOrNew(['uuid' => self::PROJECT_UUID]); $project->fill([ @@ -499,17 +619,29 @@ private function prepareEnvironment(): Environment ]); $project->save(); - $environment = $project->environments()->first(); + $environment = $project->environments() + ->where(function ($query) use ($environmentName, $environmentUuid): void { + $query + ->where('name', $environmentName) + ->orWhere('uuid', $environmentUuid); + }) + ->first(); + + $existingEnvironment = $project->environments()->first(); + + if (! $environment && $project->environments()->count() === 1 && $existingEnvironment?->name === 'production') { + $environment = $existingEnvironment; + } if (! $environment) { $environment = $project->environments()->create([ - 'name' => 'production', - 'uuid' => self::ENVIRONMENT_UUID, + 'name' => $environmentName, + 'uuid' => $environmentUuid, ]); } else { $environment->update([ - 'name' => 'production', - 'uuid' => self::ENVIRONMENT_UUID, + 'name' => $environmentName, + 'uuid' => $environmentUuid, ]); } @@ -519,13 +651,15 @@ private function prepareEnvironment(): Environment /** * @param array $example */ - private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example): void + private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example, string $uuidPrefix = '', string $nameSuffix = ''): void { - $application = Application::withTrashed()->firstOrNew(['uuid' => $example['uuid']]); + $uuid = $uuidPrefix.$example['uuid']; + $name = $example['name'].$nameSuffix; + $application = Application::withTrashed()->firstOrNew(['uuid' => $uuid]); $application->fill([ - 'name' => $example['name'], - 'description' => $example['name'], - 'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io", + 'name' => $name, + 'description' => $name, + 'fqdn' => "http://{$uuid}.127.0.0.1.sslip.io", 'repository_project_id' => $example['repository_project_id'] ?? self::REPOSITORY_PROJECT_ID, 'git_repository' => $example['git_repository'] ?? self::GIT_REPOSITORY, 'git_branch' => $example['git_branch'] ?? self::GIT_BRANCH, diff --git a/database/seeders/ProjectSeeder.php b/database/seeders/ProjectSeeder.php index ab8e54051..73ab9c530 100644 --- a/database/seeders/ProjectSeeder.php +++ b/database/seeders/ProjectSeeder.php @@ -7,6 +7,11 @@ class ProjectSeeder extends Seeder { + private const LIMA_ENVIRONMENTS = [ + ['name' => 'ubuntu24', 'uuid' => 'ubuntu24'], + ['name' => 'ubuntu26', 'uuid' => 'ubuntu26'], + ]; + public function run(): void { $project = Project::create([ @@ -16,7 +21,14 @@ public function run(): void 'team_id' => 0, ]); - // Update the auto-created environment with a deterministic UUID - $project->environments()->first()->update(['uuid' => 'production']); + foreach (self::LIMA_ENVIRONMENTS as $index => $environment) { + if ($index === 0) { + $project->environments()->first()->update($environment); + + continue; + } + + $project->environments()->create($environment); + } } } diff --git a/database/seeders/ServerSeeder.php b/database/seeders/ServerSeeder.php index 2d8746691..60b5dc317 100644 --- a/database/seeders/ServerSeeder.php +++ b/database/seeders/ServerSeeder.php @@ -9,6 +9,13 @@ class ServerSeeder extends Seeder { + private const LIMA_SENTINEL_URL = 'http://host.lima.internal:8000'; + + private const LIMA_SERVERS = [ + ['uuid' => 'lima-ubuntu-2404', 'name' => 'lima-ubuntu-2404', 'port' => 2222], + ['uuid' => 'lima-ubuntu-2604', 'name' => 'lima-ubuntu-2604', 'port' => 2223], + ]; + public function run(): void { Server::create([ @@ -24,5 +31,25 @@ public function run(): void 'status' => ProxyStatus::EXITED->value, ], ]); + + foreach (self::LIMA_SERVERS as $limaServer) { + $server = Server::create([ + 'uuid' => $limaServer['uuid'], + 'name' => $limaServer['name'], + 'description' => 'This is a Lima VM for local development testing', + 'ip' => 'host.docker.internal', + 'port' => $limaServer['port'], + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + $server->settings->forceFill([ + 'sentinel_custom_url' => self::LIMA_SENTINEL_URL, + ])->saveQuietly(); + } } } diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml index bbb483d7a..61037391b 100644 --- a/docker-compose-maxio.dev.yml +++ b/docker-compose-maxio.dev.yml @@ -10,6 +10,8 @@ services: - GROUP_ID=${GROUPID:-1000} ports: - "${APP_PORT:-8000}:8080" + extra_hosts: + - "host.docker.internal:host-gateway" environment: AUTORUN_ENABLED: false PUSHER_HOST: "${PUSHER_HOST}" @@ -70,6 +72,8 @@ services: ports: - "${FORWARD_SOKETI_PORT:-6001}:6001" - "6002:6002" + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - ./storage:/var/www/html/storage - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9c93678af..8f84b5d60 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,6 +10,8 @@ services: - GROUP_ID=${GROUPID:-1000} ports: - "${APP_PORT:-8000}:8080" + extra_hosts: + - "host.docker.internal:host-gateway" environment: AUTORUN_ENABLED: false PUSHER_HOST: "${PUSHER_HOST}" @@ -70,6 +72,8 @@ services: ports: - "${FORWARD_SOKETI_PORT:-6001}:6001" - "6002:6002" + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - ./storage:/var/www/html/storage - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8907a30b9..9618e18ec 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,6 +1,6 @@ services: coolify: - image: "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}" + image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}" volumes: - type: bind source: /data/coolify/source/.env @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16' + image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.16' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index da045fe03..3a6c3f11c 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -1,14 +1,14 @@ services: coolify-testing-host: init: true - image: "ghcr.io/coollabsio/coolify-testing-host:latest" + image: "docker.io/coollabsio/coolify-testing-host:latest" pull_policy: always container_name: coolify-testing-host volumes: - //var/run/docker.sock://var/run/docker.sock - ./:/data/coolify coolify: - image: "ghcr.io/coollabsio/coolify:latest" + image: "docker.io/coollabsio/coolify:latest" pull_policy: always container_name: coolify restart: always diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index 8fc46e32d..a5e5a7a3c 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -9,7 +9,7 @@ ARG CLOUDFLARED_VERSION=2025.7.0 # Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer= ARG POSTGRES_VERSION=18 # https://nginx.org/en/linux_packages.html -ARG NGINX_VERSION=1.31.0-r1 +ARG NGINX_VERSION=1.31.2-r1 # ================================================================= # Get MinIO client diff --git a/docker/lima/ubuntu-2404.yaml b/docker/lima/ubuntu-2404.yaml new file mode 100644 index 000000000..99819938d --- /dev/null +++ b/docker/lima/ubuntu-2404.yaml @@ -0,0 +1,35 @@ +images: + - location: https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img + arch: x86_64 + - location: https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img + arch: aarch64 + +cpus: 2 +memory: 2GiB +disk: 20GiB + +containerd: + system: false + user: false + +mounts: [] + +ssh: + localPort: 2222 + loadDotSSHPubKeys: false + +provision: + - mode: system + script: | + #!/bin/bash + set -euxo pipefail + + install -d -m 700 /root/.ssh + cat >/root/.ssh/authorized_keys <<'EOF' + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd + EOF + chmod 600 /root/.ssh/authorized_keys + + sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config + sed -i 's/^#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config + systemctl restart ssh diff --git a/docker/lima/ubuntu-2604.yaml b/docker/lima/ubuntu-2604.yaml new file mode 100644 index 000000000..042e3028a --- /dev/null +++ b/docker/lima/ubuntu-2604.yaml @@ -0,0 +1,35 @@ +images: + - location: https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-amd64.img + arch: x86_64 + - location: https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-arm64.img + arch: aarch64 + +cpus: 2 +memory: 2GiB +disk: 20GiB + +containerd: + system: false + user: false + +mounts: [] + +ssh: + localPort: 2223 + loadDotSSHPubKeys: false + +provision: + - mode: system + script: | + #!/bin/bash + set -euxo pipefail + + install -d -m 700 /root/.ssh + cat >/root/.ssh/authorized_keys <<'EOF' + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd + EOF + chmod 600 /root/.ssh/authorized_keys + + sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config + sed -i 's/^#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config + systemctl restart ssh diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 0f849785e..34e4b6789 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -9,7 +9,7 @@ ARG CLOUDFLARED_VERSION=2025.7.0 # Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer= ARG POSTGRES_VERSION=18 # https://nginx.org/en/linux_packages.html -ARG NGINX_VERSION=1.31.0-r1 +ARG NGINX_VERSION=1.31.2-r1 # Add user/group ARG USER_ID=9999 diff --git a/openapi.json b/openapi.json index ca445ade0..4a3a932ca 100644 --- a/openapi.json +++ b/openapi.json @@ -165,6 +165,10 @@ "type": "boolean", "description": "The flag to indicate if HTTPS is forced. Defaults to true." }, + "is_preview_deployments_enabled": { + "type": "boolean", + "description": "Enable preview deployments for pull requests." + }, "static_image": { "type": "string", "enum": [ @@ -376,6 +380,68 @@ "nullable": true, "description": "Use build server." }, + "use_build_secrets": { + "type": "boolean", + "default": false, + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -408,6 +474,13 @@ "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, "is_preserve_repository_enabled": { "type": "boolean", "default": false, @@ -615,6 +688,10 @@ "type": "boolean", "description": "The flag to indicate if HTTPS is forced. Defaults to true." }, + "is_preview_deployments_enabled": { + "type": "boolean", + "description": "Enable preview deployments for pull requests." + }, "static_image": { "type": "string", "enum": [ @@ -826,6 +903,68 @@ "nullable": true, "description": "Use build server." }, + "use_build_secrets": { + "type": "boolean", + "default": false, + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -858,6 +997,13 @@ "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, "is_preserve_repository_enabled": { "type": "boolean", "default": false, @@ -1065,6 +1211,10 @@ "type": "boolean", "description": "The flag to indicate if HTTPS is forced. Defaults to true." }, + "is_preview_deployments_enabled": { + "type": "boolean", + "description": "Enable preview deployments for pull requests." + }, "static_image": { "type": "string", "enum": [ @@ -1276,6 +1426,68 @@ "nullable": true, "description": "Use build server." }, + "use_build_secrets": { + "type": "boolean", + "default": false, + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -1308,6 +1520,13 @@ "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, "is_preserve_repository_enabled": { "type": "boolean", "default": false, @@ -1626,11 +1845,77 @@ "type": "boolean", "description": "The flag to indicate if HTTPS is forced. Defaults to true." }, + "is_preview_deployments_enabled": { + "type": "boolean", + "description": "Enable preview deployments for pull requests." + }, "use_build_server": { "type": "boolean", "nullable": true, "description": "Use build server." }, + "use_build_secrets": { + "type": "boolean", + "default": false, + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -1662,6 +1947,13 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." } }, "type": "object" @@ -1961,11 +2253,77 @@ "type": "boolean", "description": "The flag to indicate if HTTPS is forced. Defaults to true." }, + "is_preview_deployments_enabled": { + "type": "boolean", + "description": "Enable preview deployments for pull requests." + }, "use_build_server": { "type": "boolean", "nullable": true, "description": "Use build server." }, + "use_build_secrets": { + "type": "boolean", + "default": false, + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -1997,6 +2355,13 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." } }, "type": "object" @@ -2333,6 +2698,10 @@ "type": "boolean", "description": "The flag to indicate if HTTPS is forced. Defaults to true." }, + "is_preview_deployments_enabled": { + "type": "boolean", + "description": "Enable preview deployments for pull requests." + }, "install_command": { "type": "string", "description": "The install command." @@ -2537,6 +2906,67 @@ "nullable": true, "description": "Use build server." }, + "use_build_secrets": { + "type": "boolean", + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -2675,6 +3105,16 @@ "format": "int32", "default": 100 } + }, + { + "name": "show_timestamps", + "in": "query", + "description": "Show timestamps in the logs.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { @@ -3291,6 +3731,91 @@ ] } }, + "\/applications\/{uuid}\/move": { + "post": { + "tags": [ + "Applications" + ], + "summary": "Move", + "description": "Move application to another project\/environment. This is a purely organizational change \u2014 running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.", + "operationId": "move-application-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Target environment to move the application to.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "environment_uuid" + ], + "properties": { + "environment_uuid": { + "type": "string", + "description": "UUID of the target environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Application moved successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Application moved successfully." + }, + "uuid": { + "type": "string" + }, + "project_uuid": { + "type": "string" + }, + "environment_uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/applications\/{uuid}\/storages": { "get": { "tags": [ @@ -3682,6 +4207,179 @@ ] } }, + "\/applications\/{uuid}\/tags": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List Tags", + "description": "List tags for an application by UUID.", + "operationId": "list-tags-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tags.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Applications" + ], + "summary": "Create Tag", + "description": "Add tag(s) to an application by UUID.", + "operationId": "create-tag-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "tag_name": { + "type": "string", + "description": "The tag name (min 2 characters). Required if tag_names is not provided." + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tags added successfully.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/tags\/{tag_uuid}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Tag", + "description": "Remove a tag from an application by UUID.", + "operationId": "delete-tag-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_uuid", + "in": "path", + "description": "UUID of the tag.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag removed." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ @@ -3709,7 +4407,8 @@ "type": "string", "enum": [ "hetzner", - "digitalocean" + "digitalocean", + "vultr" ] }, "team_id": { @@ -3767,7 +4466,8 @@ "type": "string", "enum": [ "hetzner", - "digitalocean" + "digitalocean", + "vultr" ], "example": "hetzner", "description": "The cloud provider." @@ -4980,6 +5680,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5112,6 +5819,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5240,6 +5954,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5372,6 +6093,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5504,6 +6232,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5648,6 +6383,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5792,6 +6534,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5924,6 +6673,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5952,6 +6708,80 @@ ] } }, + "\/databases\/{uuid}\/logs": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Get database logs.", + "description": "Get database logs by UUID.", + "operationId": "get-database-logs-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "lines", + "in": "query", + "description": "Number of lines to show from the end of the logs.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "show_timestamps", + "in": "query", + "description": "Show timestamps in the logs.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Get database logs by UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions\/{execution_uuid}": { "delete": { "tags": [ @@ -6118,6 +6948,91 @@ ] } }, + "\/databases\/{uuid}\/move": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Move", + "description": "Move database to another project\/environment. This is a purely organizational change \u2014 running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.", + "operationId": "move-database-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Target environment to move the database to.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "environment_uuid" + ], + "properties": { + "environment_uuid": { + "type": "string", + "description": "UUID of the target environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database moved successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Database moved successfully." + }, + "uuid": { + "type": "string" + }, + "project_uuid": { + "type": "string" + }, + "environment_uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases\/{uuid}\/start": { "get": { "tags": [ @@ -6994,6 +7909,179 @@ ] } }, + "\/databases\/{uuid}\/tags": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Tags", + "description": "List tags for a database by UUID.", + "operationId": "list-tags-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tags.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Tag", + "description": "Add tag(s) to a database by UUID.", + "operationId": "create-tag-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "tag_name": { + "type": "string", + "description": "The tag name (min 2 characters). Required if tag_names is not provided." + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tags added successfully.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/tags\/{tag_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete Tag", + "description": "Remove a tag from a database by UUID.", + "operationId": "delete-tag-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_uuid", + "in": "path", + "description": "UUID of the tag.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag removed." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/deployments": { "get": { "tags": [ @@ -7338,6 +8426,464 @@ ] } }, + "\/destinations": { + "get": { + "tags": [ + "Destinations" + ], + "summary": "List destinations", + "description": "List all Docker network destinations for the authenticated team.", + "operationId": "list-destinations", + "responses": { + "200": { + "description": "Destinations for the authenticated team.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{server_uuid}\/destinations": { + "get": { + "tags": [ + "Destinations" + ], + "summary": "List destinations by server", + "description": "List Docker network destinations attached to a server owned by the authenticated team.", + "operationId": "list-server-destinations", + "parameters": [ + { + "name": "server_uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destinations attached to the server.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Destinations" + ], + "summary": "Create destination", + "description": "Create a Docker network destination on a server owned by the authenticated team.", + "operationId": "create-server-destination", + "parameters": [ + { + "name": "server_uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "network" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "network": { + "type": "string", + "maxLength": 255, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "type": { + "type": "string", + "enum": [ + "standalone", + "swarm" + ] + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Destination created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "A destination with this network already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/destinations\/{uuid}": { + "get": { + "tags": [ + "Destinations" + ], + "summary": "Get destination", + "description": "Get a Docker network destination by UUID.", + "operationId": "get-destination-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Destination UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destination details.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Destinations" + ], + "summary": "Delete destination", + "description": "Delete an unused Docker network destination.", + "operationId": "delete-destination-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Destination UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destination deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Destination has attached resources." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/digitalocean\/regions": { + "get": { + "tags": [ + "DigitalOcean" + ], + "summary": "Get DigitalOcean regions", + "operationId": "get-digitalocean-regions", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of DigitalOcean regions." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/digitalocean\/sizes": { + "get": { + "tags": [ + "DigitalOcean" + ], + "summary": "Get DigitalOcean sizes", + "operationId": "get-digitalocean-sizes", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of DigitalOcean sizes." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/digitalocean\/images": { + "get": { + "tags": [ + "DigitalOcean" + ], + "summary": "Get DigitalOcean images", + "operationId": "get-digitalocean-images", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of DigitalOcean images." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/digitalocean\/ssh-keys": { + "get": { + "tags": [ + "DigitalOcean" + ], + "summary": "Get DigitalOcean SSH keys", + "operationId": "get-digitalocean-ssh-keys", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of DigitalOcean SSH keys." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/digitalocean": { + "post": { + "tags": [ + "DigitalOcean" + ], + "summary": "Create a server on DigitalOcean", + "operationId": "create-digitalocean-server", + "responses": { + "201": { + "description": "DigitalOcean droplet created and linked to a Coolify server." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "DigitalOcean rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/github-apps": { "get": { "tags": [ @@ -7439,7 +8985,6 @@ "schema": { "required": [ "name", - "api_url", "html_url", "app_id", "installation_id", @@ -8236,6 +9781,139 @@ ] } }, + "\/hetzner\/firewalls": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Firewalls", + "description": "Get all existing Hetzner firewalls for the current project.", + "operationId": "get-hetzner-firewalls", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner firewalls.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/networks": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Networks", + "description": "Get all existing Hetzner private networks for the current project.", + "operationId": "get-hetzner-networks", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner networks.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "ip_range": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/servers\/hetzner": { "post": { "tags": [ @@ -8303,6 +9981,11 @@ "example": true, "description": "Enable IPv6 (default: true)" }, + "enable_backups": { + "type": "boolean", + "example": false, + "description": "Enable Hetzner server backups after creation (adds 20% to the monthly server fee)" + }, "hetzner_ssh_key_ids": { "type": "array", "items": { @@ -8310,6 +9993,20 @@ }, "description": "Additional Hetzner SSH key IDs" }, + "hetzner_firewall_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Existing Hetzner firewall IDs to apply during server creation" + }, + "hetzner_network_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Existing Hetzner network IDs to attach during server creation" + }, "cloud_init_script": { "type": "string", "description": "Cloud-init YAML script (optional)" @@ -10734,6 +12431,511 @@ ] } }, + "\/services\/{uuid}\/applications": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "List service applications", + "description": "List compose service applications (containers) for a single service.", + "operationId": "list-service-applications-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Service applications for this service.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/applications\/{app_uuid}": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "Get service application", + "description": "Get a single compose service application by service UUID and application UUID.", + "operationId": "get-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Service application.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Service applications" + ], + "summary": "Update service application", + "description": "Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH \/services\/{uuid}).", + "operationId": "patch-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force_domain_override", + "in": "query", + "description": "When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "content": { + "application\/json": { + "schema": { + "properties": { + "url": { + "description": "Comma-separated list of URLs (e.g. \"http:\/\/app.example.com:8080,https:\/\/app2.example.com\"). Stored as fqdn.", + "type": [ + "string", + "null" + ] + }, + "human_name": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "image": { + "type": [ + "string", + "null" + ] + }, + "exclude_from_status": { + "type": [ + "boolean", + "null" + ] + }, + "is_log_drain_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "is_gzip_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "is_stripprefix_enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated service application.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Domain conflicts (unless force_domain_override)." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/applications\/{app_uuid}\/logs": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "Get service application logs", + "description": "Get Docker logs for a single compose service container.", + "operationId": "get-service-application-logs-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lines", + "in": "query", + "description": "Number of lines to show from the end of the logs.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Logs.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/applications\/{app_uuid}\/start": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "Start or redeploy service application container", + "description": "Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.", + "operationId": "start-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force", + "in": "query", + "description": "When true, passes --build to docker compose up.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "latest", + "in": "query", + "description": "When true, pulls the image for this compose service before up.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Deploy request queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/applications\/{app_uuid}\/restart": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "Restart service application container", + "description": "Restarts a single compose service container (docker restart).", + "operationId": "restart-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Restart queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/applications\/{app_uuid}\/stop": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "Stop service application container", + "description": "Stops a single compose service container (docker stop).", + "operationId": "stop-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Stop queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/services": { "get": { "tags": [ @@ -10857,6 +13059,13 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the service." } }, "type": "object" @@ -11293,6 +13502,90 @@ ] } }, + "\/services\/{uuid}\/logs": { + "get": { + "tags": [ + "Services" + ], + "summary": "Get service logs.", + "description": "Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET \/services\/{uuid}`.", + "operationId": "get-service-logs-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "sub_service_name", + "in": "query", + "description": "Sub-service name from `GET \/services\/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.", + "required": true, + "schema": { + "type": "string", + "example": "appwrite-console" + } + }, + { + "name": "lines", + "in": "query", + "description": "Number of lines to show from the end of the logs.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "show_timestamps", + "in": "query", + "description": "Show timestamps in the logs.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Get service logs by UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/services\/{uuid}\/envs": { "get": { "tags": [ @@ -11686,6 +13979,91 @@ ] } }, + "\/services\/{uuid}\/move": { + "post": { + "tags": [ + "Services" + ], + "summary": "Move", + "description": "Move service to another project\/environment. This is a purely organizational change \u2014 running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.", + "operationId": "move-service-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Target environment to move the service to.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "environment_uuid" + ], + "properties": { + "environment_uuid": { + "type": "string", + "description": "UUID of the target environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Service moved successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Service moved successfully." + }, + "uuid": { + "type": "string" + }, + "project_uuid": { + "type": "string" + }, + "environment_uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/services\/{uuid}\/start": { "get": { "tags": [ @@ -12195,6 +14573,215 @@ ] } }, + "\/services\/{uuid}\/tags": { + "get": { + "tags": [ + "Services" + ], + "summary": "List Tags", + "description": "List tags for a service by UUID.", + "operationId": "list-tags-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tags.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Services" + ], + "summary": "Create Tag", + "description": "Add tag(s) to a service by UUID.", + "operationId": "create-tag-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "tag_name": { + "type": "string", + "description": "The tag name (min 2 characters). Required if tag_names is not provided." + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tags added successfully.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/tags\/{tag_uuid}": { + "delete": { + "tags": [ + "Services" + ], + "summary": "Delete Tag", + "description": "Remove a tag from a service by UUID.", + "operationId": "delete-tag-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_uuid", + "in": "path", + "description": "UUID of the tag.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag removed." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/tags": { + "get": { + "tags": [ + "Tags" + ], + "summary": "List", + "description": "List all tags for the current team.", + "operationId": "list-tags", + "responses": { + "200": { + "description": "All tags for the current team.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/teams": { "get": { "tags": [ @@ -12396,6 +14983,139 @@ } ] } + }, + "\/vultr\/regions": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr Regions", + "description": "Get all available Vultr regions.", + "operationId": "get-vultr-regions", + "responses": { + "200": { + "description": "List of Vultr regions." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/vultr\/plans": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr Plans", + "description": "Get all available Vultr plans.", + "operationId": "get-vultr-plans", + "responses": { + "200": { + "description": "List of Vultr plans." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/vultr\/os": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr Operating Systems", + "description": "Get all available Vultr operating systems.", + "operationId": "get-vultr-operating-systems", + "responses": { + "200": { + "description": "List of Vultr operating systems." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/vultr\/ssh-keys": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr SSH Keys", + "description": "Get all Vultr SSH keys available to the selected token.", + "operationId": "get-vultr-ssh-keys", + "responses": { + "200": { + "description": "List of Vultr SSH keys." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/vultr": { + "post": { + "tags": [ + "Vultr" + ], + "summary": "Create Vultr Server", + "description": "Create a Vultr instance and link it as a Coolify server.", + "operationId": "create-vultr-server", + "responses": { + "201": { + "description": "Vultr server created." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Vultr API rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } } }, "components": { @@ -12793,6 +15513,9 @@ "type": "string", "nullable": true, "description": "Password for HTTP Basic Authentication" + }, + "": { + "$ref": "#\/components\/schemas\/ApplicationSetting" } }, "type": "object" @@ -12888,6 +15611,123 @@ }, "type": "object" }, + "ApplicationSetting": { + "description": "Application settings.", + "properties": { + "is_static": { + "type": "boolean" + }, + "is_git_submodules_enabled": { + "type": "boolean" + }, + "is_git_lfs_enabled": { + "type": "boolean" + }, + "is_auto_deploy_enabled": { + "type": "boolean" + }, + "is_force_https_enabled": { + "type": "boolean" + }, + "is_debug_enabled": { + "type": "boolean" + }, + "is_preview_deployments_enabled": { + "type": "boolean" + }, + "is_log_drain_enabled": { + "type": "boolean" + }, + "is_gpu_enabled": { + "type": "boolean" + }, + "gpu_driver": { + "type": "string", + "nullable": true + }, + "gpu_count": { + "type": "string", + "nullable": true + }, + "gpu_device_ids": { + "type": "string", + "nullable": true + }, + "gpu_options": { + "type": "string", + "nullable": true + }, + "is_include_timestamps": { + "type": "boolean" + }, + "is_swarm_only_worker_nodes": { + "type": "boolean" + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean" + }, + "is_build_server_enabled": { + "type": "boolean" + }, + "is_consistent_container_name_enabled": { + "type": "boolean" + }, + "is_gzip_enabled": { + "type": "boolean" + }, + "is_stripprefix_enabled": { + "type": "boolean" + }, + "connect_to_docker_network": { + "type": "boolean" + }, + "custom_internal_name": { + "type": "string", + "nullable": true + }, + "is_container_label_escape_enabled": { + "type": "boolean" + }, + "is_env_sorting_enabled": { + "type": "boolean" + }, + "is_container_label_readonly_enabled": { + "type": "boolean" + }, + "is_preserve_repository_enabled": { + "type": "boolean" + }, + "disable_build_cache": { + "type": "boolean" + }, + "is_spa": { + "type": "boolean" + }, + "is_git_shallow_clone_enabled": { + "type": "boolean" + }, + "is_pr_deployments_public_enabled": { + "type": "boolean" + }, + "use_build_secrets": { + "type": "boolean" + }, + "inject_build_args_to_dockerfile": { + "type": "boolean" + }, + "include_source_commit_in_build": { + "type": "boolean" + }, + "docker_images_to_keep": { + "type": "integer" + }, + "stop_grace_period": { + "type": "integer", + "nullable": true + } + }, + "type": "object" + }, "Environment": { "description": "Environment model", "properties": { @@ -13415,6 +16255,57 @@ }, "type": "object" }, + "Destination": { + "description": "A Docker network destination attached to a server.", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "network": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "standalone", + "swarm" + ] + }, + "server_uuid": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + }, + "Tag": { + "description": "Tag model", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + }, "Team": { "description": "Team model", "properties": { @@ -13637,6 +16528,14 @@ "name": "Deployments", "description": "Deployments" }, + { + "name": "Destinations", + "description": "Destinations" + }, + { + "name": "DigitalOcean", + "description": "DigitalOcean" + }, { "name": "GitHub Apps", "description": "GitHub Apps" @@ -13665,13 +16564,25 @@ "name": "Servers", "description": "Servers" }, + { + "name": "Service applications", + "description": "Service applications" + }, { "name": "Services", "description": "Services" }, + { + "name": "Tags", + "description": "Tags" + }, { "name": "Teams", "description": "Teams" + }, + { + "name": "Vultr", + "description": "Vultr" } ] } diff --git a/openapi.yaml b/openapi.yaml index 6182cacd3..6dfe91e0a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -118,6 +118,9 @@ paths: is_force_https_enabled: type: boolean description: 'The flag to indicate if HTTPS is forced. Defaults to true.' + is_preview_deployments_enabled: + type: boolean + description: 'Enable preview deployments for pull requests.' static_image: type: string enum: ['nginx:alpine'] @@ -265,6 +268,54 @@ paths: type: boolean nullable: true description: 'Use build server.' + use_build_secrets: + type: boolean + default: false + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -290,6 +341,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' is_preserve_repository_enabled: type: boolean default: false @@ -405,6 +460,9 @@ paths: is_force_https_enabled: type: boolean description: 'The flag to indicate if HTTPS is forced. Defaults to true.' + is_preview_deployments_enabled: + type: boolean + description: 'Enable preview deployments for pull requests.' static_image: type: string enum: ['nginx:alpine'] @@ -552,6 +610,54 @@ paths: type: boolean nullable: true description: 'Use build server.' + use_build_secrets: + type: boolean + default: false + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -577,6 +683,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' is_preserve_repository_enabled: type: boolean default: false @@ -692,6 +802,9 @@ paths: is_force_https_enabled: type: boolean description: 'The flag to indicate if HTTPS is forced. Defaults to true.' + is_preview_deployments_enabled: + type: boolean + description: 'Enable preview deployments for pull requests.' static_image: type: string enum: ['nginx:alpine'] @@ -839,6 +952,54 @@ paths: type: boolean nullable: true description: 'Use build server.' + use_build_secrets: + type: boolean + default: false + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -864,6 +1025,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' is_preserve_repository_enabled: type: boolean default: false @@ -1063,10 +1228,61 @@ paths: is_force_https_enabled: type: boolean description: 'The flag to indicate if HTTPS is forced. Defaults to true.' + is_preview_deployments_enabled: + type: boolean + description: 'Enable preview deployments for pull requests.' use_build_server: type: boolean nullable: true description: 'Use build server.' + use_build_secrets: + type: boolean + default: false + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -1092,6 +1308,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' type: object responses: '201': @@ -1277,10 +1497,61 @@ paths: is_force_https_enabled: type: boolean description: 'The flag to indicate if HTTPS is forced. Defaults to true.' + is_preview_deployments_enabled: + type: boolean + description: 'Enable preview deployments for pull requests.' use_build_server: type: boolean nullable: true description: 'Use build server.' + use_build_secrets: + type: boolean + default: false + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -1306,6 +1577,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' type: object responses: '201': @@ -1507,6 +1782,9 @@ paths: is_force_https_enabled: type: boolean description: 'The flag to indicate if HTTPS is forced. Defaults to true.' + is_preview_deployments_enabled: + type: boolean + description: 'Enable preview deployments for pull requests.' install_command: type: string description: 'The install command.' @@ -1650,6 +1928,53 @@ paths: type: boolean nullable: true description: 'Use build server.' + use_build_secrets: + type: boolean + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -1716,6 +2041,14 @@ paths: type: integer format: int32 default: 100 + - + name: show_timestamps + in: query + description: 'Show timestamps in the logs.' + required: false + schema: + type: boolean + default: false responses: '200': description: 'Get application logs by UUID.' @@ -2092,6 +2425,57 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/move': + post: + tags: + - Applications + summary: Move + description: 'Move application to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.' + operationId: move-application-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + description: 'Target environment to move the application to.' + required: true + content: + application/json: + schema: + required: + - environment_uuid + properties: + environment_uuid: + type: string + description: 'UUID of the target environment.' + type: object + responses: + '200': + description: 'Application moved successfully.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Application moved successfully.' } + uuid: { type: string } + project_uuid: { type: string } + environment_uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/applications/{uuid}/storages': get: tags: @@ -2347,6 +2731,121 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/tags': + get: + tags: + - Applications + summary: 'List Tags' + description: 'List tags for an application by UUID.' + operationId: list-tags-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'List of tags.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Applications + summary: 'Create Tag' + description: 'Add tag(s) to an application by UUID.' + operationId: create-tag-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + tag_name: + type: string + description: 'The tag name (min 2 characters). Required if tag_names is not provided.' + tag_names: + type: array + items: { type: string } + description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' + type: object + responses: + '201': + description: 'Tags added successfully.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/tags/{tag_uuid}': + delete: + tags: + - Applications + summary: 'Delete Tag' + description: 'Remove a tag from an application by UUID.' + operationId: delete-tag-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: tag_uuid + in: path + description: 'UUID of the tag.' + required: true + schema: + type: string + responses: + '200': + description: 'Tag removed.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: @@ -2362,7 +2861,7 @@ paths: schema: type: array items: - properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } + properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } type: object '401': $ref: '#/components/responses/401' @@ -2390,7 +2889,7 @@ paths: properties: provider: type: string - enum: [hetzner, digitalocean] + enum: [hetzner, digitalocean, vultr] example: hetzner description: 'The cloud provider.' token: @@ -3215,6 +3714,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3310,6 +3813,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3402,6 +3909,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3497,6 +4008,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3592,6 +4107,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3696,6 +4215,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3800,6 +4323,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3895,6 +4422,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3908,6 +4439,57 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/logs': + get: + tags: + - Databases + summary: 'Get database logs.' + description: 'Get database logs by UUID.' + operationId: get-database-logs-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + format: uuid + - + name: lines + in: query + description: 'Number of lines to show from the end of the logs.' + required: false + schema: + type: integer + format: int32 + default: 100 + - + name: show_timestamps + in: query + description: 'Show timestamps in the logs.' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Get database logs by UUID.' + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}': delete: tags: @@ -4001,6 +4583,57 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/move': + post: + tags: + - Databases + summary: Move + description: 'Move database to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.' + operationId: move-database-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Target environment to move the database to.' + required: true + content: + application/json: + schema: + required: + - environment_uuid + properties: + environment_uuid: + type: string + description: 'UUID of the target environment.' + type: object + responses: + '200': + description: 'Database moved successfully.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Database moved successfully.' } + uuid: { type: string } + project_uuid: { type: string } + environment_uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/databases/{uuid}/start': get: tags: @@ -4556,6 +5189,121 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/tags': + get: + tags: + - Databases + summary: 'List Tags' + description: 'List tags for a database by UUID.' + operationId: list-tags-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + responses: + '200': + description: 'List of tags.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Databases + summary: 'Create Tag' + description: 'Add tag(s) to a database by UUID.' + operationId: create-tag-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + tag_name: + type: string + description: 'The tag name (min 2 characters). Required if tag_names is not provided.' + tag_names: + type: array + items: { type: string } + description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' + type: object + responses: + '201': + description: 'Tags added successfully.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/tags/{tag_uuid}': + delete: + tags: + - Databases + summary: 'Delete Tag' + description: 'Remove a tag from a database by UUID.' + operationId: delete-tag-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: tag_uuid + in: path + description: 'UUID of the tag.' + required: true + schema: + type: string + responses: + '200': + description: 'Tag removed.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /deployments: get: tags: @@ -4768,6 +5516,308 @@ paths: security: - bearerAuth: [] + /destinations: + get: + tags: + - Destinations + summary: 'List destinations' + description: 'List all Docker network destinations for the authenticated team.' + operationId: list-destinations + responses: + '200': + description: 'Destinations for the authenticated team.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + security: + - + bearerAuth: [] + '/servers/{server_uuid}/destinations': + get: + tags: + - Destinations + summary: 'List destinations by server' + description: 'List Docker network destinations attached to a server owned by the authenticated team.' + operationId: list-server-destinations + parameters: + - + name: server_uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Destinations attached to the server.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Destinations + summary: 'Create destination' + description: 'Create a Docker network destination on a server owned by the authenticated team.' + operationId: create-server-destination + parameters: + - + name: server_uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - network + properties: + name: + type: string + maxLength: 255 + network: + type: string + maxLength: 255 + pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$' + type: + type: string + enum: [standalone, swarm] + type: object + responses: + '201': + description: 'Destination created.' + content: + application/json: + schema: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'A destination with this network already exists.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/destinations/{uuid}': + get: + tags: + - Destinations + summary: 'Get destination' + description: 'Get a Docker network destination by UUID.' + operationId: get-destination-by-uuid + parameters: + - + name: uuid + in: path + description: 'Destination UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Destination details.' + content: + application/json: + schema: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + delete: + tags: + - Destinations + summary: 'Delete destination' + description: 'Delete an unused Docker network destination.' + operationId: delete-destination-by-uuid + parameters: + - + name: uuid + in: path + description: 'Destination UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Destination deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: Deleted. } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Destination has attached resources.' + security: + - + bearerAuth: [] + /digitalocean/regions: + get: + tags: + - DigitalOcean + summary: 'Get DigitalOcean regions' + operationId: get-digitalocean-regions + parameters: + - + name: cloud_provider_token_uuid + in: query + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of DigitalOcean regions.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + security: + - + bearerAuth: [] + /digitalocean/sizes: + get: + tags: + - DigitalOcean + summary: 'Get DigitalOcean sizes' + operationId: get-digitalocean-sizes + parameters: + - + name: cloud_provider_token_uuid + in: query + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of DigitalOcean sizes.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + security: + - + bearerAuth: [] + /digitalocean/images: + get: + tags: + - DigitalOcean + summary: 'Get DigitalOcean images' + operationId: get-digitalocean-images + parameters: + - + name: cloud_provider_token_uuid + in: query + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of DigitalOcean images.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + security: + - + bearerAuth: [] + /digitalocean/ssh-keys: + get: + tags: + - DigitalOcean + summary: 'Get DigitalOcean SSH keys' + operationId: get-digitalocean-ssh-keys + parameters: + - + name: cloud_provider_token_uuid + in: query + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of DigitalOcean SSH keys.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + security: + - + bearerAuth: [] + /servers/digitalocean: + post: + tags: + - DigitalOcean + summary: 'Create a server on DigitalOcean' + operationId: create-digitalocean-server + responses: + '201': + description: 'DigitalOcean droplet created and linked to a Coolify server.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + '429': + description: 'DigitalOcean rate limit exceeded.' + security: + - + bearerAuth: [] /github-apps: get: tags: @@ -4806,7 +5856,6 @@ paths: schema: required: - name - - api_url - html_url - app_id - installation_id @@ -5245,6 +6294,86 @@ paths: security: - bearerAuth: [] + /hetzner/firewalls: + get: + tags: + - Hetzner + summary: 'Get Hetzner Firewalls' + description: 'Get all existing Hetzner firewalls for the current project.' + operationId: get-hetzner-firewalls + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner firewalls.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /hetzner/networks: + get: + tags: + - Hetzner + summary: 'Get Hetzner Networks' + description: 'Get all existing Hetzner private networks for the current project.' + operationId: get-hetzner-networks + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner networks.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string }, ip_range: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /servers/hetzner: post: tags: @@ -5301,10 +6430,22 @@ paths: type: boolean example: true description: 'Enable IPv6 (default: true)' + enable_backups: + type: boolean + example: false + description: 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)' hetzner_ssh_key_ids: type: array items: { type: integer } description: 'Additional Hetzner SSH key IDs' + hetzner_firewall_ids: + type: array + items: { type: integer } + description: 'Existing Hetzner firewall IDs to apply during server creation' + hetzner_network_ids: + type: array + items: { type: integer } + description: 'Existing Hetzner network IDs to attach during server creation' cloud_init_script: type: string description: 'Cloud-init YAML script (optional)' @@ -6844,6 +7985,330 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/applications': + get: + tags: + - 'Service applications' + summary: 'List service applications' + description: 'List compose service applications (containers) for a single service.' + operationId: list-service-applications-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Service applications for this service.' + content: + application/json: + schema: + type: array + items: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/services/{uuid}/applications/{app_uuid}': + get: + tags: + - 'Service applications' + summary: 'Get service application' + description: 'Get a single compose service application by service UUID and application UUID.' + operationId: get-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Service application.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Service applications' + summary: 'Update service application' + description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).' + operationId: patch-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + - + name: force_domain_override + in: query + description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).' + required: false + schema: + type: boolean + default: false + requestBody: + content: + application/json: + schema: + properties: + url: + description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.' + type: [string, 'null'] + human_name: + type: [string, 'null'] + description: + type: [string, 'null'] + image: + type: [string, 'null'] + exclude_from_status: + type: [boolean, 'null'] + is_log_drain_enabled: + type: [boolean, 'null'] + is_gzip_enabled: + type: [boolean, 'null'] + is_stripprefix_enabled: + type: [boolean, 'null'] + type: object + responses: + '200': + description: 'Updated service application.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Domain conflicts (unless force_domain_override).' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/applications/{app_uuid}/logs': + get: + tags: + - 'Service applications' + summary: 'Get service application logs' + description: 'Get Docker logs for a single compose service container.' + operationId: get-service-application-logs-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + - + name: lines + in: query + description: 'Number of lines to show from the end of the logs.' + required: false + schema: + type: integer + format: int32 + default: 100 + responses: + '200': + description: Logs. + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/applications/{app_uuid}/start': + get: + tags: + - 'Service applications' + summary: 'Start or redeploy service application container' + description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.' + operationId: start-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + - + name: force + in: query + description: 'When true, passes --build to docker compose up.' + required: false + schema: + type: boolean + default: false + - + name: latest + in: query + description: 'When true, pulls the image for this compose service before up.' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Deploy request queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/applications/{app_uuid}/restart': + get: + tags: + - 'Service applications' + summary: 'Restart service application container' + description: 'Restarts a single compose service container (docker restart).' + operationId: restart-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Restart queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/applications/{app_uuid}/stop': + get: + tags: + - 'Service applications' + summary: 'Stop service application container' + description: 'Stops a single compose service container (docker stop).' + operationId: stop-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Stop queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] /services: get: tags: @@ -6929,6 +8394,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the service.' type: object responses: '201': @@ -7150,6 +8619,65 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/logs': + get: + tags: + - Services + summary: 'Get service logs.' + description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.' + operationId: get-service-logs-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + format: uuid + - + name: sub_service_name + in: query + description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.' + required: true + schema: + type: string + example: appwrite-console + - + name: lines + in: query + description: 'Number of lines to show from the end of the logs.' + required: false + schema: + type: integer + format: int32 + default: 100 + - + name: show_timestamps + in: query + description: 'Show timestamps in the logs.' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Get service logs by UUID.' + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] '/services/{uuid}/envs': get: tags: @@ -7392,6 +8920,57 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/move': + post: + tags: + - Services + summary: Move + description: 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.' + operationId: move-service-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + description: 'Target environment to move the service to.' + required: true + content: + application/json: + schema: + required: + - environment_uuid + properties: + environment_uuid: + type: string + description: 'UUID of the target environment.' + type: object + responses: + '200': + description: 'Service moved successfully.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Service moved successfully.' } + uuid: { type: string } + project_uuid: { type: string } + environment_uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/services/{uuid}/start': get: tags: @@ -7722,6 +9301,144 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/tags': + get: + tags: + - Services + summary: 'List Tags' + description: 'List tags for a service by UUID.' + operationId: list-tags-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'List of tags.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Services + summary: 'Create Tag' + description: 'Add tag(s) to a service by UUID.' + operationId: create-tag-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + tag_name: + type: string + description: 'The tag name (min 2 characters). Required if tag_names is not provided.' + tag_names: + type: array + items: { type: string } + description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' + type: object + responses: + '201': + description: 'Tags added successfully.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/tags/{tag_uuid}': + delete: + tags: + - Services + summary: 'Delete Tag' + description: 'Remove a tag from a service by UUID.' + operationId: delete-tag-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: tag_uuid + in: path + description: 'UUID of the tag.' + required: true + schema: + type: string + responses: + '200': + description: 'Tag removed.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /tags: + get: + tags: + - Tags + summary: List + description: 'List all tags for the current team.' + operationId: list-tags + responses: + '200': + description: 'All tags for the current team.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] /teams: get: tags: @@ -7853,6 +9570,93 @@ paths: security: - bearerAuth: [] + /vultr/regions: + get: + tags: + - Vultr + summary: 'Get Vultr Regions' + description: 'Get all available Vultr regions.' + operationId: get-vultr-regions + responses: + '200': + description: 'List of Vultr regions.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /vultr/plans: + get: + tags: + - Vultr + summary: 'Get Vultr Plans' + description: 'Get all available Vultr plans.' + operationId: get-vultr-plans + responses: + '200': + description: 'List of Vultr plans.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /vultr/os: + get: + tags: + - Vultr + summary: 'Get Vultr Operating Systems' + description: 'Get all available Vultr operating systems.' + operationId: get-vultr-operating-systems + responses: + '200': + description: 'List of Vultr operating systems.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /vultr/ssh-keys: + get: + tags: + - Vultr + summary: 'Get Vultr SSH Keys' + description: 'Get all Vultr SSH keys available to the selected token.' + operationId: get-vultr-ssh-keys + responses: + '200': + description: 'List of Vultr SSH keys.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /servers/vultr: + post: + tags: + - Vultr + summary: 'Create Vultr Server' + description: 'Create a Vultr instance and link it as a Coolify server.' + operationId: create-vultr-server + responses: + '201': + description: 'Vultr server created.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + '429': + description: 'Vultr API rate limit exceeded.' + security: + - + bearerAuth: [] components: schemas: Application: @@ -8163,6 +9967,8 @@ components: type: string nullable: true description: 'Password for HTTP Basic Authentication' + '': + $ref: '#/components/schemas/ApplicationSetting' type: object ApplicationDeploymentQueue: description: 'Project model' @@ -8226,6 +10032,86 @@ components: commit_message: type: string type: object + ApplicationSetting: + description: 'Application settings.' + properties: + is_static: + type: boolean + is_git_submodules_enabled: + type: boolean + is_git_lfs_enabled: + type: boolean + is_auto_deploy_enabled: + type: boolean + is_force_https_enabled: + type: boolean + is_debug_enabled: + type: boolean + is_preview_deployments_enabled: + type: boolean + is_log_drain_enabled: + type: boolean + is_gpu_enabled: + type: boolean + gpu_driver: + type: string + nullable: true + gpu_count: + type: string + nullable: true + gpu_device_ids: + type: string + nullable: true + gpu_options: + type: string + nullable: true + is_include_timestamps: + type: boolean + is_swarm_only_worker_nodes: + type: boolean + is_raw_compose_deployment_enabled: + type: boolean + is_build_server_enabled: + type: boolean + is_consistent_container_name_enabled: + type: boolean + is_gzip_enabled: + type: boolean + is_stripprefix_enabled: + type: boolean + connect_to_docker_network: + type: boolean + custom_internal_name: + type: string + nullable: true + is_container_label_escape_enabled: + type: boolean + is_env_sorting_enabled: + type: boolean + is_container_label_readonly_enabled: + type: boolean + is_preserve_repository_enabled: + type: boolean + disable_build_cache: + type: boolean + is_spa: + type: boolean + is_git_shallow_clone_enabled: + type: boolean + is_pr_deployments_public_enabled: + type: boolean + use_build_secrets: + type: boolean + inject_build_args_to_dockerfile: + type: boolean + include_source_commit_in_build: + type: boolean + docker_images_to_keep: + type: integer + stop_grace_period: + type: integer + nullable: true + type: object Environment: description: 'Environment model' properties: @@ -8602,6 +10488,41 @@ components: type: string description: 'The date and time when the service was deleted.' type: object + Destination: + description: 'A Docker network destination attached to a server.' + properties: + uuid: + type: string + name: + type: string + network: + type: string + type: + type: string + enum: + - standalone + - swarm + server_uuid: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + type: object + Tag: + description: 'Tag model' + properties: + uuid: + type: string + name: + type: string + created_at: + type: string + updated_at: + type: string + type: object Team: description: 'Team model' properties: @@ -8749,6 +10670,12 @@ tags: - name: Deployments description: Deployments + - + name: Destinations + description: Destinations + - + name: DigitalOcean + description: DigitalOcean - name: 'GitHub Apps' description: 'GitHub Apps' @@ -8770,9 +10697,18 @@ tags: - name: Servers description: Servers + - + name: 'Service applications' + description: 'Service applications' - name: Services description: Services + - + name: Tags + description: Tags - name: Teams description: Teams + - + name: Vultr + description: Vultr diff --git a/other/nightly/.env.production b/other/nightly/.env.production index fe3c8370e..cda3214d2 100644 --- a/other/nightly/.env.production +++ b/other/nightly/.env.production @@ -15,4 +15,4 @@ ROOT_USERNAME= ROOT_USER_EMAIL= ROOT_USER_PASSWORD= -REGISTRY_URL=ghcr.io +REGISTRY_URL=docker.io diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 8907a30b9..9618e18ec 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -1,6 +1,6 @@ services: coolify: - image: "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}" + image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}" volumes: - type: bind source: /data/coolify/source/.env @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16' + image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.16' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index da045fe03..3a6c3f11c 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -1,14 +1,14 @@ services: coolify-testing-host: init: true - image: "ghcr.io/coollabsio/coolify-testing-host:latest" + image: "docker.io/coollabsio/coolify-testing-host:latest" pull_policy: always container_name: coolify-testing-host volumes: - //var/run/docker.sock://var/run/docker.sock - ./:/data/coolify coolify: - image: "ghcr.io/coollabsio/coolify:latest" + image: "docker.io/coollabsio/coolify:latest" pull_policy: always container_name: coolify restart: always diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 028652d80..365e4c330 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -9,7 +9,7 @@ ## DOCKER_ADDRESS_POOL_SIZE - Custom Docker address pool size (default: 24) ## DOCKER_POOL_FORCE_OVERRIDE - Force override Docker address pool configuration (default: false) ## AUTOUPDATE - Set to "false" to disable auto-updates -## REGISTRY_URL - Custom registry URL for Docker images (default: ghcr.io) +## REGISTRY_URL - Custom registry URL for Docker images (default: docker.io) set -e # Exit immediately if a command exits with a non-zero status ## $1 could be empty, so we need to disable this check @@ -50,7 +50,7 @@ else REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2) echo "Using registry URL from .env: $REGISTRY_URL" else - REGISTRY_URL="ghcr.io" + REGISTRY_URL="docker.io" echo "Using default registry URL: $REGISTRY_URL" fi fi @@ -920,9 +920,9 @@ echo -e " - Please wait." getAJoke if [[ $- == *x* ]]; then - bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" + bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" else - bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" + bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" fi echo " - Coolify installed successfully." echo " - Waiting for Coolify to be ready..." diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 8ccacb8a0..94fb77607 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -4,9 +4,15 @@ CDN="https://cdn.coollabs.io/coolify-nightly" LATEST_IMAGE=${1:-latest} LATEST_HELPER_VERSION=${2:-latest} -REGISTRY_URL=${3:-ghcr.io} -SKIP_BACKUP=${4:-false} ENV_FILE="/data/coolify/source/.env" +if [ -n "${3+x}" ]; then + REGISTRY_URL="$3" +elif [ -f "$ENV_FILE" ] && grep -q "^REGISTRY_URL=" "$ENV_FILE"; then + REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2- | head -n1) +else + REGISTRY_URL="docker.io" +fi +SKIP_BACKUP=${4:-false} STATUS_FILE="/data/coolify/source/.upgrade-status" DATE=$(date +%Y-%m-%d-%H-%M-%S) @@ -80,7 +86,7 @@ fi # Get all unique images from docker compose config # LATEST_IMAGE env var is needed for image substitution in compose files -IMAGES=$(LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u) +IMAGES=$(REGISTRY_URL=${REGISTRY_URL} LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u) if [ -z "$IMAGES" ]; then log "ERROR: Failed to extract images from docker-compose files" @@ -127,7 +133,21 @@ update_env_var() { fi } +set_env_var() { + local key="$1" + local value="$2" + + if grep -q "^${key}=" "$ENV_FILE"; then + sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE" + log "Updated ${key}" + else + printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE" + log "Added ${key}" + fi +} + log "Checking environment variables..." +set_env_var "REGISTRY_URL" "$REGISTRY_URL" update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)" @@ -164,7 +184,7 @@ echo "3/6 Pulling Docker images..." echo " This may take a few minutes depending on your connection." # Also pull the helper image (not in compose files but needed for upgrade) -HELPER_IMAGE="${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}" +HELPER_IMAGE="${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}" echo " - Pulling $HELPER_IMAGE..." log "Pulling image: $HELPER_IMAGE" if docker pull "$HELPER_IMAGE" >>"$LOGFILE" 2>&1; then @@ -256,7 +276,7 @@ nohup bash -c " fi log 'Running docker compose up...' - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 log 'Docker compose up completed' # Final log entry diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 751db0754..9c9a405aa 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.2.0" + "version": "4.1.2" }, "nightly": { - "version": "4.2.1" + "version": "4.2.0" }, "helper": { "version": "1.0.14" diff --git a/resources/css/app.css b/resources/css/app.css index de92bf0c9..c354c6c53 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -97,6 +97,90 @@ @keyframes lds-heart { } } +@keyframes coolbox-border-track { + 0% { + background-position: 0 0, 100% 0, 100% 100%, 0 100%; + background-size: 35% 2px, 0 0, 0 0, 0 0; + } + + 33% { + background-position: 100% 0, 100% 0, 100% 100%, 0 100%; + background-size: 35% 2px, 0 0, 0 0, 0 0; + } + + 34% { + background-position: 100% 0, 100% 0, 100% 100%, 0 100%; + background-size: 0 0, 2px 35%, 0 0, 0 0; + } + + 50% { + background-position: 100% 0, 100% 100%, 100% 100%, 0 100%; + background-size: 0 0, 2px 35%, 0 0, 0 0; + } + + 51% { + background-position: 0 0, 100% 0, 100% 100%, 0 100%; + background-size: 0 0, 0 0, 35% 2px, 0 0; + } + + 84% { + background-position: 0 0, 100% 0, 0 100%, 0 100%; + background-size: 0 0, 0 0, 35% 2px, 0 0; + } + + 85% { + background-position: 0 0, 100% 0, 100% 100%, 0 100%; + background-size: 0 0, 0 0, 0 0, 2px 35%; + } + + 100% { + background-position: 0 0, 100% 0, 100% 100%, 0 0; + background-size: 0 0, 0 0, 0 0, 2px 35%; + } +} + +@layer components { + .coolbox-loading { + @apply overflow-hidden border-transparent; + isolation: isolate; + } + + .coolbox-loading::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: + linear-gradient(var(--color-warning), var(--color-warning)), + linear-gradient(var(--color-warning), var(--color-warning)), + linear-gradient(var(--color-warning), var(--color-warning)), + linear-gradient(var(--color-warning), var(--color-warning)); + background-repeat: no-repeat; + animation: coolbox-border-track 2400ms linear infinite; + pointer-events: none; + z-index: 0; + } + + .coolbox-loading::after { + content: ""; + position: absolute; + inset: 2px; + border-radius: calc(0.25rem - 2px); + background: white; + pointer-events: none; + z-index: 1; + } + + .dark .coolbox-loading::after { + background: var(--color-coolgray-100); + } + + .coolbox-loading > * { + position: relative; + z-index: 2; + } +} + /* * Base styles */ diff --git a/resources/css/utilities.css b/resources/css/utilities.css index c982f9f86..1df8ac836 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -289,7 +289,7 @@ @utility info-helper { } @utility info-helper-popup { - @apply hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-sm whitespace-normal break-words; + @apply hidden absolute right-0 z-40 w-max max-w-[min(20rem,calc(100vw-2rem))] text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 whitespace-normal break-words; } @utility buyme { diff --git a/resources/views/components/digital-ocean-icon.blade.php b/resources/views/components/digital-ocean-icon.blade.php new file mode 100644 index 000000000..5652730d6 --- /dev/null +++ b/resources/views/components/digital-ocean-icon.blade.php @@ -0,0 +1,12 @@ +@php + $iconAttributes = $attributes->has('class') + ? $attributes->class('shrink-0') + : $attributes->merge(['class' => 'size-6 shrink-0']); +@endphp + + + DigitalOcean + + diff --git a/resources/views/components/dropdown.blade.php b/resources/views/components/dropdown.blade.php index 2bb917f79..b48b04143 100644 --- a/resources/views/components/dropdown.blade.php +++ b/resources/views/components/dropdown.blade.php @@ -1,3 +1,9 @@ +@props([ + 'inline' => false, + 'triggerClass' => '', + 'panelClass' => '', +]) +
- -
-
+ :style="panelStyles" @class([ + 'mt-1 w-full' => $inline, + 'absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6' => ! $inline, + ]) x-cloak> +
! $inline, + 'border-0 bg-transparent shadow-none dark:border-0 dark:bg-transparent' => $inline, + $panelClass, + ])> {{ $slot }}
diff --git a/resources/views/components/forms/button.blade.php b/resources/views/components/forms/button.blade.php index f1efd8c6c..89b177c1f 100644 --- a/resources/views/components/forms/button.blade.php +++ b/resources/views/components/forms/button.blade.php @@ -1,4 +1,4 @@ -@if ($authDisabled) +@if ($authDisabled || filled($tooltip)) - You do not have permission to perform this action. + {{ $tooltip ?: 'You do not have permission to perform this action.' }}
@endif diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php index 2542839f1..900beddd0 100644 --- a/resources/views/components/helper.blade.php +++ b/resources/views/components/helper.blade.php @@ -1,6 +1,47 @@ -
merge(['class' => 'group']) }}> -
+
merge(['class' => 'inline-block align-middle']) }}> +
@isset($icon) {{ $icon }} @else @@ -9,11 +50,13 @@ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"> @endisset -
-
-
- {!! $helper !!} +
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 5efc9102b..830b7e09c 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -6,6 +6,7 @@ 'buttonFullWidth' => false, 'customButton' => null, 'disabled' => false, + 'disabledTooltip' => null, 'authDisabled' => false, 'dispatchAction' => false, 'submitAction' => 'delete', @@ -156,11 +157,11 @@ @else @if ($disabled) @if ($buttonFullWidth) - + {{ $buttonTitle }} @else - + {{ $buttonTitle }} @endif diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index dc1191b44..96bb3467c 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -8,6 +8,7 @@ 'content' => null, 'closeOutside' => true, 'isFullWidth' => false, + 'wireIgnore' => true, ]) @php @@ -17,7 +18,7 @@
+ class="relative w-auto h-auto" @close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif> @if ($content)
{{ $content }} diff --git a/resources/views/components/resources/breadcrumbs.blade.php b/resources/views/components/resources/breadcrumbs.blade.php index 975a1bf4b..898f684c0 100644 --- a/resources/views/components/resources/breadcrumbs.blade.php +++ b/resources/views/components/resources/breadcrumbs.blade.php @@ -52,7 +52,7 @@