mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
Merge remote-tracking branch 'origin/next' into openid-auth-support
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -15,4 +15,4 @@ ROOT_USERNAME=
|
||||
ROOT_USER_EMAIL=
|
||||
ROOT_USER_PASSWORD=
|
||||
|
||||
REGISTRY_URL=ghcr.io
|
||||
REGISTRY_URL=docker.io
|
||||
|
||||
@@ -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:
|
||||
|
||||
+190
-248
@@ -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:
|
||||
|
||||
<details>
|
||||
<summary><strong>Windows</strong></summary>
|
||||
|
||||
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)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>MacOS</strong></summary>
|
||||
|
||||
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)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Linux</strong></summary>
|
||||
|
||||
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)
|
||||
|
||||
</details>
|
||||
|
||||
## 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
|
||||
|
||||
+212
@@ -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:
|
||||
|
||||
<details>
|
||||
<summary><strong>Windows</strong></summary>
|
||||
|
||||
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)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>MacOS</strong></summary>
|
||||
|
||||
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)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Linux</strong></summary>
|
||||
|
||||
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)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## 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)
|
||||
@@ -57,106 +57,95 @@ Thank you so much!
|
||||
|
||||
### 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
|
||||
|
||||
<a href="https://open-elements.com/?utm_source=coolify.io"><img width="60px" alt="OpenElements" src="https://github.com/OpenElements.png"/></a>
|
||||
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="XamanApp" src="https://github.com/XamanApp.png"/></a>
|
||||
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
|
||||
<a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a>
|
||||
<a href="https://github.com/iujlaki"><img width="60px" alt="Imre Ujlaki" src="https://github.com/iujlaki.png"/></a>
|
||||
<a href="https://bsky.app/profile/jyc.dev"><img width="60px" alt="jyc.dev" src="https://github.com/jycouet.png"/></a>
|
||||
<a href="https://github.com/therealjp?utm_source=coolify.io"><img width="60px" alt="TheRealJP" src="https://github.com/therealjp.png"/></a>
|
||||
<a href="https://360creators.com/?utm_source=coolify.io"><img width="60px" alt="360Creators" src="https://opencollective-production.s3.us-west-1.amazonaws.com/account-avatar/503e0953-bff7-4296-b4cc-5e36d40eecc0/icon-360creators.png"/></a>
|
||||
<a href="https://github.com/aniftyco"><img width="60px" alt="NiftyCo" src="https://github.com/aniftyco.png"/></a>
|
||||
<a href="https://dry.software/?utm_source=coolify.io"><img width="60px" alt="Dry Software" src="https://github.com/dry-software.png"/></a>
|
||||
<a href="https://lightspeed.run/?utm_source=coolify.io"><img width="60px" alt="Lightspeed.run" src="https://github.com/lightspeedrun.png"/></a>
|
||||
<a href="https://linkdr.com?utm_source=coolify.io"><img width="60px" alt="LinkDr" src="https://github.com/LLM-Inc.png"/></a>
|
||||
<a href="http://gravitywiz.com/?utm_source=coolify.io"><img width="60px" alt="Gravity Wiz" src="https://github.com/gravitywiz.png"/></a>
|
||||
<a href="https://bitlaunch.io/?utm_source=coolify.io"><img width="60px" alt="BitLaunch" src="https://github.com/bitlaunchio.png"/></a>
|
||||
<a href="https://bestforandroid.com/?utm_source=coolify.io"><img width="60px" alt="Best for Android" src="https://github.com/bestforandroid.png"/></a>
|
||||
<a href="https://il.ly/?utm_source=coolify.io"><img width="60px" alt="Ilias Ism" src="https://github.com/Illyism.png"/></a>
|
||||
<a href="https://formbricks.com/?utm_source=coolify.io"><img width="60px" alt="Formbricks" src="https://github.com/formbricks.png"/></a>
|
||||
<a href="https://www.serversearcher.com/"><img width="60px" alt="Server Searcher" src="https://github.com/serversearcher.png"/></a>
|
||||
<a href="https://www.reshot.ai/?utm_source=coolify.io"><img width="60px" alt="Reshot" src="https://coolify.io/images/reshotai.png"/></a>
|
||||
<a href="https://cirun.io/?utm_source=coolify.io"><img width="60px" alt="Cirun" src="https://coolify.io/images/cirun-logo.png"/></a>
|
||||
<a href="https://typebot.io/?utm_source=coolify.io"><img width="60px" alt="Typebot" src="https://cdn.bsky.app/img/avatar/plain/did:plc:gwxcta3pccyim4z5vuultdqx/bafkreig23hci7e2qpdxicsshnuzujbcbcgmydxhbybkewszdezhdodv42m@jpeg"/></a>
|
||||
<a href="https://cccareers.org/?utm_source=coolify.io"><img width="60px" alt="Creating Coding Careers" src="https://github.com/cccareers.png"/></a>
|
||||
<a href="https://internetgarden.co/?utm_source=coolify.io"><img width="60px" alt="Internet Garden" src="https://coolify.io/images/internetgarden.ico"/></a>
|
||||
<a href="https://web3.career/?utm_source=coolify.io"><img width="60px" alt="Web3 Jobs" src="https://coolify.io/images/web3jobs.png"/></a>
|
||||
<a href="https://codext.link/coolify-io?utm_source=coolify.io"><img width="60px" alt="Codext" src="https://coolify.io/images/codext.jpg"/></a>
|
||||
<a href="https://github.com/monocursive"><img width="60px" alt="Michael Mazurczak" src="https://github.com/monocursive.png"/></a>
|
||||
<a href="https://fider.io/?utm_source=coolify.io"><img width="60px" alt="Fider" src="https://github.com/getfider.png"/></a>
|
||||
<a href="https://www.flint.sh/en/home?utm_source=coolify.io"><img width="60px" alt="Flint" src="https://github.com/Flint-company.png"/></a>
|
||||
<a href="https://github.com/urtho"><img width="60px" alt="Paweł Pierścionek" src="https://github.com/urtho.png"/></a>
|
||||
<a href="https://www.runpod.io/?utm_source=coolify.io"><img width="60px" alt="RunPod" src="https://coolify.io/images/runpod.svg"/></a>
|
||||
<a href="https://dartnode.com/?utm_source=coolify.io"><img width="60px" alt="DartNode" src="https://github.com/dartnode.png"/></a>
|
||||
<a href="https://github.com/whitesidest"><img width="60px" alt="Tyler Whitesides" src="https://avatars.githubusercontent.com/u/12365916?s=52&v=4"/></a>
|
||||
<a href="https://aquarela.io"><img width="60px" alt="Aquarela" src="https://github.com/aquarela-io.png"/></a>
|
||||
<a href="https://cryptojobslist.com/?utm_source=coolify.io"><img width="60px" alt="Crypto Jobs List" src="https://github.com/cryptojobslist.png"/></a>
|
||||
<a href="https://www.youtube.com/@AlfredNutile?utm_source=coolify.io"><img width="60px" alt="Alfred Nutile" src="https://github.com/alnutile.png"/></a>
|
||||
<a href="https://startupfa.me?utm_source=coolify.io"><img width="60px" alt="Startup Fame" src="https://github.com/startupfame.png"/></a>
|
||||
<a href="https://barrad.me/?utm_source=coolify.io"><img width="60px" alt="Younes Barrad" src="https://github.com/Flowko.png"/></a>
|
||||
<a href="https://jonasjaeger.com?utm_source=coolify.io"><img width="60px" alt="Jonas Jaeger" src="https://github.com/toxin20.png"/></a>
|
||||
<a href="https://pixel.ao/?utm_source=coolify.io"><img width="60px" alt="Pixel Infinito" src="https://github.com/pixelinfinito.png"/></a>
|
||||
<a href="https://github.com/corentinclichy"><img width="60px" alt="Corentin Clichy" src="https://github.com/corentinclichy.png"/></a>
|
||||
<a href="https://x.com/mrsmith9ja?utm_source=coolify.io"><img width="60px" alt="Thompson Edolo" src="https://github.com/verygreenboi.png"/></a>
|
||||
<a href="https://devhuset.no?utm_source=coolify.io"><img width="60px" alt="Devhuset" src="https://github.com/devhuset.png"/></a>
|
||||
<a href="https://arvensis.systems/?utm_source=coolify.io"><img width="60px" alt="Arvensis Systems" src="https://coolify.io/images/arvensis.png"/></a>
|
||||
<a href="https://github.com/Niki2k1"><img width="60px" alt="Niklas Lausch" src="https://github.com/Niki2k1.png"/></a>
|
||||
<a href="https://capgo.app/?utm_source=coolify.io"><img width="60px" alt="Cap-go" src="https://github.com/cap-go.png"/></a>
|
||||
<a href="https://interviewpal.com/?utm_source=coolify.io"><img width="60px" alt="InterviewPal" src="/public/svgs/interviewpal.svg"/></a>
|
||||
<a href="https://transcript.lol/?utm_source=coolify.io"><img width="60px" alt="Transcript LOL" src="https://transcript.lol/logo.png"/></a>
|
||||
<a href="https://www.movavi.com/mac.html?utm_source=coolify.io"><img width="60px" alt="Movavi" src="https://cdn.coollabs.io/sponsors/movavi.png"/></a>
|
||||
<a href="https://usefoil.com/"><img width="60px" alt="ABXY" src="https://usefoil.com/favicon.svg"/></a>
|
||||
<a href="https://www.launchfa.st/?utm_source=coolify.io"><img width="60px" alt="LaunchFast Boilerplates" src="https://github.com/LaunchFast-Boilerplates.png"/></a>
|
||||
<a href="https://vanaways.co.uk/?utm_source=coolify.io"><img width="60px" alt="Vanaways" src="https://github.com/Vanaways.png"/></a>
|
||||
<a href="https://www.netrouting.com/?utm_source=coolify.io"><img width="60px" alt="Netrouting" src="https://github.com/netroutingcom.png"/></a>
|
||||
<a href="https://github.com/mindedtech"><img width="60px" alt="MindEd Tech" src="https://github.com/mindedtech.png"/></a>
|
||||
<a href="https://youstable.com/?utm_source=coolify.io"><img width="60px" alt="YouStable" src="https://github.com/youstable.png"/></a>
|
||||
<a href="https://github.com/mindedtech?utm_source=coolify.io"><img width="60px" alt="MindedTech" src="https://github.com/mindedtech.png"/></a>
|
||||
<a href="https://netrouting.com/?utm_source=coolify.io"><img width="60px" alt="NetRouting" src="https://github.com/netroutingcom.png"/></a>
|
||||
<a href="https://github.com/parsecph?utm_source=coolify.io"><img width="60px" alt="ParsecPH" src="https://github.com/parsecph.png"/></a>
|
||||
|
||||
<a href="https://transcript.lol/?utm_source=coolify.io"><img width="60px" alt="Transcript LOL" src="https://transcript.lol/logo.png"/></a>
|
||||
<a href="https://www.autom.dev/?utm_source=coolify.io"><img width="60px" alt="Autom" src="https://cdn.coollabs.io/sponsors/autom.png"/></a>
|
||||
<a href="https://www.huntapi.com/?utm_source=coolify.io"><img width="60px" alt="HuntAPI" src="https://cdn.coollabs.io/sponsors/huntapi.png"/></a>
|
||||
<a href="https://ultraservers.com/?utm_source=coolify.io"><img width="60px" alt="ULTRASERVERS" src="https://github.com/ULTRASERVERS.png"/></a>
|
||||
<a href="https://vibetone.com/?utm_source=coolify.io"><img width="60px" alt="VibeTone" src="https://github.com/vibetonefm.png"/></a>
|
||||
<a href="https://www.piloterr.com/?utm_source=coolify.io"><img width="60px" alt="Piloterr" src="https://cdn.coollabs.io/sponsors/piloterr.svg"/></a>
|
||||
<a href="https://yoxel.com/?utm_source=coolify.io"><img width="60px" alt="Alexey Panteleev" src="https://github.com/aspantel.png"/></a>
|
||||
<a href="https://summyt.app?utm_source=coolify.io"><img width="60px" alt="SummYT - YouTube Summarizer" src="https://summyt.app/logo.svg"/></a>
|
||||
<a href="https://open-elements.com/?utm_source=coolify.io"><img width="60px" alt="OpenElements" src="https://github.com/OpenElements.png"/></a>
|
||||
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="Xaman" src="https://github.com/XamanApp.png"/></a>
|
||||
<a href="https://monadical.com/?utm_source=coolify.io"><img width="60px" alt="Monadical" src="https://github.com/Monadical-SAS.png"/></a>
|
||||
<a href="https://maas.engineering/?utm_source=coolify.io"><img width="60px" alt="Magic as a Service" src="https://github.com/magicasaservice.png"/></a>
|
||||
<a href="https://fivemanage.com?utm_source=coolify.io"><img width="60px" alt="FiveManage" src="https://cdn.coollabs.io/sponsors/fivemanage.jpg"/></a>
|
||||
<a href="https://cryptojobslist.com/?utm_source=coolify.io"><img width="60px" alt="Crypto Jobs List" src="https://github.com/cryptojobslist.png"/></a>
|
||||
<a href="https://serpapi.com/?utm_source=coolify.io"><img width="60px" alt="SerpAPI" src="https://github.com/serpapi.png"/></a>
|
||||
<a href="https://typebot.io/?utm_source=coolify.io"><img width="60px" alt="typebot" src="https://cdn.bsky.app/img/avatar/plain/did:plc:gwxcta3pccyim4z5vuultdqx/bafkreig23hci7e2qpdxicsshnuzujbcbcgmydxhbybkewszdezhdodv42m@jpeg"/></a>
|
||||
<a href="https://360creators.com/?utm_source=coolify.io"><img width="60px" alt="360Creators" src="https://opencollective-production.s3.us-west-1.amazonaws.com/account-avatar/503e0953-bff7-4296-b4cc-5e36d40eecc0/icon-360creators.png"/></a>
|
||||
<a href="https://capgo.app/?utm_source=coolify.io"><img width="60px" alt="Cap-go" src="https://github.com/cap-go.png"/></a>
|
||||
<a href="https://cirun.io/?utm_source=coolify.io"><img width="60px" alt="Cirun" src="https://cdn.coollabs.io/sponsors/cirun-logo.png"/></a>
|
||||
<a href="https://github.com/puls-digital-group"><img width="60px" alt="Puls Digital Group" src="https://github.com/puls-digital-group.png"/></a>
|
||||
<a href="https://github.com/jonathanprl"><img width="60px" alt="Jonathan Pereira" src="https://github.com/jonathanprl.png"/></a>
|
||||
<a href="https://internetgarden.co/?utm_source=coolify.io"><img width="60px" alt="Internet Garden" src="https://cdn.coollabs.io/sponsors/internetgarden.ico"/></a>
|
||||
<a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a>
|
||||
<a href="https://web3.career/?utm_source=coolify.io"><img width="60px" alt="Web3 Jobs" src="https://cdn.coollabs.io/sponsors/web3jobs.png"/></a>
|
||||
<a href="https://linkdr.com?utm_source=coolify.io"><img width="60px" alt="LinkDr" src="https://cdn.coollabs.io/sponsors/linkdr.svg"/></a>
|
||||
<a href="https://arvensis.systems/?utm_source=coolify.io"><img width="60px" alt="Arvensis Systems" src="https://cdn.coollabs.io/sponsors/arvensis.png"/></a>
|
||||
<a href="https://www.reshot.ai/?utm_source=coolify.io"><img width="60px" alt="Reshot" src="https://cdn.coollabs.io/sponsors/reshotai.png"/></a>
|
||||
<a href="https://www.runpod.io/?utm_source=coolify.io"><img width="60px" alt="RunPod" src="https://cdn.coollabs.io/sponsors/runpod.svg"/></a>
|
||||
<a href="http://gravitywiz.com/?utm_source=coolify.io"><img width="60px" alt="Gravity Wiz" src="https://github.com/gravitywiz.png"/></a>
|
||||
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
|
||||
<a href="https://codext.link/coolify-io?utm_source=coolify.io"><img width="60px" alt="Codext" src="https://cdn.coollabs.io/sponsors/codext.jpg"/></a>
|
||||
<a href="https://interviewpal.com"><img width="60px" alt="InterviewPal" src="https://cdn.coollabs.io/sponsors/interviewpal.svg"/></a>
|
||||
<a href="https://decidable.no?utm_source=coolify.io"><img width="60px" alt="Decidable" src="https://github.com/Decidable-AS.png"/></a>
|
||||
<a href="https://hosthavoc.com"><img width="60px" alt="Host Havoc" src="https://cdn.coollabs.io/sponsors/hosthavoc.png"/></a>
|
||||
|
||||
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
|
||||
|
||||
|
||||
@@ -143,8 +143,6 @@ class StartDatabaseProxy
|
||||
)
|
||||
);
|
||||
|
||||
ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Destination;
|
||||
|
||||
use App\Models\StandaloneDocker;
|
||||
|
||||
class RemoveStandaloneDockerNetwork
|
||||
{
|
||||
public function handle(StandaloneDocker $destination): void
|
||||
{
|
||||
$safeNetwork = escapeshellarg($destination->network);
|
||||
|
||||
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false);
|
||||
instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ namespace App\Actions\Fortify;
|
||||
|
||||
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 @@ use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
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 @@ class CreateNewUser implements CreatesNewUsers
|
||||
if (! $settings->isPasswordRegistrationAllowed()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->ensureRegistrationIsNotRateLimited($input);
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
@@ -72,4 +87,42 @@ class CreateNewUser implements CreatesNewUsers
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class CleanupDocker
|
||||
$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";
|
||||
|
||||
@@ -6,14 +6,16 @@ use App\Models\CloudProviderToken;
|
||||
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 @@ class DeleteServer
|
||||
);
|
||||
}
|
||||
|
||||
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 @@ class DeleteServer
|
||||
$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 @@ class DeleteServer
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
ray('No Hetzner token found for team, skipping Hetzner deletion', [
|
||||
'team_id' => $teamId,
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -77,16 +88,7 @@ class DeleteServer
|
||||
$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 @@ class DeleteServer
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class StartSentinel
|
||||
$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.');
|
||||
}
|
||||
|
||||
@@ -118,10 +118,14 @@ class UpdateCoolify
|
||||
{
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,32 @@ class ValidateServer
|
||||
$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');
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Contracts\Activity;
|
||||
|
||||
class DeployServiceApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity
|
||||
{
|
||||
$service = $serviceApplication->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class RestartServiceApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication $serviceApplication): void
|
||||
{
|
||||
$service = $serviceApplication->service;
|
||||
$server = $service->destination->server;
|
||||
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
|
||||
|
||||
instant_remote_process([
|
||||
"docker restart {$containerName}",
|
||||
], $server);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StopServiceApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication $serviceApplication): void
|
||||
{
|
||||
$service = $serviceApplication->service;
|
||||
$server = $service->destination->server;
|
||||
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
|
||||
|
||||
instant_remote_process([
|
||||
"docker stop {$containerName}",
|
||||
], $server);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Support\ServiceComposeUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UpdateServiceApplicationFromApi
|
||||
{
|
||||
public function execute(ServiceApplication $serviceApplication, Request $request, string $teamId, array $payload): ?JsonResponse
|
||||
{
|
||||
$forceDomainOverride = $request->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;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,12 @@ namespace App\Console\Commands;
|
||||
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 @@ class SyncBunny extends Command
|
||||
$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 @@ class SyncBunny extends Command
|
||||
$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 @@ class SyncBunny extends Command
|
||||
$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 @@ class SyncBunny extends Command
|
||||
}
|
||||
}
|
||||
|
||||
exec('rm -rf '.escapeshellarg($diffTmpDir));
|
||||
$this->removeTemporaryDirectory($diffTmpDir);
|
||||
|
||||
if (! $hasChanges) {
|
||||
$this->newLine();
|
||||
@@ -167,49 +390,55 @@ class SyncBunny extends Command
|
||||
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 @@ class SyncBunny extends Command
|
||||
$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());
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,9 +16,14 @@ class CloudProviderTokensController extends Controller
|
||||
{
|
||||
$token->makeHidden([
|
||||
'id',
|
||||
'token',
|
||||
]);
|
||||
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$token->makeVisible([
|
||||
'token',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($token);
|
||||
}
|
||||
|
||||
@@ -37,6 +42,9 @@ class CloudProviderTokensController extends Controller
|
||||
'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 @@ class CloudProviderTokensController extends Controller
|
||||
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 @@ class CloudProviderTokensController extends Controller
|
||||
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 @@ class CloudProviderTokensController extends Controller
|
||||
$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',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use App\Http\Controllers\Api\TagsController;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
trait HandlesTagsApi
|
||||
{
|
||||
/**
|
||||
* Find the taggable resource by UUID within the team.
|
||||
*/
|
||||
abstract protected function findTaggableResource(string $uuid, int|string $teamId): mixed;
|
||||
|
||||
/**
|
||||
* Get the 404 message for the taggable resource.
|
||||
*/
|
||||
abstract protected function tagResourceNotFoundMessage(): string;
|
||||
|
||||
public function listTags(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('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();
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use App\Models\ScheduledDatabaseBackup;
|
||||
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 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
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 @@ class DatabasesController extends Controller
|
||||
}
|
||||
$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 @@ class DatabasesController extends Controller
|
||||
|
||||
$this->authorize('view', $database);
|
||||
|
||||
return response()->json($this->removeSensitiveData($database));
|
||||
return response()->json($this->removeSensitiveData($database, loadNestedServerSecrets: true));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
@@ -1132,6 +1232,7 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
|
||||
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 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
}
|
||||
}
|
||||
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 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
'uuid' => $database->uuid,
|
||||
@@ -1829,7 +1949,7 @@ class DatabasesController extends Controller
|
||||
|
||||
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 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
@@ -1898,7 +2021,7 @@ class DatabasesController extends Controller
|
||||
|
||||
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 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
@@ -1967,7 +2093,7 @@ class DatabasesController extends Controller
|
||||
|
||||
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 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
@@ -2033,7 +2162,7 @@ class DatabasesController extends Controller
|
||||
|
||||
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 @@ class DatabasesController extends Controller
|
||||
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 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
@@ -2129,7 +2264,7 @@ class DatabasesController extends Controller
|
||||
|
||||
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 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
@@ -2175,7 +2313,7 @@ class DatabasesController extends Controller
|
||||
|
||||
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 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
@@ -2247,6 +2388,116 @@ class DatabasesController extends Controller
|
||||
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 @@ class DatabasesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
#[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 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
|
||||
$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 @@ class DatabasesController extends Controller
|
||||
'mount_path' => $storage->mount_path,
|
||||
]);
|
||||
|
||||
return response()->json($storage, 201);
|
||||
return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
@@ -4056,7 +4401,7 @@ class DatabasesController extends Controller
|
||||
'mount_path' => $storage->mount_path ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($storage);
|
||||
return response()->json($this->exposeFileStorageContentIfAllowed($storage));
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
@@ -4143,4 +4488,148 @@ class DatabasesController extends Controller
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ class DeployController extends Controller
|
||||
$deployment->makeHidden([
|
||||
'logs',
|
||||
]);
|
||||
} else {
|
||||
$deployment->makeVisible([
|
||||
'logs',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($deployment);
|
||||
@@ -365,7 +369,7 @@ class DeployController extends Controller
|
||||
|
||||
$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 @@ class DeployController extends Controller
|
||||
}
|
||||
['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 @@ class DeployController extends Controller
|
||||
}
|
||||
['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 @@ class DeployController extends Controller
|
||||
'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 @@ class DeployController extends Controller
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class DestinationsController extends Controller
|
||||
{
|
||||
private function transform(StandaloneDocker|SwarmDocker $destination): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $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.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\DigitalOceanService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class DigitalOceanController extends Controller
|
||||
{
|
||||
private function getCloudProviderTokenUuid(Request $request): ?string
|
||||
{
|
||||
return $request->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'];
|
||||
}
|
||||
}
|
||||
@@ -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 @@ class GithubController extends Controller
|
||||
'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 @@ class GithubController extends Controller
|
||||
'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 @@ class GithubController extends Controller
|
||||
'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 @@ class GithubController extends Controller
|
||||
|
||||
$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 @@ class GithubController extends Controller
|
||||
], 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)
|
||||
|
||||
@@ -460,6 +460,195 @@ class HetznerController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
#[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 @@ class HetznerController extends Controller
|
||||
'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 @@ class HetznerController extends Controller
|
||||
'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 @@ class HetznerController extends Controller
|
||||
'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 @@ class HetznerController extends Controller
|
||||
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 @@ class HetznerController extends Controller
|
||||
],
|
||||
];
|
||||
|
||||
$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 @@ class HetznerController extends Controller
|
||||
$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);
|
||||
|
||||
@@ -166,6 +166,9 @@ class ProjectController extends Controller
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ class ResourcesController extends Controller
|
||||
}
|
||||
$resources = $resources->flatten();
|
||||
$resources = $resources->map(function ($resource) {
|
||||
exposeSensitiveFields($resource);
|
||||
$payload = $resource->toArray();
|
||||
$payload['status'] = $resource->status;
|
||||
$payload['type'] = $resource->type();
|
||||
|
||||
@@ -16,6 +16,10 @@ class SecurityController extends Controller
|
||||
$team->makeHidden([
|
||||
'private_key',
|
||||
]);
|
||||
} else {
|
||||
$team->makeVisible([
|
||||
'private_key',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($team);
|
||||
|
||||
@@ -97,11 +97,6 @@ class SentinelController extends Controller
|
||||
|
||||
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 @@ class SentinelController extends Controller
|
||||
* 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 @@ class SentinelController extends Controller
|
||||
->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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@ class ServersController extends Controller
|
||||
$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 @@ class ServersController extends Controller
|
||||
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', [
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Service\DeployServiceApplication;
|
||||
use App\Actions\Service\RestartServiceApplication;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Actions\Service\UpdateServiceApplicationFromApi;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServiceApplicationsController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(ServiceApplication $serviceApplication): array
|
||||
{
|
||||
$serviceApplication->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);
|
||||
}
|
||||
}
|
||||
@@ -14,29 +14,57 @@ use App\Models\Project;
|
||||
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 @@ class ServicesController extends Controller
|
||||
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 @@ class ServicesController extends Controller
|
||||
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 @@ class ServicesController extends Controller
|
||||
}
|
||||
|
||||
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 @@ class ServicesController extends Controller
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$containerUrls = $containerUrls->filter(fn ($u) => filled($u))->unique()->implode(',');
|
||||
} else {
|
||||
$containerUrls = null;
|
||||
}
|
||||
@@ -181,8 +234,12 @@ class ServicesController extends Controller
|
||||
}
|
||||
$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 @@ class ServicesController extends Controller
|
||||
],
|
||||
'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 @@ class ServicesController extends Controller
|
||||
)]
|
||||
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 @@ class ServicesController extends Controller
|
||||
'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 @@ class ServicesController extends Controller
|
||||
], 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 @@ class ServicesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
@@ -506,7 +575,7 @@ class ServicesController extends Controller
|
||||
|
||||
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 @@ class ServicesController extends Controller
|
||||
'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 @@ class ServicesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
@@ -737,11 +812,135 @@ class ServicesController extends Controller
|
||||
|
||||
$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 @@ class ServicesController extends Controller
|
||||
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 @@ class ServicesController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$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 @@ class ServicesController extends Controller
|
||||
'mount_path' => $storage->mount_path,
|
||||
]);
|
||||
|
||||
return response()->json($storage, 201);
|
||||
return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
@@ -2513,7 +2807,7 @@ class ServicesController extends Controller
|
||||
'mount_path' => $storage->mount_path ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($storage);
|
||||
return response()->json($this->exposeFileStorageContentIfAllowed($storage));
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
@@ -2627,4 +2921,148 @@ class ServicesController extends Controller
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class TagsController extends Controller
|
||||
{
|
||||
public static function serializeTag(Tag $tag): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $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(...)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\VultrService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class VultrController extends Controller
|
||||
{
|
||||
private function getCloudProviderTokenUuid(Request $request): ?string
|
||||
{
|
||||
return $request->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);
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ class Controller extends BaseController
|
||||
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 @@ class Controller extends BaseController
|
||||
$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 @@ class Controller extends BaseController
|
||||
}
|
||||
$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 @@ class Controller extends BaseController
|
||||
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');
|
||||
|
||||
@@ -162,7 +162,7 @@ class Bitbucket extends Controller
|
||||
'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,
|
||||
]);
|
||||
|
||||
@@ -148,7 +148,7 @@ class Gitea extends Controller
|
||||
'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,
|
||||
]);
|
||||
|
||||
@@ -190,7 +190,7 @@ class Gitlab extends Controller
|
||||
'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,
|
||||
]);
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Http\Middleware\CheckForcePasswordReset;
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ class ApiSensitiveData
|
||||
{
|
||||
$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([
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureTeamMcpEnabled
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
|
||||
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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
|
||||
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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
// 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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
],
|
||||
);
|
||||
}
|
||||
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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
." -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 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
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 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
|
||||
$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 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
$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 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
|
||||
'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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
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 @@ class CleanupOrphanedPreviewContainersJob implements ShouldBeEncrypted, ShouldBe
|
||||
/**
|
||||
* 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 @@ class CleanupOrphanedPreviewContainersJob implements ShouldBeEncrypted, ShouldBe
|
||||
/**
|
||||
* 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([
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Models\Team;
|
||||
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 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$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 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private function getFullImageName(): string
|
||||
{
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$helperImage = coolifyHelperImage();
|
||||
$latestVersion = getHelperVersion();
|
||||
|
||||
return "{$helperImage}:{$latestVersion}";
|
||||
|
||||
@@ -96,7 +96,16 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
|
||||
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 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
// 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
|
||||
|
||||
@@ -311,6 +311,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->isCompleteSnapshot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->updateProxyStatus();
|
||||
|
||||
$this->updateNotFoundApplicationStatus();
|
||||
@@ -329,6 +333,11 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
$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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
$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';
|
||||
|
||||
@@ -457,7 +457,9 @@ class ScheduledJobManager implements ShouldQueue
|
||||
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
|
||||
|
||||
@@ -51,13 +51,24 @@ class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,15 +44,26 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
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 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*
|
||||
* @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708
|
||||
*/
|
||||
$this->sendToMattermost();
|
||||
$this->sendToMattermost($httpOptions);
|
||||
}
|
||||
|
||||
private function isSlackWebhook(): bool
|
||||
@@ -79,9 +90,12 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return $scheme === 'https' && $host === 'hooks.slack.com';
|
||||
}
|
||||
|
||||
private function sendToSlack(): void
|
||||
/**
|
||||
* @param array<string, mixed> $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 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
/**
|
||||
* @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type.
|
||||
*/
|
||||
private function sendToMattermost(): void
|
||||
/**
|
||||
* @param array<string, mixed> $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' => [
|
||||
[
|
||||
|
||||
+10
-14
@@ -50,28 +50,24 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ServerCloudProviderStatusCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
public $timeout = 120;
|
||||
|
||||
public function __construct(public Server $server)
|
||||
{
|
||||
$this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use App\Events\ServerReachabilityChanged;
|
||||
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 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$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 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
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 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
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 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -55,6 +55,13 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// 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 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
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;
|
||||
|
||||
@@ -251,7 +251,6 @@ class GlobalSearch extends Component
|
||||
$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 @@ class GlobalSearch extends Component
|
||||
'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 @@ class GlobalSearch extends Component
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -192,13 +192,6 @@ class Webhook extends Component
|
||||
$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 @@ class Webhook extends Component
|
||||
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) {
|
||||
|
||||
@@ -286,6 +286,7 @@ class Advanced extends Component
|
||||
$this->application->settings->save();
|
||||
|
||||
$this->dispatch('success', 'Stop grace period updated.');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -12,7 +12,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportEvents\Event;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class General extends Component
|
||||
{
|
||||
@@ -142,7 +141,8 @@ class General extends Component
|
||||
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 @@ class General extends Component
|
||||
if ($this->isContainerLabelReadonlyEnabled) {
|
||||
$this->resetDefaultLabels(false);
|
||||
}
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -771,16 +772,7 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Actions\Docker\GetContainersStatus;
|
||||
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 @@ class Previews extends Component
|
||||
});
|
||||
|
||||
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)) {
|
||||
|
||||
@@ -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 @@ class PreviewsCompose extends Component
|
||||
{
|
||||
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 @@ class PreviewsCompose extends Component
|
||||
$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();
|
||||
|
||||
@@ -147,6 +147,7 @@ class Source extends Component
|
||||
'source_id' => $source->id,
|
||||
'source_type' => $sourceType,
|
||||
]);
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
['repository' => $customRepository] = $this->application->customRepository();
|
||||
$repository = githubApi($this->application->source, "repos/{$customRepository}");
|
||||
|
||||
@@ -57,6 +57,7 @@ class Swarm extends Component
|
||||
$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 @@ class Swarm extends Component
|
||||
$this->authorize('update', $this->application);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Swarm settings updated.');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\StandaloneMongodb;
|
||||
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 @@ EOD;
|
||||
'bucket' => $s3Storage->bucket,
|
||||
'endpoint' => $s3Storage->endpoint,
|
||||
'use_path_style_endpoint' => true,
|
||||
'http' => SafeWebhookUrl::httpClientOptions($s3Storage->endpoint),
|
||||
]);
|
||||
|
||||
// Check if file exists
|
||||
@@ -678,7 +680,7 @@ EOD;
|
||||
}
|
||||
|
||||
// Get helper image
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$helperImage = coolifyHelperImage();
|
||||
$latestVersion = getHelperVersion();
|
||||
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||||
|
||||
|
||||
@@ -112,14 +112,17 @@ class Select extends Component
|
||||
$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 @@ class Select extends Component
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 @@ class EditDomain extends Component
|
||||
{
|
||||
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'));
|
||||
|
||||
@@ -8,11 +8,11 @@ use App\Models\Server;
|
||||
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 @@ class Index extends Component
|
||||
{
|
||||
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'));
|
||||
|
||||
@@ -76,6 +76,7 @@ class All extends Component
|
||||
$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 @@ class All extends Component
|
||||
|
||||
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 @@ class All extends Component
|
||||
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 @@ class All extends Component
|
||||
|
||||
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' ||
|
||||
|
||||
@@ -152,6 +152,7 @@ class HealthChecks extends Component
|
||||
$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 @@ class HealthChecks extends Component
|
||||
$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 @@ class HealthChecks extends Component
|
||||
} else {
|
||||
$this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.');
|
||||
}
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ class Logs extends Component
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log error but don't fail the entire operation
|
||||
ray("Error loading containers for server {$server->name}: ".$e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -91,9 +91,7 @@ class Tags extends Component
|
||||
$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) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Security\CloudInitScript;
|
||||
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public CloudInitScript $cloudInitScript;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $script = '';
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => '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');
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,13 @@ class CloudInitScripts extends Component
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Security\CloudProviderToken;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public CloudProviderToken $cloudProviderToken;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public ?string $description = null;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => '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');
|
||||
}
|
||||
}
|
||||
@@ -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 @@ class CloudProviderTokenForm extends Component
|
||||
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 @@ class CloudProviderTokenForm extends Component
|
||||
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 @@ class CloudProviderTokenForm extends Component
|
||||
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 @@ class CloudProviderTokenForm extends Component
|
||||
'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) {
|
||||
|
||||
@@ -55,6 +55,13 @@ class CloudProviderTokens extends Component
|
||||
} 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 @@ class CloudProviderTokens extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -43,22 +43,6 @@ class Create extends Component
|
||||
);
|
||||
}
|
||||
|
||||
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 @@ class Create extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Security\PrivateKey;
|
||||
|
||||
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 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ class Show extends Component
|
||||
{
|
||||
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');
|
||||
|
||||
@@ -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 @@ class Show extends Component
|
||||
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 @@ class Show extends Component
|
||||
'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 @@ class Show extends Component
|
||||
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 @@ class Show extends Component
|
||||
];
|
||||
}
|
||||
|
||||
// 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 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ class Show extends Component
|
||||
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', [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class CreatePage extends Component
|
||||
{
|
||||
public ?string $type = null;
|
||||
|
||||
public ?string $token_uuid = null;
|
||||
|
||||
public string $title = 'New Server';
|
||||
|
||||
public ?string $tokenProvider = null;
|
||||
|
||||
public ?string $tokenProviderName = null;
|
||||
|
||||
public bool $hasProviderTokens = false;
|
||||
|
||||
public function mount(?string $type = null, ?string $token_uuid = null): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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 @@ class Delete extends Component
|
||||
|
||||
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 @@ class Delete extends Component
|
||||
$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 @@ class Delete extends Component
|
||||
];
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
|
||||
@@ -39,6 +39,7 @@ class Navbar extends Component
|
||||
return [
|
||||
'refreshServerShow' => 'refreshServer',
|
||||
"echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification',
|
||||
"echo-private:team.{$teamId},SentinelRestarted" => 'refreshSentinelStatus',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -203,6 +204,15 @@ class Navbar extends Component
|
||||
$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.
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server\New;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\DigitalOceanService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class ByDigitalOcean extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public int $current_step = 1;
|
||||
|
||||
#[Locked]
|
||||
public Collection $available_tokens;
|
||||
|
||||
#[Locked]
|
||||
public $private_keys;
|
||||
|
||||
#[Locked]
|
||||
public $limit_reached;
|
||||
|
||||
public ?int $selected_token_id = null;
|
||||
|
||||
public ?string $selectedTokenUuid = null;
|
||||
|
||||
public array $regions = [];
|
||||
|
||||
public array $images = [];
|
||||
|
||||
public array $sizes = [];
|
||||
|
||||
public array $digitalOceanSshKeys = [];
|
||||
|
||||
public ?string $selected_region = null;
|
||||
|
||||
public string|int|null $selected_image = null;
|
||||
|
||||
public ?string $selected_size = null;
|
||||
|
||||
public array $selectedDigitalOceanSshKeyIds = [];
|
||||
|
||||
public string $server_name = '';
|
||||
|
||||
public ?int $private_key_id = null;
|
||||
|
||||
public bool $loading_data = false;
|
||||
|
||||
public ?string $provider_data_error = null;
|
||||
|
||||
public bool $enable_ipv6 = true;
|
||||
|
||||
public bool $monitoring = true;
|
||||
|
||||
public bool $show_cloud_init_script = false;
|
||||
|
||||
public ?string $cloud_init_script = null;
|
||||
|
||||
public bool $save_cloud_init_script = false;
|
||||
|
||||
public ?string $cloud_init_script_name = null;
|
||||
|
||||
public ?int $selected_cloud_init_script_id = null;
|
||||
|
||||
#[Locked]
|
||||
public Collection $saved_cloud_init_scripts;
|
||||
|
||||
public bool $from_onboarding = false;
|
||||
|
||||
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();
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Rules\ValidCloudInitYaml;
|
||||
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 @@ class ByHetzner extends Component
|
||||
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 @@ class ByHetzner extends Component
|
||||
public function getListeners()
|
||||
{
|
||||
return [
|
||||
'tokenAdded' => 'handleTokenAdded',
|
||||
'tokenAdded.hetzner' => 'handleTokenAdded',
|
||||
'privateKeyCreated' => 'handlePrivateKeyCreated',
|
||||
'modalClosed' => 'resetSelection',
|
||||
];
|
||||
@@ -112,10 +135,15 @@ class ByHetzner extends Component
|
||||
{
|
||||
$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 @@ class ByHetzner extends Component
|
||||
'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 @@ class ByHetzner extends Component
|
||||
];
|
||||
}
|
||||
|
||||
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 @@ class ByHetzner extends Component
|
||||
]);
|
||||
|
||||
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 @@ class ByHetzner extends Component
|
||||
|
||||
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 @@ class ByHetzner extends Component
|
||||
->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 @@ class ByHetzner extends Component
|
||||
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 @@ class ByHetzner extends Component
|
||||
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 @@ class ByHetzner extends Component
|
||||
$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 @@ class ByHetzner extends Component
|
||||
$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 @@ class ByHetzner extends Component
|
||||
],
|
||||
];
|
||||
|
||||
$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 @@ class ByHetzner extends Component
|
||||
{
|
||||
$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 @@ class ByHetzner extends Component
|
||||
}
|
||||
|
||||
$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 @@ class ByHetzner extends Component
|
||||
$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([
|
||||
|
||||
@@ -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 @@ class ByIp extends Component
|
||||
]);
|
||||
}
|
||||
|
||||
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!');
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server\New;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\VultrService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class ByVultr extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public int $current_step = 1;
|
||||
|
||||
#[Locked]
|
||||
public Collection $available_tokens;
|
||||
|
||||
#[Locked]
|
||||
public $private_keys;
|
||||
|
||||
#[Locked]
|
||||
public $limit_reached;
|
||||
|
||||
public ?int $selected_token_id = null;
|
||||
|
||||
public ?string $selectedTokenUuid = null;
|
||||
|
||||
public array $regions = [];
|
||||
|
||||
public array $plans = [];
|
||||
|
||||
public array $operatingSystems = [];
|
||||
|
||||
public array $vultrSshKeys = [];
|
||||
|
||||
public ?string $selected_region = null;
|
||||
|
||||
public ?string $selected_plan = null;
|
||||
|
||||
public ?int $selected_os_id = null;
|
||||
|
||||
public array $selectedVultrSshKeyIds = [];
|
||||
|
||||
public string $server_name = '';
|
||||
|
||||
public ?int $private_key_id = null;
|
||||
|
||||
public bool $loading_data = false;
|
||||
|
||||
public ?string $provider_data_error = null;
|
||||
|
||||
public bool $enable_ipv6 = true;
|
||||
|
||||
public bool $disable_public_ipv4 = false;
|
||||
|
||||
public ?string $cloud_init_script = null;
|
||||
|
||||
public bool $save_cloud_init_script = false;
|
||||
|
||||
public ?string $cloud_init_script_name = null;
|
||||
|
||||
public ?int $selected_cloud_init_script_id = null;
|
||||
|
||||
#[Locked]
|
||||
public Collection $saved_cloud_init_scripts;
|
||||
|
||||
public bool $from_onboarding = false;
|
||||
|
||||
public function mount(?string $selectedTokenUuid = null): void
|
||||
{
|
||||
$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();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,33 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -8,7 +8,9 @@ use App\Events\ServerReachabilityChanged;
|
||||
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 @@ class Show extends Component
|
||||
}
|
||||
// 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 @@ class Show extends Component
|
||||
{
|
||||
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 @@ class Show extends Component
|
||||
$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 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ class Show extends Component
|
||||
|
||||
// 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 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ class Show extends Component
|
||||
->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 @@ class Show extends Component
|
||||
$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);
|
||||
|
||||
@@ -92,6 +92,38 @@ class ValidateAndInstall extends Component
|
||||
{
|
||||
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');
|
||||
|
||||
@@ -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 @@ class Advanced extends Component
|
||||
'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 @@ class Advanced extends Component
|
||||
$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 @@ class Advanced extends Component
|
||||
$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<int, string>|null $webhookAllowedInternalHosts
|
||||
*/
|
||||
public function instantSave(?array $webhookAllowedInternalHosts = null)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
@@ -167,6 +184,8 @@ class Advanced extends Component
|
||||
$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 @@ class Advanced extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>|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)) {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Jobs\CheckForUpdatesJob;
|
||||
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 @@ class Updates extends Component
|
||||
$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 @@ class Updates extends Component
|
||||
'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 @@ class Updates extends Component
|
||||
if ($this->server) {
|
||||
$this->server->setupDynamicProxyConfiguration();
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,8 @@ use App\Models\PrivateKey;
|
||||
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 @@ class Change extends Component
|
||||
];
|
||||
}
|
||||
|
||||
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 @@ class Change extends Component
|
||||
{
|
||||
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 @@ class Change extends Component
|
||||
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 @@ class Change extends Component
|
||||
|
||||
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 @@ class Change extends Component
|
||||
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 @@ class Change extends Component
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Source\Github;
|
||||
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 @@ class Create extends Component
|
||||
}
|
||||
|
||||
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -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 @@ class Index extends Component
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ class InviteLink extends Component
|
||||
$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 @@ class InviteLink extends Component
|
||||
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 @@ class InviteLink extends Component
|
||||
'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)) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user