Merge remote-tracking branch 'origin/next' into 2731-investigate-failed-git-clone

This commit is contained in:
Andras Bacsai 2026-06-01 10:40:02 +02:00
commit bc8928fdc4
583 changed files with 30566 additions and 11158 deletions

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,18 @@ DB_PASSWORD=password
DB_HOST=host.docker.internal DB_HOST=host.docker.internal
DB_PORT=5432 DB_PORT=5432
# Read/write replicas (optional). Set DB_READ_HOST to enable the read/write split.
# Hosts may be comma-separated. Port/username/password fall back to DB_* when unset.
# DB_READ_HOST=replica1,replica2
# DB_READ_PORT=5432
# DB_READ_USERNAME=coolify
# DB_READ_PASSWORD=
# DB_WRITE_HOST=
# DB_WRITE_PORT=5432
# DB_WRITE_USERNAME=coolify
# DB_WRITE_PASSWORD=
# DB_STICKY=true
# Ray Configuration # Ray Configuration
# Set to true to enable Ray # Set to true to enable Ray
RAY_ENABLED=false RAY_ENABLED=false

View file

@ -9,9 +9,6 @@ body:
> [!IMPORTANT] > [!IMPORTANT]
> **Please ensure you are using the latest version of Coolify before submitting an issue, as the bug may have already been fixed in a recent update.** (Of course, if you're experiencing an issue on the latest version that wasn't present in a previous version, please let us know.) > **Please ensure you are using the latest version of Coolify before submitting an issue, as the bug may have already been fixed in a recent update.** (Of course, if you're experiencing an issue on the latest version that wasn't present in a previous version, please let us know.)
# 💎 Bounty Program (with [algora.io](https://console.algora.io/org/coollabsio/bounties/new))
- If you would like to prioritize the issue resolution, consider adding a bounty to this issue through our [Bounty Program](https://console.algora.io/org/coollabsio/bounties/new).
- type: textarea - type: textarea
attributes: attributes:
label: Error Message and Logs label: Error Message and Logs

View file

@ -1,31 +0,0 @@
name: 💎 Enhancement Bounty
description: "Propose a new feature, service, or improvement with an attached bounty."
title: "[Enhancement]: "
labels: ["✨ Enhancement", "🔍 Triage"]
body:
- type: markdown
attributes:
value: |
> [!IMPORTANT]
> **This issue template is exclusively for proposing new features, services, or improvements with an attached bounty.** Enhancements without a bounty can be discussed in the appropriate category of [Github Discussions](https://github.com/coollabsio/coolify/discussions).
# 💎 Add a Bounty (with [algora.io](https://console.algora.io/org/coollabsio/bounties/new))
- [Click here to add the required bounty](https://console.algora.io/org/coollabsio/bounties/new)
- type: dropdown
attributes:
label: Request Type
description: Select the type of request you are making.
options:
- New Feature
- New Service
- Improvement
validations:
required: true
- type: textarea
attributes:
label: Description
description: Provide a detailed description of the feature, improvement, or service you are proposing.
validations:
required: true

View file

@ -22,7 +22,7 @@ ## Category
## Preview ## Preview
<!-- Screenshot or short video showing your changes in action. Mandatory for bounty claims and new features. --> <!-- Screenshot or short video showing your changes in action. Mandatory for new features. -->
## AI Assistance ## AI Assistance

View file

@ -1,3 +1,7 @@
## Design Reference
For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo.
<laravel-boost-guidelines> <laravel-boost-guidelines>
=== foundation rules === === foundation rules ===

View file

@ -6,6 +6,10 @@ ## Project Overview
Coolify is an open-source, self-hostable PaaS (alternative to Heroku/Netlify/Vercel). It manages servers, applications, databases, and services via SSH. Built with Laravel 12 (using Laravel 10 file structure), Livewire 3, and Tailwind CSS v4. Coolify is an open-source, self-hostable PaaS (alternative to Heroku/Netlify/Vercel). It manages servers, applications, databases, and services via SSH. Built with Laravel 12 (using Laravel 10 file structure), Livewire 3, and Tailwind CSS v4.
## Design Reference
For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo.
## Development Environment ## Development Environment
Docker Compose-based dev setup with services: coolify (app), postgres, redis, soketi (WebSockets), vite, testing-host, mailpit, minio. Docker Compose-based dev setup with services: coolify (app), postgres, redis, soketi (WebSockets), vite, testing-host, mailpit, minio.

View file

@ -212,7 +212,7 @@ #### Review Process
- Duplicate or superseded work - Duplicate or superseded work
- Security or quality concerns - Security or quality concerns
#### Code Quality, Testing, and Bounty Submissions #### Code Quality and Testing
All contributions must adhere to the highest standards of 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. - **Testing Required**: Every PR must include steps to test your changes. Untested code will not be reviewed or merged.
@ -220,15 +220,6 @@ #### Code Quality, Testing, and Bounty Submissions
- **Code Standards**: Follow the existing code style, conventions, and patterns in the codebase. - **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. - **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.
**For PRs that claim bounties:**
- **Eligibility**: Bounty PRs must strictly follow all guidelines above. Untested, poorly described, or non-compliant PRs will not qualify for bounty rewards.
- **Original Work**: Bounties are for genuine contributions. Submitting AI-generated or copied code solely for bounty claims will result in disqualification and potential removal from contributing.
- **Quality Standards**: Bounty submissions are held to even higher standards. Ensure comprehensive testing, clear documentation, and alignment with project goals. When maintainers review the changes, they should work as expected (the things mentioned in the PR description plus what the bounty issuer needs).
- **Claim Process**: Only successfully merged PRs that pass all reviews (core maintainers + bounty issuer) and meet bounty criteria will be awarded. Follow the issue's bounty guidelines precisely.
- **Prioritization**: Contributor PRs are prioritized over first-time or new contributors.
- **Developer Experience**: We highly advise beginners to avoid participating in bug bounties for our codebase. Most of the time, they don't know what they are changing, how it affects other parts of the system, or if their changes are even correct.
- **Review Comments**: When maintainers ask questions, you should be able to respond properly without generic or AI-generated fluff.
## Development Notes ## Development Notes

View file

@ -4,7 +4,7 @@ # Coolify
An open-source & self-hostable Heroku / Netlify / Vercel alternative. An open-source & self-hostable Heroku / Netlify / Vercel alternative.
![Latest Release Version](https://img.shields.io/badge/dynamic/json?labelColor=grey&color=6366f1&label=Latest%20released%20version&url=https%3A%2F%2Fcdn.coollabs.io%2Fcoolify%2Fversions.json&query=coolify.v4.version&style=for-the-badge ![Latest Release Version](https://img.shields.io/badge/dynamic/json?labelColor=grey&color=6366f1&label=Latest%20released%20version&url=https%3A%2F%2Fcdn.coollabs.io%2Fcoolify%2Fversions.json&query=coolify.v4.version&style=for-the-badge
) [![Bounty Issues](https://img.shields.io/static/v1?labelColor=grey&color=6366f1&label=Algora&message=%F0%9F%92%8E+Bounty+issues&style=for-the-badge)](https://console.algora.io/org/coollabsio/bounties/new) )
</div> </div>
## About the Project ## About the Project
@ -59,24 +59,23 @@ ### Huge Sponsors
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [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 * [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 * [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
### Big Sponsors ### Big Sponsors
* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions! * [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions!
* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform
* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services * [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 * [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 * [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform * [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain * [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 * [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 * [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [COMIT](https://comit.international?ref=coolify.io) - New York Times awardwinning contractor * [COMIT](https://comit.international?ref=coolify.io) - New York Times awardwinning contractor
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform * [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 * [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design * [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. * [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 * [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
@ -88,6 +87,7 @@ ### Big Sponsors
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency * [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions * [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers * [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 * [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 * [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 * [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions
@ -152,6 +152,10 @@ ### Small Sponsors
<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://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://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://transcript.lol/?utm_source=coolify.io"><img width="60px" alt="Transcript LOL" src="https://transcript.lol/logo.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>
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)

View file

@ -36,10 +36,11 @@ public function handle(Application $application, bool $previewDeployments = fals
: getCurrentApplicationContainerStatus($server, $application->id, 0); : getCurrentApplicationContainerStatus($server, $application->id, 0);
$containersToStop = $containers->pluck('Names')->toArray(); $containersToStop = $containers->pluck('Names')->toArray();
$timeout = $application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) { foreach ($containersToStop as $containerName) {
instant_remote_process(command: [ instant_remote_process(command: [
"docker stop -t 30 $containerName", "docker stop --time=$timeout $containerName",
"docker rm -f $containerName", "docker rm -f $containerName",
], server: $server, throwError: false); ], server: $server, throwError: false);
} }

View file

@ -20,13 +20,15 @@ public function handle(Application $application, Server $server)
} }
try { try {
$containers = getCurrentApplicationContainerStatus($server, $application->id, 0); $containers = getCurrentApplicationContainerStatus($server, $application->id, 0);
$timeout = $application->settings->stopGracePeriodSeconds();
if ($containers->count() > 0) { if ($containers->count() > 0) {
foreach ($containers as $container) { foreach ($containers as $container) {
$containerName = data_get($container, 'Names'); $containerName = data_get($container, 'Names');
if ($containerName) { if ($containerName) {
instant_remote_process( instant_remote_process(
[ [
"docker stop -t 30 $containerName", "docker stop --time=$timeout $containerName",
"docker rm -f $containerName", "docker rm -f $containerName",
], ],
$server $server

View file

@ -50,13 +50,9 @@ public function handle(StandaloneClickhouse $database)
], ],
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'", 'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -98,6 +94,9 @@ public function handle(StandaloneClickhouse $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -11,12 +11,16 @@
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
class StartDatabase class StartDatabase
{ {
use AsAction; use AsAction;
public string $jobQueue = 'high'; public function configureJob(JobDecorator $job): void
{
$job->onQueue(deployment_queue());
}
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database) public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
{ {
@ -25,28 +29,28 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
return 'Server is not functional'; return 'Server is not functional';
} }
switch ($database->getMorphClass()) { switch ($database->getMorphClass()) {
case \App\Models\StandalonePostgresql::class: case StandalonePostgresql::class:
$activity = StartPostgresql::run($database); $activity = StartPostgresql::run($database);
break; break;
case \App\Models\StandaloneRedis::class: case StandaloneRedis::class:
$activity = StartRedis::run($database); $activity = StartRedis::run($database);
break; break;
case \App\Models\StandaloneMongodb::class: case StandaloneMongodb::class:
$activity = StartMongodb::run($database); $activity = StartMongodb::run($database);
break; break;
case \App\Models\StandaloneMysql::class: case StandaloneMysql::class:
$activity = StartMysql::run($database); $activity = StartMysql::run($database);
break; break;
case \App\Models\StandaloneMariadb::class: case StandaloneMariadb::class:
$activity = StartMariadb::run($database); $activity = StartMariadb::run($database);
break; break;
case \App\Models\StandaloneKeydb::class: case StandaloneKeydb::class:
$activity = StartKeydb::run($database); $activity = StartKeydb::run($database);
break; break;
case \App\Models\StandaloneDragonfly::class: case StandaloneDragonfly::class:
$activity = StartDragonfly::run($database); $activity = StartDragonfly::run($database);
break; break;
case \App\Models\StandaloneClickhouse::class: case StandaloneClickhouse::class:
$activity = StartClickhouse::run($database); $activity = StartClickhouse::run($database);
break; break;
} }

View file

@ -11,14 +11,19 @@
use App\Models\StandaloneMysql; use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use App\Notifications\Container\ContainerRestarted;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
class StartDatabaseProxy class StartDatabaseProxy
{ {
use AsAction; use AsAction;
public string $jobQueue = 'high'; public function configureJob(JobDecorator $job): void
{
$job->onQueue(deployment_queue());
}
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database) public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database)
{ {
@ -29,7 +34,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
$proxyContainerName = "{$database->uuid}-proxy"; $proxyContainerName = "{$database->uuid}-proxy";
$isSSLEnabled = $database->enable_ssl ?? false; $isSSLEnabled = $database->enable_ssl ?? false;
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { if ($database->getMorphClass() === ServiceDatabase::class) {
$databaseType = $database->databaseType(); $databaseType = $database->databaseType();
$network = $database->service->uuid; $network = $database->service->uuid;
$server = data_get($database, 'service.destination.server'); $server = data_get($database, 'service.destination.server');
@ -132,7 +137,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
?? data_get($database, 'service.environment.project.team'); ?? data_get($database, 'service.environment.project.team');
$team?->notify( $team?->notify(
new \App\Notifications\Container\ContainerRestarted( new ContainerRestarted(
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}", "TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
$server, $server,
) )

View file

@ -106,13 +106,9 @@ public function handle(StandaloneDragonfly $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => "redis-cli -a {$this->database->dragonfly_password} ping", 'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -182,6 +178,9 @@ public function handle(StandaloneDragonfly $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -108,13 +108,9 @@ public function handle(StandaloneKeydb $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => "keydb-cli --pass {$this->database->keydb_password} ping", 'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -166,7 +162,7 @@ public function handle(StandaloneKeydb $database)
$docker_compose['volumes'] = $volume_names; $docker_compose['volumes'] = $volume_names;
} }
if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) { if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [], $docker_compose['services'][$container_name]['volumes'] ?? [],
[ [
@ -197,6 +193,9 @@ public function handle(StandaloneKeydb $database)
// Add custom docker run options // Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -103,13 +103,9 @@ public function handle(StandaloneMariadb $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'], 'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -175,7 +171,7 @@ public function handle(StandaloneMariadb $database)
); );
} }
if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) { if (! is_null($this->database->mariadb_conf) && ! empty($this->database->mariadb_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'], $docker_compose['services'][$container_name]['volumes'],
[ [
@ -202,6 +198,9 @@ public function handle(StandaloneMariadb $database)
]; ];
} }
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -109,17 +109,11 @@ public function handle(StandaloneMongodb $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => [ 'CMD',
'CMD', 'echo',
'echo', 'ok',
'ok', ]),
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -253,6 +247,9 @@ public function handle(StandaloneMongodb $database)
$docker_compose['services'][$container_name]['command'] = $commandParts; $docker_compose['services'][$container_name]['command'] = $commandParts;
} }
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@ -340,7 +337,10 @@ private function add_custom_mongo_conf()
private function add_default_database() private function add_default_database()
{ {
$content = "db = db.getSiblingDB(\"{$this->database->mongo_initdb_database}\");db.createCollection('init_collection');db.createUser({user: \"{$this->database->mongo_initdb_root_username}\", pwd: \"{$this->database->mongo_initdb_root_password}\",roles: [{role:\"readWrite\",db:\"{$this->database->mongo_initdb_database}\"}]});"; $dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES);
$userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES);
$pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES);
$content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});";
$content_base64 = base64_encode($content); $content_base64 = base64_encode($content);
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d"; $this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null"; $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null";

View file

@ -103,13 +103,9 @@ public function handle(StandaloneMysql $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"], 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -175,7 +171,7 @@ public function handle(StandaloneMysql $database)
); );
} }
if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) { if (! is_null($this->database->mysql_conf) && ! empty($this->database->mysql_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [], $docker_compose['services'][$container_name]['volumes'] ?? [],
[ [
@ -203,6 +199,9 @@ public function handle(StandaloneMysql $database)
]; ];
} }
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@ -215,7 +214,8 @@ public function handle(StandaloneMysql $database)
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) { if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->mysql_user}:{$this->database->mysql_user} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key"); $mysqlUser = escapeshellarg($this->database->mysql_user);
$this->commands[] = executeInDocker($this->database->uuid, "chown {$mysqlUser}:{$mysqlUser} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
} }
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";

View file

@ -110,16 +110,9 @@ public function handle(StandalonePostgresql $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => [ 'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
'CMD-SHELL', ]),
"psql -U {$this->database->postgres_user} -d {$this->database->postgres_db} -c 'SELECT 1' || exit 1",
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -216,6 +209,9 @@ public function handle(StandalonePostgresql $database)
$docker_compose['services'][$container_name]['command'] = $command; $docker_compose['services'][$container_name]['command'] = $command;
} }
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@ -227,7 +223,8 @@ public function handle(StandalonePostgresql $database)
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) { if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"); $postgresUser = escapeshellarg($this->database->postgres_user);
$this->commands[] = executeInDocker($this->database->uuid, "chown {$postgresUser}:{$postgresUser} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
} }
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";
@ -304,9 +301,18 @@ private function generate_init_scripts()
foreach ($this->database->init_scripts as $init_script) { foreach ($this->database->init_scripts as $init_script) {
$filename = data_get($init_script, 'filename'); $filename = data_get($init_script, 'filename');
$content = data_get($init_script, 'content'); $content = data_get($init_script, 'content');
// Normalise filename without rejecting legacy values so previously created
// init scripts keep deploying. basename() strips any directory components
// (path traversal) and escapeshellarg() contains every shell metacharacter
// in the tee target. Livewire / API validate new filenames up front.
$filename = basename((string) $filename);
$target_path = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
$escaped_target = escapeshellarg($target_path);
$content_base64 = base64_encode($content); $content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/{$filename} > /dev/null"; $this->commands[] = "echo '{$content_base64}' | base64 -d | tee {$escaped_target} > /dev/null";
$this->init_scripts[] = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}"; $this->init_scripts[] = $target_path;
} }
} }

View file

@ -105,17 +105,11 @@ public function handle(StandaloneRedis $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => [ 'CMD-SHELL',
'CMD-SHELL', 'redis-cli',
'redis-cli', 'ping',
'ping', ]),
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -181,7 +175,7 @@ public function handle(StandaloneRedis $database)
); );
} }
if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) { if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
$docker_compose['services'][$container_name]['volumes'][] = [ $docker_compose['services'][$container_name]['volumes'][] = [
'type' => 'bind', 'type' => 'bind',
'source' => $this->configuration_dir.'/redis.conf', 'source' => $this->configuration_dir.'/redis.conf',
@ -194,6 +188,9 @@ public function handle(StandaloneRedis $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -2,6 +2,7 @@
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@ -44,7 +45,10 @@ public function create(array $input): User
'password' => Hash::make($input['password']), 'password' => Hash::make($input['password']),
]); ]);
$user->save(); $user->save();
$team = $user->teams()->first(); $team = $user->teams()->first() ?? Team::find(0);
if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) {
$user->teams()->attach($team, ['role' => 'owner']);
}
// Disable registration after first user is created // Disable registration after first user is created
$settings = instanceSettings(); $settings = instanceSettings();

View file

@ -48,9 +48,10 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
); );
$commands = [ $commands = [
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd, $imagePruneCmd,
'docker builder prune -af', 'docker builder prune -af',
'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true',
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",

View file

@ -1,41 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Application;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
class ResourcesCheck
{
use AsAction;
public function handle()
{
$seconds = 60;
try {
Application::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
ServiceApplication::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
ServiceDatabase::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandalonePostgresql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneRedis::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneMongodb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneMysql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneMariadb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneKeydb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneDragonfly::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneClickhouse::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
} catch (\Throwable $e) {
return handleError($e);
}
}
}

View file

@ -4,7 +4,6 @@
use App\Events\SentinelRestarted; use App\Events\SentinelRestarted;
use App\Models\Server; use App\Models\Server;
use App\Models\ServerSetting;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
class StartSentinel class StartSentinel
@ -23,10 +22,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
$metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days'); $metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days');
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds'); $refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds'); $pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
$token = data_get($server, 'settings.sentinel_token'); $token = $server->settings->ensureValidSentinelToken();
if (! ServerSetting::isValidSentinelToken($token)) {
throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
}
$endpoint = data_get($server, 'settings.sentinel_custom_url'); $endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled'); $debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel'; $mountDir = '/data/coolify/sentinel';

View file

@ -13,8 +13,10 @@ class RestartService
public function handle(Service $service, bool $pullLatestImages) public function handle(Service $service, bool $pullLatestImages)
{ {
StopService::run($service); return StartService::run(
service: $service,
return StartService::run($service, $pullLatestImages); pullLatestImages: $pullLatestImages,
stopBeforeStart: true,
);
} }
} }

View file

@ -4,18 +4,22 @@
use App\Models\Service; use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
class StartService class StartService
{ {
use AsAction; use AsAction;
public string $jobQueue = 'high'; public function configureJob(JobDecorator $job): void
{
$job->onQueue(deployment_queue());
}
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
{ {
$service->parse(); $service->parse();
if ($stopBeforeStart) { if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) {
StopService::run(service: $service, dockerCleanup: false); StopService::run(service: $service, dockerCleanup: false);
} }
$service->saveComposeConfigs(); $service->saveComposeConfigs();
@ -49,4 +53,9 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
} }
private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool
{
return $stopBeforeStart && ! $pullLatestImages;
}
} }

View file

@ -18,9 +18,13 @@ public function handle()
if ($servers->count() > 0) { if ($servers->count() > 0) {
foreach ($servers as $server) { foreach ($servers as $server) {
echo "Cleanup unreachable server ($server->id) with name $server->name"; echo "Cleanup unreachable server ($server->id) with name $server->name";
$server->update([ if (isCloud()) {
'ip' => '1.2.3.4', $server->update([
]); 'ip' => '1.2.3.4',
]);
} else {
$server->forceDisableServer();
}
} }
} }
} }

View file

@ -88,6 +88,14 @@ private function processFile(string $file): false|array
$payload['envs'] = base64_encode($envFileContent); $payload['envs'] = base64_encode($envFileContent);
} }
if (str($data->get('amd_only'))->toBoolean()) {
$payload['amd_only'] = true;
}
if (str($data->get('arm_only'))->toBoolean()) {
$payload['arm_only'] = true;
}
return $payload; return $payload;
} }
@ -160,6 +168,14 @@ private function processFileWithFqdn(string $file): false|array
$payload['envs'] = base64_encode($modifiedEnvContent); $payload['envs'] = base64_encode($modifiedEnvContent);
} }
if (str($data->get('amd_only'))->toBoolean()) {
$payload['amd_only'] = true;
}
if (str($data->get('arm_only'))->toBoolean()) {
$payload['arm_only'] = true;
}
return $payload; return $payload;
} }
@ -229,6 +245,14 @@ private function processFileWithFqdnRaw(string $file): false|array
$payload['envs'] = $modifiedEnvContent; $payload['envs'] = $modifiedEnvContent;
} }
if (str($data->get('amd_only'))->toBoolean()) {
$payload['amd_only'] = true;
}
if (str($data->get('arm_only'))->toBoolean()) {
$payload['arm_only'] = true;
}
return $payload; return $payload;
} }
} }

View file

@ -16,7 +16,7 @@ class SyncBunny extends Command
* *
* @var string * @var string
*/ */
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}'; protected $signature = 'sync:bunny {--templates} {--release} {--nightly}';
/** /**
* The console command description. * The console command description.
@ -25,650 +25,6 @@ class SyncBunny extends Command
*/ */
protected $description = 'Sync files to BunnyCDN'; protected $description = 'Sync files to BunnyCDN';
/**
* Fetch GitHub releases and sync to GitHub repository
*/
private function syncReleasesToGitHubRepo(): 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;
}
$releases = $response->json();
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp;
$branchName = 'update-releases-'.$timestamp;
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$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));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
$releasesDir = dirname($releasesPath);
// Ensure directory exists
if (! is_dir($releasesDir)) {
$this->info("Creating directory: $releasesDir");
if (! mkdir($releasesDir, 0755, true)) {
$this->error("Failed to create directory: $releasesDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write releases.json to: $releasesPath");
$this->error('Possible reasons: permission denied or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Stage and commit
$this->info('Committing changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('Releases are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
$commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$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));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR Output: '.implode("\n", $output));
}
$this->info('Total releases synced: '.count($releases));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing releases: '.$e->getMessage());
return false;
}
}
/**
* Sync both releases.json and versions.json to GitHub repository in one PR
*/
private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
{
$this->info('Syncing releases.json and versions.json to GitHub repository...');
try {
// 1. Fetch releases from GitHub API
$this->info('Fetching releases from GitHub API...');
$response = Http::timeout(30)
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
'per_page' => 30,
]);
if (! $response->successful()) {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
$releases = $response->json();
// 2. Read versions.json
if (! file_exists($versionsLocation)) {
$this->error("versions.json not found at: $versionsLocation");
return false;
}
$file = file_get_contents($versionsLocation);
$versionsJson = json_decode($file, true);
$actualVersion = data_get($versionsJson, 'coolify.v4.version');
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
$branchName = 'update-releases-and-versions-'.$timestamp;
$versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
// 3. Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// 4. 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));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 5. Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
$releasesDir = dirname($releasesPath);
if (! is_dir($releasesDir)) {
if (! mkdir($releasesDir, 0755, true)) {
$this->error("Failed to create directory: $releasesDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
$this->error("Failed to write releases.json to: $releasesPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 6. Write versions.json
$this->info('Writing versions.json...');
$versionsPath = "$tmpDir/$versionsTargetPath";
$versionsDir = dirname($versionsPath);
if (! is_dir($versionsDir)) {
if (! mkdir($versionsDir, 0755, true)) {
$this->error("Failed to create directory: $versionsDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
$this->error("Failed to write versions.json to: $versionsPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 7. Stage both files
$this->info('Staging changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 8. Check for changes
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('Both files are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
// 9. Commit changes
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 10. 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));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 11. Create pull request
$this->info('Creating pull request...');
$prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// 12. Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info("Version synced: $actualVersion");
$this->info('Total releases synced: '.count($releases));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing to GitHub: '.$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().'/coolify-cdn-files-'.$timestamp;
$branchName = 'update-files-'.$timestamp;
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$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));
exec('rm -rf '.escapeshellarg($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");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
if (copy($sourceFile, $destPath) === false) {
$this->error("Failed to copy $sourceFile to $destPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$copiedFiles[] = $targetPath;
$this->info("Copied: $targetPath");
}
if (empty($copiedFiles)) {
$this->warn('No files were copied. Nothing to commit.');
exec('rm -rf '.escapeshellarg($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));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Check for changes
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('All files are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($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));
exec('rm -rf '.escapeshellarg($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));
exec('rm -rf '.escapeshellarg($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- ", $copiedFiles);
$prBody = "Automated update of $envLabel files:\n- $fileList";
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info('Files synced: '.count($copiedFiles));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing files to GitHub: '.$e->getMessage());
return false;
}
}
/**
* Sync versions.json to GitHub repository via PR
*/
private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
{
$this->info('Syncing versions.json to GitHub repository...');
try {
if (! file_exists($versionsLocation)) {
$this->error("versions.json not found at: $versionsLocation");
return false;
}
$file = file_get_contents($versionsLocation);
$json = json_decode($file, true);
$actualVersion = data_get($json, 'coolify.v4.version');
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
$branchName = 'update-versions-'.$timestamp;
$targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$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));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Write versions.json
$this->info('Writing versions.json...');
$versionsPath = "$tmpDir/$targetPath";
$versionsDir = dirname($versionsPath);
// Ensure directory exists
if (! is_dir($versionsDir)) {
$this->info("Creating directory: $versionsDir");
if (! mkdir($versionsDir, 0755, true)) {
$this->error("Failed to create directory: $versionsDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($versionsPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write versions.json to: $versionsPath");
$this->error('Possible reasons: permission denied or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Stage and commit
$this->info('Committing changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('versions.json is already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$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));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$prBody = "Automated update of $envLabel versions.json to version $actualVersion";
$output = [];
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info("Version synced: $actualVersion");
return true;
} catch (\Throwable $e) {
$this->error('Error syncing versions.json: '.$e->getMessage());
return false;
}
}
/** /**
* Execute the console command. * Execute the console command.
*/ */
@ -677,8 +33,6 @@ public function handle()
$that = $this; $that = $this;
$only_template = $this->option('templates'); $only_template = $this->option('templates');
$only_version = $this->option('release'); $only_version = $this->option('release');
$only_github_releases = $this->option('github-releases');
$only_github_versions = $this->option('github-versions');
$nightly = $this->option('nightly'); $nightly = $this->option('nightly');
$bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn = 'https://cdn.coollabs.io';
$bunny_cdn_path = 'coolify'; $bunny_cdn_path = 'coolify';
@ -736,30 +90,11 @@ public function handle()
$install_script_location = "$parent_dir/other/nightly/$install_script"; $install_script_location = "$parent_dir/other/nightly/$install_script";
$versions_location = "$parent_dir/other/nightly/$versions"; $versions_location = "$parent_dir/other/nightly/$versions";
} }
if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) { if (! $only_template && ! $only_version) {
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn."); $this->info("About to sync $envLabel files to BunnyCDN.");
$this->newLine(); $this->newLine();
// Build file mapping for diff
if ($nightly) {
$fileMapping = [
$compose_file_location => 'docker/nightly/docker-compose.yml',
$compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
$production_env_location => 'environment/nightly/.env.production',
$upgrade_script_location => 'scripts/nightly/upgrade.sh',
$install_script_location => 'scripts/nightly/install.sh',
];
} else {
$fileMapping = [
$compose_file_location => 'docker/docker-compose.yml',
$compose_file_prod_location => 'docker/docker-compose.prod.yml',
$production_env_location => 'environment/.env.production',
$upgrade_script_location => 'scripts/upgrade.sh',
$install_script_location => 'scripts/install.sh',
];
}
// BunnyCDN file mapping (local file => CDN URL path) // BunnyCDN file mapping (local file => CDN URL path)
$bunnyFileMapping = [ $bunnyFileMapping = [
$compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file", $compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file",
@ -812,44 +147,6 @@ public function handle()
} }
} }
// Diff against GitHub coolify-cdn repo
$this->newLine();
$this->info('Fetching coolify-cdn repo to compare...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode);
if ($returnCode === 0) {
foreach ($fileMapping as $localFile => $cdnPath) {
$remotePath = "$diffTmpDir/repo/$cdnPath";
if (! file_exists($localFile)) {
continue;
}
if (! file_exists($remotePath)) {
$this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)");
$hasChanges = true;
continue;
}
$diffOutput = [];
exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode);
if ($diffCode !== 0) {
$hasChanges = true;
$this->newLine();
$this->info("--- GitHub: $cdnPath");
$this->info("+++ Local: $cdnPath");
foreach ($diffOutput as $line) {
if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
continue;
}
$this->line($line);
}
}
}
} else {
$this->warn('Could not fetch coolify-cdn repo for diff.');
}
exec('rm -rf '.escapeshellarg($diffTmpDir)); exec('rm -rf '.escapeshellarg($diffTmpDir));
if (! $hasChanges) { if (! $hasChanges) {
@ -881,9 +178,9 @@ public function handle()
return; return;
} elseif ($only_version) { } elseif ($only_version) {
if ($nightly) { if ($nightly) {
$this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.'); $this->info('About to sync NIGHTLY versions.json to BunnyCDN.');
} else { } else {
$this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.'); $this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
} }
$file = file_get_contents($versions_location); $file = file_get_contents($versions_location);
$json = json_decode($file, true); $json = json_decode($file, true);
@ -891,8 +188,7 @@ public function handle()
$this->info("Version: {$actual_version}"); $this->info("Version: {$actual_version}");
$this->info('This will:'); $this->info('This will:');
$this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)'); $this->info(' 1. Sync versions.json to BunnyCDN');
$this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
$this->newLine(); $this->newLine();
$confirmed = confirm('Are you sure you want to proceed?'); $confirmed = confirm('Are you sure you want to proceed?');
@ -900,8 +196,7 @@ public function handle()
return; return;
} }
// 1. Sync versions.json to BunnyCDN (deprecated but still needed) $this->info('Syncing versions.json to BunnyCDN...');
$this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
Http::pool(fn (Pool $pool) => [ Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
@ -909,46 +204,8 @@ public function handle()
$this->info('✓ versions.json uploaded & purged to BunnyCDN'); $this->info('✓ versions.json uploaded & purged to BunnyCDN');
$this->newLine(); $this->newLine();
// 2. Create GitHub PR with both releases.json and versions.json
$this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
$githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
if ($githubSuccess) {
$this->info('✓ GitHub PR created successfully with both files');
} else {
$this->error('✗ Failed to create GitHub PR');
}
$this->newLine();
$this->info('=== Summary ==='); $this->info('=== Summary ===');
$this->info('BunnyCDN sync: ✓ Complete'); $this->info('BunnyCDN sync: ✓ Complete');
$this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed'));
return;
} elseif ($only_github_releases) {
$this->info('About to sync GitHub releases to GitHub repository.');
$confirmed = confirm('Are you sure you want to sync GitHub releases?');
if (! $confirmed) {
return;
}
// Sync releases to GitHub repository
$this->syncReleasesToGitHubRepo();
return;
} elseif ($only_github_versions) {
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$file = file_get_contents($versions_location);
$json = json_decode($file, true);
$actual_version = data_get($json, 'coolify.v4.version');
$this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository.");
$confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?');
if (! $confirmed) {
return;
}
// Sync versions.json to GitHub repository
$this->syncVersionsToGitHubRepo($versions_location, $nightly);
return; return;
} }
@ -970,31 +227,8 @@ public function handle()
$this->info('All files uploaded & purged to BunnyCDN.'); $this->info('All files uploaded & purged to BunnyCDN.');
$this->newLine(); $this->newLine();
// Sync files to GitHub CDN repository via PR
$this->info('Creating GitHub PR for coolify-cdn repository...');
if ($nightly) {
$files = [
$compose_file_location => 'docker/nightly/docker-compose.yml',
$compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
$production_env_location => 'environment/nightly/.env.production',
$upgrade_script_location => 'scripts/nightly/upgrade.sh',
$install_script_location => 'scripts/nightly/install.sh',
];
} else {
$files = [
$compose_file_location => 'docker/docker-compose.yml',
$compose_file_prod_location => 'docker/docker-compose.prod.yml',
$production_env_location => 'environment/.env.production',
$upgrade_script_location => 'scripts/upgrade.sh',
$install_script_location => 'scripts/install.sh',
];
}
$githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly);
$this->newLine();
$this->info('=== Summary ==='); $this->info('=== Summary ===');
$this->info('BunnyCDN sync: Complete'); $this->info('BunnyCDN sync: Complete');
$this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed'));
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error('Error: '.$e->getMessage()); $this->error('Error: '.$e->getMessage());
} }

View file

@ -28,6 +28,11 @@ class ViewScheduledLogs extends Command
public function handle() public function handle()
{ {
$date = $this->option('date') ?: now()->format('Y-m-d'); $date = $this->option('date') ?: now()->format('Y-m-d');
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$this->error('Invalid date format. Use Y-m-d (e.g. 2025-01-31).');
return self::INVALID;
}
$logPaths = $this->getLogPaths($date); $logPaths = $this->getLogPaths($date);
if (empty($logPaths)) { if (empty($logPaths)) {
@ -49,17 +54,19 @@ public function handle()
$this->line(''); $this->line('');
if (count($logPaths) === 1) { if (count($logPaths) === 1) {
$logPath = $logPaths[0]; $logPath = escapeshellarg($logPaths[0]);
if ($filters) { if ($filters) {
passthru("tail -f {$logPath} | grep -E '{$filters}'"); $escapedFilters = escapeshellarg($filters);
passthru("tail -f {$logPath} | grep -E {$escapedFilters}");
} else { } else {
passthru("tail -f {$logPath}"); passthru("tail -f {$logPath}");
} }
} else { } else {
// Multiple files - use multitail or tail with process substitution // Multiple files - use multitail or tail with process substitution
$logPathsStr = implode(' ', $logPaths); $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
if ($filters) { if ($filters) {
passthru("tail -f {$logPathsStr} | grep -E '{$filters}'"); $escapedFilters = escapeshellarg($filters);
passthru("tail -f {$logPathsStr} | grep -E {$escapedFilters}");
} else { } else {
passthru("tail -f {$logPathsStr}"); passthru("tail -f {$logPathsStr}");
} }
@ -68,20 +75,23 @@ public function handle()
$this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:"); $this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:");
$this->line(''); $this->line('');
$escapedLines = escapeshellarg((string) $lines);
if (count($logPaths) === 1) { if (count($logPaths) === 1) {
$logPath = $logPaths[0]; $logPath = escapeshellarg($logPaths[0]);
if ($filters) { if ($filters) {
passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'"); $escapedFilters = escapeshellarg($filters);
passthru("tail -n {$escapedLines} {$logPath} | grep -E {$escapedFilters}");
} else { } else {
passthru("tail -n {$lines} {$logPath}"); passthru("tail -n {$escapedLines} {$logPath}");
} }
} else { } else {
// Multiple files - concatenate and sort by timestamp // Multiple files - concatenate and sort by timestamp
$logPathsStr = implode(' ', $logPaths); $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
if ($filters) { if ($filters) {
passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'"); $escapedFilters = escapeshellarg($filters);
passthru("tail -n {$escapedLines} {$logPathsStr} | sort | grep -E {$escapedFilters}");
} else { } else {
passthru("tail -n {$lines} {$logPathsStr} | sort"); passthru("tail -n {$escapedLines} {$logPathsStr} | sort");
} }
} }
} }

View file

@ -2,6 +2,7 @@
namespace App\Console; namespace App\Console;
use App\Jobs\ApiTokenExpirationWarningJob;
use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CheckTraefikVersionJob;
@ -39,8 +40,9 @@ protected function schedule(Schedule $schedule): void
$this->instanceTimezone = config('app.timezone'); $this->instanceTimezone = config('app.timezone');
} }
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
if (isDev()) { if (isDev()) {
// Instance Jobs // Instance Jobs
@ -75,7 +77,7 @@ protected function schedule(Schedule $schedule): void
// Scheduled Jobs (Backups & Tasks) // Scheduled Jobs (Backups & Tasks)
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer(); $this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily(); $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer();
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer(); $this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();

View file

@ -8,4 +8,5 @@ enum BuildPackTypes: string
case STATIC = 'static'; case STATIC = 'static';
case DOCKERFILE = 'dockerfile'; case DOCKERFILE = 'dockerfile';
case DOCKERCOMPOSE = 'dockercompose'; case DOCKERCOMPOSE = 'dockercompose';
case RAILPACK = 'railpack';
} }

View file

@ -4,8 +4,10 @@
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Psr\Log\LogLevel;
use RuntimeException; use RuntimeException;
use Sentry\Laravel\Integration; use Sentry\Laravel\Integration;
use Sentry\State\Scope; use Sentry\State\Scope;
@ -16,7 +18,7 @@ class Handler extends ExceptionHandler
/** /**
* A list of exception types with their corresponding custom log levels. * A list of exception types with their corresponding custom log levels.
* *
* @var array<class-string<\Throwable>, \Psr\Log\LogLevel::*> * @var array<class-string<Throwable>, LogLevel::*>
*/ */
protected $levels = [ protected $levels = [
// //
@ -25,7 +27,7 @@ class Handler extends ExceptionHandler
/** /**
* A list of the exception types that are not reported. * A list of the exception types that are not reported.
* *
* @var array<int, class-string<\Throwable>> * @var array<int, class-string<Throwable>>
*/ */
protected $dontReport = [ protected $dontReport = [
ProcessException::class, ProcessException::class,
@ -49,6 +51,13 @@ class Handler extends ExceptionHandler
protected function unauthenticated($request, AuthenticationException $exception) protected function unauthenticated($request, AuthenticationException $exception)
{ {
if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) { if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) {
if ($request->is('api/*')) {
auditLog('api.auth.unauthenticated', [
'reason' => $exception->getMessage(),
'guards' => $exception->guards(),
], 'warning');
}
return response()->json(['message' => $exception->getMessage()], 401); return response()->json(['message' => $exception->getMessage()], 401);
} }
@ -61,8 +70,15 @@ protected function unauthenticated($request, AuthenticationException $exception)
public function render($request, Throwable $e) public function render($request, Throwable $e)
{ {
// Handle authorization exceptions for API routes // Handle authorization exceptions for API routes
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { if ($e instanceof AuthorizationException) {
if ($request->is('api/*') || $request->expectsJson()) { if ($request->is('api/*') || $request->expectsJson()) {
if ($request->is('api/*')) {
auditLog('api.auth.policy_denied', [
'reason' => $e->getMessage(),
'route' => $request->route()?->getName() ?? $request->path(),
], 'warning');
}
// Get the custom message from the policy if available // Get the custom message from the policy if available
$message = $e->getMessage(); $message = $e->getMessage();

View file

@ -4,7 +4,6 @@
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Server; use App\Models\Server;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
@ -12,145 +11,65 @@
class SshMultiplexingHelper class SshMultiplexingHelper
{ {
public static function serverSshConfiguration(Server $server) public static function serverSshConfiguration(Server $server): array
{ {
$privateKey = PrivateKey::findOrFail($server->private_key_id); $privateKey = PrivateKey::findOrFail($server->private_key_id);
$sshKeyLocation = $privateKey->getKeyLocation();
$muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
return [ return [
'sshKeyLocation' => $sshKeyLocation, 'sshKeyLocation' => $privateKey->getKeyLocation(),
'muxFilename' => $muxFilename, 'muxFilename' => self::muxSocket($server),
]; ];
} }
public static function ensureMultiplexedConnection(Server $server): bool public static function ensureMultiplexedConnection(Server $server): bool
{ {
if (! self::isMultiplexingEnabled()) { return self::isMultiplexingEnabled();
return false;
}
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
// Check if connection exists
$checkCommand = "ssh -O check -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$checkCommand .= self::escapedUserAtHost($server);
$process = Process::run($checkCommand);
if ($process->exitCode() !== 0) {
return self::establishNewMultiplexedConnection($server);
}
// Connection exists, ensure we have metadata for age tracking
if (self::getConnectionAge($server) === null) {
// Existing connection but no metadata, store current time as fallback
self::storeConnectionMetadata($server);
}
// Connection exists, check if it needs refresh due to age
if (self::isConnectionExpired($server)) {
return self::refreshMultiplexedConnection($server);
}
// Perform health check if enabled
if (config('constants.ssh.mux_health_check_enabled')) {
if (! self::isConnectionHealthy($server)) {
return self::refreshMultiplexedConnection($server);
}
}
return true;
} }
public static function establishNewMultiplexedConnection(Server $server): bool public static function removeMuxFile(Server $server): void
{ {
$sshConfig = self::serverSshConfiguration($server); $closeCommand = self::muxControlCommand($server, 'exit');
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
$connectionTimeout = config('constants.ssh.connection_timeout');
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
$establishCommand .= self::escapedUserAtHost($server);
$establishProcess = Process::run($establishCommand);
if ($establishProcess->exitCode() !== 0) {
return false;
}
// Store connection metadata for tracking
self::storeConnectionMetadata($server);
return true;
}
public static function removeMuxFile(Server $server)
{
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
$closeCommand = "ssh -O exit -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$closeCommand .= self::escapedUserAtHost($server);
Process::run($closeCommand); Process::run($closeCommand);
// Clear connection metadata from cache
self::clearConnectionMetadata($server);
} }
public static function generateScpCommand(Server $server, string $source, string $dest) private static function muxControlCommand(Server $server, string $operation): string
{
$command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
return $command.self::escapedUserAtHost($server);
}
public static function generateScpCommand(Server $server, string $source, string $dest): string
{ {
$sshConfig = self::serverSshConfiguration($server); $sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation']; $sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename']; $scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp ';
$timeout = config('constants.ssh.command_timeout');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$scp_command = "timeout $timeout scp ";
if ($server->isIpv6()) { if ($server->isIpv6()) {
$scp_command .= '-6 '; $scpCommand .= '-6 ';
} }
if (self::isMultiplexingEnabled()) { if (self::isMultiplexingEnabled()) {
try { $scpCommand .= self::multiplexingOptions($server);
if (self::ensureMultiplexedConnection($server)) {
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
}
} catch (\Exception $e) {
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
'server' => $server->name ?? $server->ip,
'error' => $e->getMessage(),
]);
// Continue without multiplexing
}
} }
if (data_get($server, 'settings.is_cloudflare_tunnel')) { if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; $scpCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
} }
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true); $scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
if ($server->isIpv6()) { if ($server->isIpv6()) {
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}"; return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
} else {
$scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}";
} }
return $scp_command; return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
} }
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false) public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
{ {
if ($server->settings->force_disabled) { if ($server->settings->force_disabled) {
throw new \RuntimeException('Server is disabled.'); throw new \RuntimeException('Server is disabled.');
@ -161,40 +80,37 @@ public static function generateSshCommand(Server $server, string $command, bool
self::validateSshKey($server->privateKey); self::validateSshKey($server->privateKey);
$muxSocket = $sshConfig['muxFilename']; $commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout');
$sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
$timeout = config('constants.ssh.command_timeout');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$ssh_command = "timeout $timeout ssh ";
$multiplexingSuccessful = false;
if (! $disableMultiplexing && self::isMultiplexingEnabled()) { if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
try { $sshCommand .= self::multiplexingOptions($server);
$multiplexingSuccessful = self::ensureMultiplexedConnection($server);
if ($multiplexingSuccessful) {
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
}
} catch (\Exception $e) {
// Continue without multiplexing
}
} }
if (data_get($server, 'settings.is_cloudflare_tunnel')) { if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' "; $sshCommand .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
} }
$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval')); $sshCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
$delimiter = Hash::make($command); $delimiter = base64_encode(Hash::make($command));
$delimiter = base64_encode($delimiter);
$command = str_replace($delimiter, '', $command); $command = str_replace($delimiter, '', $command);
$ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
.$command.PHP_EOL .$command.PHP_EOL
.$delimiter; .$delimiter;
}
return $ssh_command; private static function multiplexingOptions(Server $server): string
{
return '-o ControlMaster=auto '
.'-o ControlPath='.self::muxSocket($server).' '
.'-o ControlPersist='.config('constants.ssh.mux_persist_time').' ';
}
private static function muxSocket(Server $server): string
{
return '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
} }
private static function escapedUserAtHost(Server $server): string private static function escapedUserAtHost(Server $server): string
@ -231,7 +147,6 @@ private static function validateSshKey(PrivateKey $privateKey): void
$privateKey->storeInFileSystem(); $privateKey->storeInFileSystem();
} }
// Ensure correct permissions (SSH requires 0600)
if (file_exists($keyLocation)) { if (file_exists($keyLocation)) {
$currentPerms = fileperms($keyLocation) & 0777; $currentPerms = fileperms($keyLocation) & 0777;
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) { if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
@ -243,6 +158,15 @@ private static function validateSshKey(PrivateKey $privateKey): void
} }
} }
public static function getConnectionTimeout(Server $server): int
{
$timeout = data_get($server, 'settings.connection_timeout');
return is_numeric($timeout) && (int) $timeout > 0
? (int) $timeout
: (int) config('constants.ssh.connection_timeout');
}
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
{ {
$options = "-i {$sshKeyLocation} " $options = "-i {$sshKeyLocation} "
@ -253,90 +177,10 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati
.'-o RequestTTY=no ' .'-o RequestTTY=no '
.'-o LogLevel=ERROR '; .'-o LogLevel=ERROR ';
// Bruh
if ($isScp) { if ($isScp) {
$options .= '-P '.escapeshellarg((string) $server->port).' '; return $options.'-P '.escapeshellarg((string) $server->port).' ';
} else {
$options .= '-p '.escapeshellarg((string) $server->port).' ';
} }
return $options; return $options.'-p '.escapeshellarg((string) $server->port).' ';
}
/**
* Check if the multiplexed connection is healthy by running a test command
*/
public static function isConnectionHealthy(Server $server): bool
{
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
$process = Process::run($healthCommand);
$isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
return $isHealthy;
}
/**
* Check if the connection has exceeded its maximum age
*/
public static function isConnectionExpired(Server $server): bool
{
$connectionAge = self::getConnectionAge($server);
$maxAge = config('constants.ssh.mux_max_age');
return $connectionAge !== null && $connectionAge > $maxAge;
}
/**
* Get the age of the current connection in seconds
*/
public static function getConnectionAge(Server $server): ?int
{
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
$connectionTime = Cache::get($cacheKey);
if ($connectionTime === null) {
return null;
}
return time() - $connectionTime;
}
/**
* Refresh a multiplexed connection by closing and re-establishing it
*/
public static function refreshMultiplexedConnection(Server $server): bool
{
// Close existing connection
self::removeMuxFile($server);
// Establish new connection
return self::establishNewMultiplexedConnection($server);
}
/**
* Store connection metadata when a new connection is established
*/
private static function storeConnectionMetadata(Server $server): void
{
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time
}
/**
* Clear connection metadata from cache
*/
private static function clearConnectionMetadata(Server $server): void
{
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
Cache::forget($cacheKey);
} }
} }

View file

@ -2,13 +2,14 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Actions\Application\LoadComposeFile; use App\Actions\Application\LoadComposeFile;
use App\Actions\Application\StopApplication; use App\Actions\Application\StopApplication;
use App\Actions\Service\StartService;
use App\Enums\BuildPackTypes; use App\Enums\BuildPackTypes;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob; use App\Jobs\DeleteResourceJob;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\EnvironmentVariable; use App\Models\EnvironmentVariable;
use App\Models\GithubApp; use App\Models\GithubApp;
use App\Models\LocalFileVolume; use App\Models\LocalFileVolume;
@ -16,7 +17,6 @@
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use App\Models\Service;
use App\Rules\ValidGitBranch; use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl; use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser; use App\Services\DockerImageParser;
@ -153,7 +153,7 @@ public function applications(Request $request)
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'], 'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'],
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'], 'name' => ['type' => 'string', 'description' => 'The application name.'],
@ -217,7 +217,7 @@ public function applications(Request $request)
type: 'object', type: 'object',
properties: [ properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'], 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
], ],
), ),
], ],
@ -322,7 +322,7 @@ public function create_public_application(Request $request)
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'], 'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'], 'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@ -383,7 +383,7 @@ public function create_public_application(Request $request)
type: 'object', type: 'object',
properties: [ properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'], 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
], ],
), ),
], ],
@ -488,7 +488,7 @@ public function create_private_gh_app_application(Request $request)
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'], 'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'], 'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@ -549,7 +549,7 @@ public function create_private_gh_app_application(Request $request)
type: 'object', type: 'object',
properties: [ properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'], 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
], ],
), ),
], ],
@ -650,7 +650,7 @@ public function create_private_deploy_key_application(Request $request)
'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'], 'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'build_pack' => ['type' => 'string', 'enum' => ['dockerfile'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'], 'name' => ['type' => 'string', 'description' => 'The application name.'],
@ -897,105 +897,6 @@ public function create_dockerimage_application(Request $request)
return $this->create_application($request, 'dockerimage'); return $this->create_application($request, 'dockerimage');
} }
/**
* @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is an unstable duplicate of POST /api/v1/services.
*/
#[OA\Post(
summary: 'Create (Docker Compose)',
description: 'Deprecated: Use POST /api/v1/services instead.',
path: '/applications/dockercompose',
operationId: 'create-dockercompose-application',
deprecated: true,
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
requestBody: new OA\RequestBody(
description: 'Application object that needs to be created.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID if the server has more than one destinations.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
],
)
),
]
),
responses: [
new OA\Response(
response: 201,
description: 'Application created successfully.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'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: 409,
description: 'Domain conflicts detected.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
'conflicts' => [
'type' => 'array',
'items' => new OA\Schema(
type: 'object',
properties: [
'domain' => ['type' => 'string', 'example' => 'example.com'],
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
]
),
],
]
)
),
]
),
]
)]
public function create_dockercompose_application(Request $request)
{
return $this->create_application($request, 'dockercompose');
}
private function create_application(Request $request, $type) private function create_application(Request $request, $type)
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
@ -1078,6 +979,9 @@ private function create_application(Request $request, $type)
], ],
], 422); ], 422);
} }
$request->merge([
'custom_nginx_configuration' => $customNginxConfiguration,
]);
} }
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first(); $project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
@ -1307,6 +1211,15 @@ private function create_application(Request $request, $type)
} }
} }
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([ return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'), 'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'), 'domains' => data_get($application, 'fqdn'),
@ -1537,6 +1450,15 @@ private function create_application(Request $request, $type)
} }
} }
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([ return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'), 'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'), 'domains' => data_get($application, 'fqdn'),
@ -1737,6 +1659,15 @@ private function create_application(Request $request, $type)
} }
} }
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([ return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'), 'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'), 'domains' => data_get($application, 'fqdn'),
@ -1844,6 +1775,15 @@ private function create_application(Request $request, $type)
} }
} }
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([ return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'), 'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'), 'domains' => data_get($application, 'fqdn'),
@ -1954,93 +1894,19 @@ private function create_application(Request $request, $type)
} }
} }
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([ return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'), 'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'), 'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201); ]))->setStatusCode(201);
} elseif ($type === 'dockercompose') {
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if (! $request->has('name')) {
$request->offsetSet('name', 'service'.new Cuid2);
}
$validationRules = [
'docker_compose_raw' => 'string|required',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validator = customApiValidator($request->all(), $validationRules);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof JsonResponse) {
return $return;
}
if (! isBase64Encoded($request->docker_compose_raw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
$service = new Service;
removeUnnecessaryFieldsFromRequest($request);
$service->fill($request->only($allowedFields));
$service->docker_compose_raw = $dockerComposeRaw;
$service->environment_id = $environment->id;
$service->server_id = $server->id;
$service->destination_id = $destination->id;
$service->destination_type = $destination->getMorphClass();
if (isset($isContainerLabelEscapeEnabled)) {
$service->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
}
$service->save();
$service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
if ($instantDeploy) {
StartService::dispatch($service);
}
return response()->json(serializeApiResponse([
'uuid' => data_get($service, 'uuid'),
'domains' => data_get($service, 'domains'),
]))->setStatusCode(201);
} }
return response()->json(['message' => 'Invalid type.'], 400); return response()->json(['message' => 'Invalid type.'], 400);
@ -2295,6 +2161,12 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true) dockerCleanup: $request->boolean('docker_cleanup', true)
); );
auditLog('api.application.deleted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
return response()->json([ return response()->json([
'message' => 'Application deletion request queued.', 'message' => 'Application deletion request queued.',
]); ]);
@ -2337,7 +2209,7 @@ public function delete_by_uuid(Request $request)
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'], 'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'], 'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@ -2397,7 +2269,7 @@ public function delete_by_uuid(Request $request)
type: 'object', type: 'object',
properties: [ properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'], 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
], ],
), ),
], ],
@ -2528,7 +2400,7 @@ public function update_by_uuid(Request $request)
} }
} }
} }
if ($request->has('custom_nginx_configuration')) { if ($request->has('custom_nginx_configuration') && ! is_null($request->custom_nginx_configuration)) {
if (! isBase64Encoded($request->custom_nginx_configuration)) { if (! isBase64Encoded($request->custom_nginx_configuration)) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
@ -2546,6 +2418,9 @@ public function update_by_uuid(Request $request)
], ],
], 422); ], 422);
} }
$request->merge([
'custom_nginx_configuration' => $customNginxConfiguration,
]);
} }
$return = $this->validateDataApplications($request, $server); $return = $this->validateDataApplications($request, $server);
if ($return instanceof JsonResponse) { if ($return instanceof JsonResponse) {
@ -2794,6 +2669,13 @@ public function update_by_uuid(Request $request)
} }
$application->save(); $application->save();
auditLog('api.application.updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
if ($instantDeploy) { if ($instantDeploy) {
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
@ -3046,6 +2928,14 @@ public function update_env_by_uuid(Request $request)
} }
$env->save(); $env->save();
auditLog('api.application.env_updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
'is_preview' => (bool) $is_preview,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201); return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else { } else {
return response()->json([ return response()->json([
@ -3079,6 +2969,14 @@ public function update_env_by_uuid(Request $request)
} }
$env->save(); $env->save();
auditLog('api.application.env_updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
'is_preview' => (bool) $is_preview,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201); return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else { } else {
return response()->json([ return response()->json([
@ -3305,6 +3203,12 @@ public function create_bulk_envs(Request $request)
$returnedEnvs->push($this->removeSensitiveData($env)); $returnedEnvs->push($this->removeSensitiveData($env));
} }
auditLog('api.application.env_bulk_upserted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_count' => $returnedEnvs->count(),
]);
return response()->json($returnedEnvs)->setStatusCode(201); return response()->json($returnedEnvs)->setStatusCode(201);
} }
@ -3444,6 +3348,14 @@ public function create_env(Request $request)
'resourceable_id' => $application->id, 'resourceable_id' => $application->id,
]); ]);
auditLog('api.application.env_created', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
'is_preview' => (bool) $is_preview,
]);
return response()->json([ return response()->json([
'uuid' => $env->uuid, 'uuid' => $env->uuid,
])->setStatusCode(201); ])->setStatusCode(201);
@ -3469,6 +3381,14 @@ public function create_env(Request $request)
'resourceable_id' => $application->id, 'resourceable_id' => $application->id,
]); ]);
auditLog('api.application.env_created', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
'is_preview' => (bool) $is_preview,
]);
return response()->json([ return response()->json([
'uuid' => $env->uuid, 'uuid' => $env->uuid,
])->setStatusCode(201); ])->setStatusCode(201);
@ -3560,8 +3480,17 @@ public function delete_env_by_uuid(Request $request)
'message' => 'Environment variable not found.', 'message' => 'Environment variable not found.',
], 404); ], 404);
} }
$envKey = $found_env->key;
$envUuid = $found_env->uuid;
$found_env->forceDelete(); $found_env->forceDelete();
auditLog('api.application.env_deleted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $envUuid,
'env_key' => $envKey,
]);
return response()->json([ return response()->json([
'message' => 'Environment variable deleted.', 'message' => 'Environment variable deleted.',
]); ]);
@ -3673,6 +3602,15 @@ public function action_deploy(Request $request)
); );
} }
auditLog('api.application.deployed', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'force_rebuild' => $force,
'instant_deploy' => $instant_deploy,
]);
return response()->json( return response()->json(
[ [
'message' => 'Deployment request queued.', 'message' => 'Deployment request queued.',
@ -3761,6 +3699,13 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true); $dockerCleanup = $request->boolean('docker_cleanup', true);
StopApplication::dispatch($application, false, $dockerCleanup); StopApplication::dispatch($application, false, $dockerCleanup);
auditLog('api.application.stopped', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'docker_cleanup' => $dockerCleanup,
]);
return response()->json( return response()->json(
[ [
'message' => 'Application stopping request queued.', 'message' => 'Application stopping request queued.',
@ -3851,6 +3796,13 @@ public function action_restart(Request $request)
], 200); ], 200);
} }
auditLog('api.application.restarted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
]);
return response()->json( return response()->json(
[ [
'message' => 'Restart request queued.', 'message' => 'Restart request queued.',
@ -4119,7 +4071,7 @@ public function update_storage(Request $request): JsonResponse
'is_preview_suffix_enabled' => 'boolean', 'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string', 'mount_path' => 'string',
'host_path' => 'string|nullable', 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable', 'content' => 'string|nullable',
]); ]);
@ -4219,6 +4171,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save(); $storage->save();
auditLog('api.application.storage_updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage); return response()->json($storage);
} }
@ -4297,7 +4258,7 @@ public function create_storage(Request $request): JsonResponse
'type' => 'required|string|in:persistent,file', 'type' => 'required|string|in:persistent,file',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string', 'mount_path' => 'required|string',
'host_path' => 'string|nullable', 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable', 'content' => 'string|nullable',
'is_directory' => 'boolean', 'is_directory' => 'boolean',
'fs_path' => 'string', 'fs_path' => 'string',
@ -4397,6 +4358,15 @@ public function create_storage(Request $request): JsonResponse
]); ]);
} }
auditLog('api.application.storage_created', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201); return response()->json($storage, 201);
} }
@ -4470,8 +4440,93 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer(); $storage->deleteStorageOnServer();
} }
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
$storageMountPath = $storage->mount_path ?? null;
$storage->delete(); $storage->delete();
auditLog('api.application.storage_deleted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'storage_uuid' => $storageUuid,
'storage_type' => $storageType,
'mount_path' => $storageMountPath,
]);
return response()->json(['message' => 'Storage deleted.']); return response()->json(['message' => 'Storage deleted.']);
} }
#[OA\Delete(
summary: 'Delete Preview Deployment',
description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.',
path: '/applications/{uuid}/previews/{pull_request_id}',
operationId: 'delete-preview-deployment-by-pull-request-id',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'pull_request_id',
in: 'path',
description: 'Pull request ID of the preview to delete.',
required: true,
schema: new OA\Schema(type: 'integer')
),
],
responses: [
new OA\Response(response: 200, description: 'Preview deletion queued.', content: new OA\JsonContent(
properties: [new OA\Property(property: 'message', 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 delete_preview_by_pull_request_id(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('delete', $application);
$pullRequestIdRaw = $request->route('pull_request_id');
if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
}
$pullRequestId = (int) $pullRequestIdRaw;
$preview = ApplicationPreview::where('application_id', $application->id)
->where('pull_request_id', $pullRequestId)
->first();
if (! $preview) {
return response()->json(['message' => 'Preview not found.'], 404);
}
$preview->delete();
CleanupPreviewDeployment::run($application, $pullRequestId, $preview);
auditLog('api.application.preview_deleted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'pull_request_id' => $pullRequestId,
]);
return response()->json(['message' => 'Preview deletion request queued.']);
}
} }

View file

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken; use App\Models\CloudProviderToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -244,7 +245,7 @@ public function store(Request $request)
} }
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof JsonResponse) {
return $return; return $return;
} }
@ -286,6 +287,13 @@ public function store(Request $request)
'name' => $body['name'], 'name' => $body['name'],
]); ]);
auditLog('api.cloud_token.created', [
'team_id' => $teamId,
'cloud_token_uuid' => $cloudProviderToken->uuid,
'cloud_token_name' => $cloudProviderToken->name,
'provider' => $cloudProviderToken->provider,
]);
return response()->json([ return response()->json([
'uuid' => $cloudProviderToken->uuid, 'uuid' => $cloudProviderToken->uuid,
])->setStatusCode(201); ])->setStatusCode(201);
@ -355,7 +363,7 @@ public function update(Request $request)
} }
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof JsonResponse) {
return $return; return $return;
} }
@ -389,6 +397,14 @@ public function update(Request $request)
$token->update(array_intersect_key($body, array_flip($allowedFields))); $token->update(array_intersect_key($body, array_flip($allowedFields)));
auditLog('api.cloud_token.updated', [
'team_id' => $teamId,
'cloud_token_uuid' => $token->uuid,
'cloud_token_name' => $token->name,
'provider' => $token->provider,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
]);
return response()->json([ return response()->json([
'uuid' => $token->uuid, 'uuid' => $token->uuid,
]); ]);
@ -464,8 +480,18 @@ public function destroy(Request $request)
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400); return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
} }
$tokenUuid = $token->uuid;
$tokenName = $token->name;
$tokenProvider = $token->provider;
$token->delete(); $token->delete();
auditLog('api.cloud_token.deleted', [
'team_id' => $teamId,
'cloud_token_uuid' => $tokenUuid,
'cloud_token_name' => $tokenName,
'provider' => $tokenProvider,
]);
return response()->json(['message' => 'Cloud provider token deleted.']); return response()->json(['message' => 'Cloud provider token deleted.']);
} }

View file

@ -299,6 +299,11 @@ public function database_by_uuid(Request $request)
'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'], 'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'],
'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'], 'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'],
'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'], 'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'],
'health_check_enabled' => ['type' => 'boolean', 'description' => 'Enable the database healthcheck probe.', 'default' => true],
'health_check_interval' => ['type' => 'integer', 'description' => 'Healthcheck interval in seconds.', 'minimum' => 1, 'default' => 15],
'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.', 'minimum' => 1, 'default' => 5],
'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5],
'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5],
], ],
), ),
) )
@ -379,9 +384,9 @@ public function update_by_uuid(Request $request)
case 'standalone-postgresql': case 'standalone-postgresql':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', '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', '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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'postgres_user' => 'string', 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_password' => 'string', 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
'postgres_db' => 'string', 'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_initdb_args' => 'string', 'postgres_initdb_args' => 'string',
'postgres_host_auth_method' => 'string', 'postgres_host_auth_method' => 'string',
'postgres_conf' => 'string', 'postgres_conf' => 'string',
@ -410,20 +415,20 @@ public function update_by_uuid(Request $request)
case 'standalone-clickhouse': case 'standalone-clickhouse':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', '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', '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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => 'string', 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'clickhouse_admin_password' => 'string', 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
]); ]);
break; break;
case 'standalone-dragonfly': case 'standalone-dragonfly':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', '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', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'dragonfly_password' => 'string', 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
]); ]);
break; break;
case 'standalone-redis': case 'standalone-redis':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', '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', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'redis_password' => 'string', 'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
'redis_conf' => 'string', 'redis_conf' => 'string',
]); ]);
if ($request->has('redis_conf')) { if ($request->has('redis_conf')) {
@ -450,7 +455,7 @@ public function update_by_uuid(Request $request)
case 'standalone-keydb': case 'standalone-keydb':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', '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', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'keydb_password' => 'string', 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
'keydb_conf' => 'string', 'keydb_conf' => 'string',
]); ]);
if ($request->has('keydb_conf')) { if ($request->has('keydb_conf')) {
@ -478,10 +483,10 @@ public function update_by_uuid(Request $request)
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', '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', '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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'mariadb_conf' => 'string', 'mariadb_conf' => 'string',
'mariadb_root_password' => 'string', 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mariadb_user' => 'string', 'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'mariadb_password' => 'string', 'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false),
'mariadb_database' => 'string', 'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]); ]);
if ($request->has('mariadb_conf')) { if ($request->has('mariadb_conf')) {
if (! isBase64Encoded($request->mariadb_conf)) { if (! isBase64Encoded($request->mariadb_conf)) {
@ -508,9 +513,9 @@ public function update_by_uuid(Request $request)
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', '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', '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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'mongo_conf' => 'string', 'mongo_conf' => 'string',
'mongo_initdb_root_username' => 'string', 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
'mongo_initdb_root_password' => 'string', 'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mongo_initdb_database' => 'string', 'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]); ]);
if ($request->has('mongo_conf')) { if ($request->has('mongo_conf')) {
if (! isBase64Encoded($request->mongo_conf)) { if (! isBase64Encoded($request->mongo_conf)) {
@ -537,10 +542,10 @@ public function update_by_uuid(Request $request)
case 'standalone-mysql': case 'standalone-mysql':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', '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', '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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'mysql_root_password' => 'string', 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_password' => 'string', 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_user' => 'string', 'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_database' => 'string', 'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_conf' => 'string', 'mysql_conf' => 'string',
]); ]);
if ($request->has('mysql_conf')) { if ($request->has('mysql_conf')) {
@ -565,9 +570,17 @@ public function update_by_uuid(Request $request)
} }
break; break;
} }
$allowedFields = array_merge($allowedFields, ['health_check_enabled', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period']);
$healthCheckValidator = customApiValidator($request->all(), [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer|min:1',
'health_check_timeout' => 'integer|min:1',
'health_check_retries' => 'integer|min:1',
'health_check_start_period' => 'integer|min:0',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) {
$errors = $validator->errors(); $errors = $validator->errors()->merge($healthCheckValidator->errors());
if (! empty($extraFields)) { if (! empty($extraFields)) {
foreach ($extraFields as $field) { foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.'); $errors->add($field, 'This field is not allowed.');
@ -596,6 +609,14 @@ public function update_by_uuid(Request $request)
StopDatabaseProxy::dispatch($database); StopDatabaseProxy::dispatch($database);
} }
auditLog('api.database.updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([ return response()->json([
'message' => 'Database updated.', 'message' => 'Database updated.',
]); ]);
@ -639,10 +660,10 @@ public function update_by_uuid(Request $request)
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'], 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'], 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'], 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'], 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
], ],
), ),
@ -703,10 +724,10 @@ public function create_backup(Request $request)
'databases_to_backup' => 'string|nullable', 'databases_to_backup' => 'string|nullable',
'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'integer|min:0', 'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000', 'timeout' => 'integer|min:60|max:36000',
]); ]);
@ -747,7 +768,7 @@ public function create_backup(Request $request)
} }
if ($request->filled('s3_storage_uuid')) { if ($request->filled('s3_storage_uuid')) {
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) { if (! $existsInTeam) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
@ -774,7 +795,7 @@ public function create_backup(Request $request)
// Convert s3_storage_uuid to s3_storage_id // Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) { if (isset($backupData['s3_storage_uuid'])) {
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) { if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id; $backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) { } elseif ($request->boolean('save_s3')) {
@ -826,6 +847,15 @@ public function create_backup(Request $request)
dispatch(new DatabaseBackupJob($backupConfig)); dispatch(new DatabaseBackupJob($backupConfig));
} }
auditLog('api.database.backup_created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'backup_uuid' => $backupConfig->uuid,
'frequency' => $backupConfig->frequency,
'save_s3' => (bool) $backupConfig->save_s3,
'backup_now' => (bool) $request->backup_now,
]);
return response()->json([ return response()->json([
'uuid' => $backupConfig->uuid, 'uuid' => $backupConfig->uuid,
'message' => 'Backup configuration created successfully.', 'message' => 'Backup configuration created successfully.',
@ -878,10 +908,10 @@ public function create_backup(Request $request)
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'], 'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
], ],
), ),
@ -933,10 +963,10 @@ public function update_backup(Request $request)
'frequency' => 'string', 'frequency' => 'string',
'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'integer|min:0', 'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000', 'timeout' => 'integer|min:60|max:36000',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -982,7 +1012,7 @@ public function update_backup(Request $request)
], 422); ], 422);
} }
if ($request->filled('s3_storage_uuid')) { if ($request->filled('s3_storage_uuid')) {
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) { if (! $existsInTeam) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
@ -1015,7 +1045,7 @@ public function update_backup(Request $request)
// Convert s3_storage_uuid to s3_storage_id // Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) { if (isset($backupData['s3_storage_uuid'])) {
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) { if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id; $backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) { } elseif ($request->boolean('save_s3')) {
@ -1045,6 +1075,14 @@ public function update_backup(Request $request)
dispatch(new DatabaseBackupJob($backupConfig)); dispatch(new DatabaseBackupJob($backupConfig));
} }
auditLog('api.database.backup_updated', [
'team_id' => $teamId,
'backup_uuid' => $backupConfig->uuid,
'database_id' => $backupConfig->database_id,
'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))),
'backup_now' => (bool) $request->backup_now,
]);
return response()->json([ return response()->json([
'message' => 'Database backup configuration updated', 'message' => 'Database backup configuration updated',
]); ]);
@ -1724,9 +1762,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($type === NewDatabaseTypes::POSTGRESQL) { 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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'postgres_user' => 'string', 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_password' => 'string', 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
'postgres_db' => 'string', 'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_initdb_args' => 'string', 'postgres_initdb_args' => 'string',
'postgres_host_auth_method' => 'string', 'postgres_host_auth_method' => 'string',
'postgres_conf' => 'string', 'postgres_conf' => 'string',
@ -1766,7 +1804,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} }
$request->offsetSet('postgres_conf', $postgresConf); $request->offsetSet('postgres_conf', $postgresConf);
} }
$database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields)); $database = create_standalone_postgresql($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
@ -1779,12 +1817,25 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url; $payload['external_db_url'] = $database->external_db_url;
} }
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) { } 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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => 'string', 'mariadb_conf' => 'string',
'clickhouse_admin_password' => 'string', 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false),
'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]); ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || ! empty($extraFields)) {
@ -1821,7 +1872,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} }
$request->offsetSet('mariadb_conf', $mariadbConf); $request->offsetSet('mariadb_conf', $mariadbConf);
} }
$database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields)); $database = create_standalone_mariadb($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
@ -1835,14 +1886,24 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url; $payload['external_db_url'] = $database->external_db_url;
} }
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) { } 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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'mysql_root_password' => 'string', 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_password' => 'string', 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_user' => 'string', 'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_database' => 'string', 'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_conf' => 'string', 'mysql_conf' => 'string',
]); ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -1880,7 +1941,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} }
$request->offsetSet('mysql_conf', $mysqlConf); $request->offsetSet('mysql_conf', $mysqlConf);
} }
$database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields)); $database = create_standalone_mysql($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
@ -1894,11 +1955,21 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url; $payload['external_db_url'] = $database->external_db_url;
} }
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) { } 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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'redis_password' => 'string', 'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
'redis_conf' => 'string', 'redis_conf' => 'string',
]); ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -1936,7 +2007,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} }
$request->offsetSet('redis_conf', $redisConf); $request->offsetSet('redis_conf', $redisConf);
} }
$database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields)); $database = create_standalone_redis($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
@ -1950,11 +2021,21 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url; $payload['external_db_url'] = $database->external_db_url;
} }
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) { } 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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'dragonfly_password' => 'string', 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
]); ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -1973,7 +2054,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} }
removeUnnecessaryFieldsFromRequest($request); removeUnnecessaryFieldsFromRequest($request);
$database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields)); $database = create_standalone_dragonfly($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
@ -1984,7 +2065,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::KEYDB) { } 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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'keydb_password' => 'string', 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
'keydb_conf' => 'string', 'keydb_conf' => 'string',
]); ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -2022,7 +2103,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} }
$request->offsetSet('keydb_conf', $keydbConf); $request->offsetSet('keydb_conf', $keydbConf);
} }
$database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields)); $database = create_standalone_keydb($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
@ -2036,12 +2117,22 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url; $payload['external_db_url'] = $database->external_db_url;
} }
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) { } 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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => 'string', 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'clickhouse_admin_password' => 'string', 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
]); ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || ! empty($extraFields)) {
@ -2058,7 +2149,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
], 422); ], 422);
} }
removeUnnecessaryFieldsFromRequest($request); removeUnnecessaryFieldsFromRequest($request);
$database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields)); $database = create_standalone_clickhouse($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
@ -2072,14 +2163,24 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url; $payload['external_db_url'] = $database->external_db_url;
} }
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) { } 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'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'mongo_conf' => 'string', 'mongo_conf' => 'string',
'mongo_initdb_root_username' => 'string', 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
'mongo_initdb_root_password' => 'string', 'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mongo_initdb_database' => 'string', 'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]); ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || ! empty($extraFields)) {
@ -2116,7 +2217,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} }
$request->offsetSet('mongo_conf', $mongoConf); $request->offsetSet('mongo_conf', $mongoConf);
} }
$database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields)); $database = create_standalone_mongodb($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
@ -2130,6 +2231,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url; $payload['external_db_url'] = $database->external_db_url;
} }
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} }
@ -2214,6 +2325,13 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true) dockerCleanup: $request->boolean('docker_cleanup', true)
); );
auditLog('api.database.deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
]);
return response()->json([ return response()->json([
'message' => 'Database deletion request queued.', 'message' => 'Database deletion request queued.',
]); ]);
@ -2326,13 +2444,21 @@ public function delete_backup_by_uuid(Request $request)
$backup->delete(); $backup->delete();
DB::commit(); DB::commit();
auditLog('api.database.backup_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'backup_uuid' => $request->scheduled_backup_uuid,
'delete_s3' => $deleteS3,
'executions_deleted' => $executions->count(),
]);
return response()->json([ return response()->json([
'message' => 'Backup configuration and all executions deleted.', 'message' => 'Backup configuration and all executions deleted.',
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
DB::rollBack(); DB::rollBack();
return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500); return response()->json(['message' => 'Failed to delete backup.'], 500);
} }
} }
@ -2448,11 +2574,19 @@ public function delete_execution_by_uuid(Request $request)
$execution->delete(); $execution->delete();
auditLog('api.database.backup_execution_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'backup_uuid' => $request->scheduled_backup_uuid,
'execution_uuid' => $request->execution_uuid,
'delete_s3' => $deleteS3,
]);
return response()->json([ return response()->json([
'message' => 'Backup execution deleted.', 'message' => 'Backup execution deleted.',
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500); return response()->json(['message' => 'Failed to delete backup execution.'], 500);
} }
} }
@ -2630,6 +2764,13 @@ public function action_deploy(Request $request)
} }
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
auditLog('api.database.started', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
]);
return response()->json( return response()->json(
[ [
'message' => 'Database starting request queued.', 'message' => 'Database starting request queued.',
@ -2721,6 +2862,14 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true); $dockerCleanup = $request->boolean('docker_cleanup', true);
StopDatabase::dispatch($database, $dockerCleanup); StopDatabase::dispatch($database, $dockerCleanup);
auditLog('api.database.stopped', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
'docker_cleanup' => $dockerCleanup,
]);
return response()->json( return response()->json(
[ [
'message' => 'Database stopping request queued.', 'message' => 'Database stopping request queued.',
@ -2798,6 +2947,13 @@ public function action_restart(Request $request)
RestartDatabase::dispatch($database); RestartDatabase::dispatch($database);
auditLog('api.database.restarted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
]);
return response()->json( return response()->json(
[ [
'message' => 'Database restarting request queued.', 'message' => 'Database restarting request queued.',
@ -3014,6 +3170,13 @@ public function update_env_by_uuid(Request $request)
} }
$env->save(); $env->save();
auditLog('api.database.env_updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
} }
@ -3142,6 +3305,12 @@ public function create_bulk_envs(Request $request)
$updatedEnvs->push($this->removeSensitiveEnvData($env)); $updatedEnvs->push($this->removeSensitiveEnvData($env));
} }
auditLog('api.database.env_bulk_upserted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_count' => $updatedEnvs->count(),
]);
return response()->json($updatedEnvs)->setStatusCode(201); return response()->json($updatedEnvs)->setStatusCode(201);
} }
@ -3263,6 +3432,13 @@ public function create_env(Request $request)
'comment' => $request->comment ?? null, 'comment' => $request->comment ?? null,
]); ]);
auditLog('api.database.env_created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
} }
@ -3348,8 +3524,17 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404); return response()->json(['message' => 'Environment variable not found.'], 404);
} }
$envKey = $env->key;
$envUuid = $env->uuid;
$env->forceDelete(); $env->forceDelete();
auditLog('api.database.env_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_uuid' => $envUuid,
'env_key' => $envKey,
]);
return response()->json(['message' => 'Environment variable deleted.']); return response()->json(['message' => 'Environment variable deleted.']);
} }
@ -3496,7 +3681,7 @@ public function create_storage(Request $request): JsonResponse
'type' => 'required|string|in:persistent,file', 'type' => 'required|string|in:persistent,file',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string', 'mount_path' => 'required|string',
'host_path' => 'string|nullable', 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable', 'content' => 'string|nullable',
'is_directory' => 'boolean', 'is_directory' => 'boolean',
'fs_path' => 'string', 'fs_path' => 'string',
@ -3596,6 +3781,15 @@ public function create_storage(Request $request): JsonResponse
]); ]);
} }
auditLog('api.database.storage_created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201); return response()->json($storage, 201);
} }
@ -3694,7 +3888,7 @@ public function update_storage(Request $request): JsonResponse
'is_preview_suffix_enabled' => 'boolean', 'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string', 'mount_path' => 'string',
'host_path' => 'string|nullable', 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable', 'content' => 'string|nullable',
]); ]);
@ -3794,6 +3988,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save(); $storage->save();
auditLog('api.database.storage_updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage); return response()->json($storage);
} }
@ -3867,8 +4070,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer(); $storage->deleteStorageOnServer();
} }
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
$storageMountPath = $storage->mount_path ?? null;
$storage->delete(); $storage->delete();
auditLog('api.database.storage_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'storage_uuid' => $storageUuid,
'storage_type' => $storageType,
'mount_path' => $storageMountPath,
]);
return response()->json(['message' => 'Storage deleted.']); return response()->json(['message' => 'Storage deleted.']);
} }
} }

View file

@ -281,6 +281,14 @@ public function cancel_deployment(Request $request)
} }
} }
auditLog('api.deployment.cancelled', [
'team_id' => $teamId,
'deployment_uuid' => $deployment->deployment_uuid,
'application_id' => $application?->id,
'application_uuid' => $application?->uuid,
'server_id' => $deployment->server_id,
]);
return response()->json([ return response()->json([
'message' => 'Deployment cancelled successfully.', 'message' => 'Deployment cancelled successfully.',
'deployment_uuid' => $deployment->deployment_uuid, 'deployment_uuid' => $deployment->deployment_uuid,
@ -518,6 +526,14 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$message = $result['message']; $message = $result['message'];
} else { } else {
$message = "Application {$resource->name} deployment queued."; $message = "Application {$resource->name} deployment queued.";
auditLog('api.deployment.triggered', [
'resource_type' => 'application',
'application_uuid' => $resource->uuid,
'application_name' => $resource->name,
'deployment_uuid' => $deployment_uuid?->toString(),
'force_rebuild' => $force,
'pull_request_id' => $pr,
]);
} }
break; break;
case Service::class: case Service::class:
@ -529,6 +545,10 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
} }
StartService::run($resource); StartService::run($resource);
$message = "Service {$resource->name} started. It could take a while, be patient."; $message = "Service {$resource->name} started. It could take a while, be patient.";
auditLog('api.service.deployed', [
'service_uuid' => $resource->uuid,
'service_name' => $resource->name,
]);
break; break;
default: default:
// Database resource - check authorization // Database resource - check authorization
@ -543,6 +563,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$resource->save(); $resource->save();
$message = "Database {$resource->name} started."; $message = "Database {$resource->name} started.";
auditLog('api.database.started', [
'database_uuid' => $resource->uuid,
'database_name' => $resource->name,
'database_type' => $resource->getMorphClass(),
]);
break; break;
} }

View file

@ -271,6 +271,12 @@ public function create_github_app(Request $request)
$githubApp = GithubApp::create($payload); $githubApp = GithubApp::create($payload);
auditLog('api.github_app.created', [
'team_id' => $teamId,
'github_app_uuid' => $githubApp->uuid,
'github_app_name' => $githubApp->name,
]);
return response()->json($githubApp, 201); return response()->json($githubApp, 201);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e); return handleError($e);
@ -650,6 +656,13 @@ public function update_github_app(Request $request, $github_app_id)
// Update the GitHub app // Update the GitHub app
$githubApp->update($payload); $githubApp->update($payload);
auditLog('api.github_app.updated', [
'team_id' => $teamId,
'github_app_uuid' => $githubApp->uuid,
'github_app_name' => $githubApp->name,
'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])),
]);
return response()->json([ return response()->json([
'message' => 'GitHub app updated successfully', 'message' => 'GitHub app updated successfully',
'data' => $githubApp, 'data' => $githubApp,
@ -734,8 +747,16 @@ public function delete_github_app($github_app_id)
], 409); ], 409);
} }
$deletedUuid = $githubApp->uuid;
$deletedName = $githubApp->name;
$githubApp->delete(); $githubApp->delete();
auditLog('api.github_app.deleted', [
'team_id' => $teamId,
'github_app_uuid' => $deletedUuid,
'github_app_name' => $deletedName,
]);
return response()->json([ return response()->json([
'message' => 'GitHub app deleted successfully', 'message' => 'GitHub app deleted successfully',
]); ]);

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException; use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@ -12,6 +13,7 @@
use App\Rules\ValidCloudInitYaml; use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname; use App\Rules\ValidHostname;
use App\Services\HetznerService; use App\Services\HetznerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
@ -121,7 +123,7 @@ public function locations(Request $request)
return response()->json($locations); return response()->json($locations);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500); return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500);
} }
} }
@ -242,7 +244,7 @@ public function serverTypes(Request $request)
return response()->json($serverTypes); return response()->json($serverTypes);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500); return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500);
} }
} }
@ -354,7 +356,7 @@ public function images(Request $request)
return response()->json(array_values($filtered)); return response()->json(array_values($filtered));
} catch (\Throwable $e) { } catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500); return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500);
} }
} }
@ -450,7 +452,7 @@ public function sshKeys(Request $request)
return response()->json($sshKeys); return response()->json($sshKeys);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500); return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500);
} }
} }
@ -550,7 +552,7 @@ public function createServer(Request $request)
} }
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof JsonResponse) {
return $return; return $return;
} }
@ -717,9 +719,17 @@ public function createServer(Request $request)
// Validate server if requested // Validate server if requested
if ($request->instant_validate) { if ($request->instant_validate) {
\App\Actions\Server\ValidateServer::dispatch($server); ValidateServer::dispatch($server);
} }
auditLog('api.hetzner_server.created', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'hetzner_server_id' => $hetznerServer['id'],
'ip' => $ipAddress,
]);
return response()->json([ return response()->json([
'uuid' => $server->uuid, 'uuid' => $server->uuid,
'hetzner_server_id' => $hetznerServer['id'], 'hetzner_server_id' => $hetznerServer['id'],
@ -733,7 +743,7 @@ public function createServer(Request $request)
return $response; return $response;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500); return response()->json(['message' => 'Failed to create Hetzner server.'], 500);
} }
} }
} }

View file

@ -85,11 +85,15 @@ public function enable_api(Request $request)
return invalidTokenResponse(); return invalidTokenResponse();
} }
if ($teamId !== '0') { if ($teamId !== '0') {
auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning');
return response()->json(['message' => 'You are not allowed to enable the API.'], 403); return response()->json(['message' => 'You are not allowed to enable the API.'], 403);
} }
$settings = instanceSettings(); $settings = instanceSettings();
$settings->update(['is_api_enabled' => true]); $settings->update(['is_api_enabled' => true]);
auditLog('api.instance.enabled', ['team_id' => $teamId]);
return response()->json(['message' => 'API enabled.'], 200); return response()->json(['message' => 'API enabled.'], 200);
} }
@ -137,21 +141,141 @@ public function disable_api(Request $request)
return invalidTokenResponse(); return invalidTokenResponse();
} }
if ($teamId !== '0') { if ($teamId !== '0') {
auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning');
return response()->json(['message' => 'You are not allowed to disable the API.'], 403); return response()->json(['message' => 'You are not allowed to disable the API.'], 403);
} }
$settings = instanceSettings(); $settings = instanceSettings();
$settings->update(['is_api_enabled' => false]); $settings->update(['is_api_enabled' => false]);
auditLog('api.instance.disabled', ['team_id' => $teamId]);
return response()->json(['message' => 'API disabled.'], 200); return response()->json(['message' => 'API disabled.'], 200);
} }
#[OA\Post(
summary: 'Enable MCP Server',
description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
path: '/mcp/enable',
operationId: 'enable-mcp',
security: [
['bearerAuth' => []],
],
responses: [
new OA\Response(
response: 200,
description: 'MCP server enabled.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
]
)),
new OA\Response(
response: 403,
description: 'You are not allowed to enable the MCP server.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
]
)),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function enable_mcp(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if ($teamId !== '0') {
auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_mcp_server_enabled' => true]);
auditLog('api.mcp.enabled', ['team_id' => $teamId]);
return response()->json(['message' => 'MCP server enabled.'], 200);
}
#[OA\Post(
summary: 'Disable MCP Server',
description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
path: '/mcp/disable',
operationId: 'disable-mcp',
security: [
['bearerAuth' => []],
],
responses: [
new OA\Response(
response: 200,
description: 'MCP server disabled.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
]
)),
new OA\Response(
response: 403,
description: 'You are not allowed to disable the MCP server.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
]
)),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function disable_mcp(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if ($teamId !== '0') {
auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_mcp_server_enabled' => false]);
auditLog('api.mcp.disabled', ['team_id' => $teamId]);
return response()->json(['message' => 'MCP server disabled.'], 200);
}
public function feedback(Request $request) public function feedback(Request $request)
{ {
$content = $request->input('content'); $data = $request->validate([
'content' => ['required', 'string', 'min:10', 'max:2000'],
]);
$webhook_url = config('constants.webhooks.feedback_discord_webhook'); $webhook_url = config('constants.webhooks.feedback_discord_webhook');
if ($webhook_url) { if ($webhook_url) {
Http::post($webhook_url, [ Http::timeout(5)->post($webhook_url, [
'content' => $content, 'content' => $data['content'],
'allowed_mentions' => ['parse' => []],
]); ]);
} }

View file

@ -264,6 +264,12 @@ public function create_project(Request $request)
'team_id' => $teamId, 'team_id' => $teamId,
]); ]);
auditLog('api.project.created', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
]);
return response()->json([ return response()->json([
'uuid' => $project->uuid, 'uuid' => $project->uuid,
])->setStatusCode(201); ])->setStatusCode(201);
@ -382,6 +388,13 @@ public function update_project(Request $request)
$project->update($request->only($allowedFields)); $project->update($request->only($allowedFields));
auditLog('api.project.updated', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([ return response()->json([
'uuid' => $project->uuid, 'uuid' => $project->uuid,
'name' => $project->name, 'name' => $project->name,
@ -460,8 +473,16 @@ public function delete_project(Request $request)
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400); return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
} }
$projectUuid = $project->uuid;
$projectName = $project->name;
$project->delete(); $project->delete();
auditLog('api.project.deleted', [
'team_id' => $teamId,
'project_uuid' => $projectUuid,
'project_name' => $projectName,
]);
return response()->json(['message' => 'Project deleted.']); return response()->json(['message' => 'Project deleted.']);
} }
@ -641,6 +662,13 @@ public function create_environment(Request $request)
'name' => $request->name, 'name' => $request->name,
]); ]);
auditLog('api.project.environment_created', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'environment_name' => $environment->name,
]);
return response()->json([ return response()->json([
'uuid' => $environment->uuid, 'uuid' => $environment->uuid,
])->setStatusCode(201); ])->setStatusCode(201);
@ -723,8 +751,17 @@ public function delete_environment(Request $request)
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400); return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
} }
$envUuid = $environment->uuid;
$envName = $environment->name;
$environment->delete(); $environment->delete();
auditLog('api.project.environment_deleted', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'environment_uuid' => $envUuid,
'environment_name' => $envName,
]);
return response()->json(['message' => 'Environment deleted.']); return response()->json(['message' => 'Environment deleted.']);
} }
} }

View file

@ -6,6 +6,7 @@
use App\Models\Application; use App\Models\Application;
use App\Models\ScheduledTask; use App\Models\ScheduledTask;
use App\Models\Service; use App\Models\Service;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
@ -33,7 +34,7 @@ private function resolveService(Request $request, int $teamId): ?Service
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first(); return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
} }
private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse private function listTasks(Application|Service $resource): JsonResponse
{ {
$this->authorize('view', $resource); $this->authorize('view', $resource);
@ -44,12 +45,12 @@ private function listTasks(Application|Service $resource): \Illuminate\Http\Json
return response()->json($tasks); return response()->json($tasks);
} }
private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse private function createTask(Request $request, Application|Service $resource): JsonResponse
{ {
$this->authorize('update', $resource); $this->authorize('update', $resource);
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof JsonResponse) {
return $return; return $return;
} }
@ -105,15 +106,23 @@ private function createTask(Request $request, Application|Service $resource): \I
$task->save(); $task->save();
auditLog('api.scheduled_task.created', [
'team_id' => $teamId,
'task_uuid' => $task->uuid,
'task_name' => $task->name,
'resource_type' => $resource instanceof Application ? 'application' : 'service',
'resource_uuid' => $resource->uuid,
]);
return response()->json($this->removeSensitiveData($task), 201); return response()->json($this->removeSensitiveData($task), 201);
} }
private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse private function updateTask(Request $request, Application|Service $resource): JsonResponse
{ {
$this->authorize('update', $resource); $this->authorize('update', $resource);
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof JsonResponse) {
return $return; return $return;
} }
@ -161,22 +170,43 @@ private function updateTask(Request $request, Application|Service $resource): \I
$task->update($request->only($allowedFields)); $task->update($request->only($allowedFields));
auditLog('api.scheduled_task.updated', [
'team_id' => getTeamIdFromToken(),
'task_uuid' => $task->uuid,
'task_name' => $task->name,
'resource_type' => $resource instanceof Application ? 'application' : 'service',
'resource_uuid' => $resource->uuid,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json($this->removeSensitiveData($task), 200); return response()->json($this->removeSensitiveData($task), 200);
} }
private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse private function deleteTask(Request $request, Application|Service $resource): JsonResponse
{ {
$this->authorize('update', $resource); $this->authorize('update', $resource);
$deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete(); $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
if (! $deleted) { if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404); return response()->json(['message' => 'Scheduled task not found.'], 404);
} }
$taskUuid = $task->uuid;
$taskName = $task->name;
$task->delete();
auditLog('api.scheduled_task.deleted', [
'team_id' => getTeamIdFromToken(),
'task_uuid' => $taskUuid,
'task_name' => $taskName,
'resource_type' => $resource instanceof Application ? 'application' : 'service',
'resource_uuid' => $resource->uuid,
]);
return response()->json(['message' => 'Scheduled task deleted.']); return response()->json(['message' => 'Scheduled task deleted.']);
} }
private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse private function getExecutions(Request $request, Application|Service $resource): JsonResponse
{ {
$this->authorize('view', $resource); $this->authorize('view', $resource);
@ -238,7 +268,7 @@ private function getExecutions(Request $request, Application|Service $resource):
), ),
] ]
)] )]
public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -317,7 +347,7 @@ public function scheduled_tasks_by_application_uuid(Request $request): \Illumina
), ),
] ]
)] )]
public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -404,7 +434,7 @@ public function create_scheduled_task_by_application_uuid(Request $request): \Il
), ),
] ]
)] )]
public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -474,7 +504,7 @@ public function update_scheduled_task_by_application_uuid(Request $request): \Il
), ),
] ]
)] )]
public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -542,7 +572,7 @@ public function delete_scheduled_task_by_application_uuid(Request $request): \Il
), ),
] ]
)] )]
public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse public function executions_by_application_uuid(Request $request): JsonResponse
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -601,7 +631,7 @@ public function executions_by_application_uuid(Request $request): \Illuminate\Ht
), ),
] ]
)] )]
public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -680,7 +710,7 @@ public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\H
), ),
] ]
)] )]
public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -767,7 +797,7 @@ public function create_scheduled_task_by_service_uuid(Request $request): \Illumi
), ),
] ]
)] )]
public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -837,7 +867,7 @@ public function update_scheduled_task_by_service_uuid(Request $request): \Illumi
), ),
] ]
)] )]
public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -905,7 +935,7 @@ public function delete_scheduled_task_by_service_uuid(Request $request): \Illumi
), ),
] ]
)] )]
public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse public function executions_by_service_uuid(Request $request): JsonResponse
{ {
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {

View file

@ -232,6 +232,13 @@ public function create_key(Request $request)
'private_key' => $request->private_key, 'private_key' => $request->private_key,
]); ]);
auditLog('api.private_key.created', [
'team_id' => $teamId,
'private_key_uuid' => $key->uuid,
'private_key_name' => $key->name,
'fingerprint' => $fingerPrint,
]);
return response()->json(serializeApiResponse([ return response()->json(serializeApiResponse([
'uuid' => $key->uuid, 'uuid' => $key->uuid,
]))->setStatusCode(201); ]))->setStatusCode(201);
@ -333,6 +340,13 @@ public function update_key(Request $request)
} }
$foundKey->update($request->only($allowedFields)); $foundKey->update($request->only($allowedFields));
auditLog('api.private_key.updated', [
'team_id' => $teamId,
'private_key_uuid' => $foundKey->uuid,
'private_key_name' => $foundKey->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json(serializeApiResponse([ return response()->json(serializeApiResponse([
'uuid' => $foundKey->uuid, 'uuid' => $foundKey->uuid,
]))->setStatusCode(201); ]))->setStatusCode(201);
@ -415,8 +429,16 @@ public function delete_key(Request $request)
], 422); ], 422);
} }
$keyUuid = $key->uuid;
$keyName = $key->name;
$key->forceDelete(); $key->forceDelete();
auditLog('api.private_key.deleted', [
'team_id' => $teamId,
'private_key_uuid' => $keyUuid,
'private_key_name' => $keyName,
]);
return response()->json([ return response()->json([
'message' => 'Private Key deleted.', 'message' => 'Private Key deleted.',
]); ]);

View file

@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use Exception;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator;
class SentinelController extends Controller
{
/**
* Handle a Sentinel agent metrics push.
*
* Sentinel pushes its full container list on a fixed interval (default 60s),
* even when nothing changed. To avoid dispatching one PushServerUpdateJob per
* server per minute, the job is only dispatched when the container state hash
* changes, or when the force window has elapsed.
*/
public function push(Request $request)
{
$token = $request->header('Authorization');
if (! $token) {
auditLogWebhookFailure('sentinel', 'token_missing');
return response()->json(['message' => 'Unauthorized'], 401);
}
$naked_token = str_replace('Bearer ', '', $token);
try {
$decrypted = decrypt($naked_token);
$decrypted_token = json_decode($decrypted, true);
} catch (Exception $e) {
auditLogWebhookFailure('sentinel', 'decrypt_failed');
return response()->json(['message' => 'Invalid token'], 401);
}
$server_uuid = data_get($decrypted_token, 'server_uuid');
if (! $server_uuid) {
auditLogWebhookFailure('sentinel', 'invalid_token_payload');
return response()->json(['message' => 'Invalid token'], 401);
}
$server = Server::where('uuid', $server_uuid)->first();
if (! $server) {
auditLogWebhookFailure('sentinel', 'server_not_found', [
'server_uuid' => $server_uuid,
]);
return response()->json(['message' => 'Server not found'], 404);
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Unauthorized'], 401);
}
if ($server->isFunctional() === false) {
auditLogWebhookFailure('sentinel', 'server_not_functional', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Server is not functional'], 401);
}
if ($server->settings->sentinel_token !== $naked_token) {
auditLogWebhookFailure('sentinel', 'token_mismatch', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Unauthorized'], 401);
}
$validator = Validator::make($request->all(), [
'containers' => ['present', 'array'],
]);
if ($validator->fails()) {
return response()->json(serializeApiResponse([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
]), 422);
}
$data = $request->all();
// Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
$server->sentinelHeartbeat();
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);
}
/**
* Decide whether PushServerUpdateJob should be dispatched for this push.
*
* Dispatches when: first push (no cached hash), the container state changed,
* or the force window elapsed.
*/
private function shouldDispatchUpdate(Server $server, array $data): bool
{
$hash = $this->containerStateHash($data);
$hashKey = "sentinel:push-hash:{$server->id}";
$forceKey = "sentinel:push-force:{$server->id}";
$lockKey = "sentinel:push-lock:{$server->id}";
try {
return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
$cachedHash = Cache::get($hashKey);
$forceActive = Cache::has($forceKey);
$shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
if ($shouldDispatch) {
// Day-long TTL bounds memory if a server stops pushing entirely.
Cache::put($hashKey, $hash, now()->addDay());
Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
}
return $shouldDispatch;
});
} catch (LockTimeoutException) {
return false;
}
}
/**
* Build a stable hash of container state.
*
* Covers [name, state] only metrics, filesystem_usage_root, and
* health_status are excluded on purpose. Disk % churns constantly, and
* 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.
*/
private function containerStateHash(array $data): string
{
$containers = collect(data_get($data, 'containers', []))
->map(fn ($c) => [
'name' => data_get($c, 'name'),
'state' => data_get($c, 'state'),
])
->sortBy('name')
->values()
->all();
return hash('xxh128', json_encode($containers));
}
}

View file

@ -13,6 +13,7 @@
use App\Models\Project; use App\Models\Project;
use App\Models\Server as ModelsServer; use App\Models\Server as ModelsServer;
use App\Rules\ValidServerIp; use App\Rules\ValidServerIp;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Stringable; use Stringable;
@ -477,7 +478,7 @@ public function create_server(Request $request)
} }
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof JsonResponse) {
return $return; return $return;
} }
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
@ -564,6 +565,14 @@ public function create_server(Request $request)
ValidateServer::dispatch($server); ValidateServer::dispatch($server);
} }
auditLog('api.server.created', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'ip' => $server->ip,
'is_build_server' => (bool) $request->is_build_server,
]);
return response()->json([ return response()->json([
'uuid' => $server->uuid, 'uuid' => $server->uuid,
])->setStatusCode(201); ])->setStatusCode(201);
@ -603,6 +612,7 @@ public function create_server(Request $request)
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'], 'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'], 'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'], 'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
], ],
), ),
), ),
@ -639,7 +649,7 @@ public function create_server(Request $request)
)] )]
public function update_server(Request $request) public function update_server(Request $request)
{ {
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']; $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout'];
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -647,7 +657,7 @@ public function update_server(Request $request)
} }
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof JsonResponse) {
return $return; return $return;
} }
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
@ -665,6 +675,7 @@ public function update_server(Request $request)
'deployment_queue_limit' => 'integer|min:1', 'deployment_queue_limit' => 'integer|min:1',
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100', 'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
'server_disk_usage_check_frequency' => 'string', 'server_disk_usage_check_frequency' => 'string',
'connection_timeout' => 'integer|min:1|max:300',
]); ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -709,7 +720,7 @@ public function update_server(Request $request)
], 422); ], 422);
} }
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']); $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
if (! empty($advancedSettings)) { if (! empty($advancedSettings)) {
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
} }
@ -718,6 +729,13 @@ public function update_server(Request $request)
ValidateServer::dispatch($server); ValidateServer::dispatch($server);
} }
auditLog('api.server.updated', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([ return response()->json([
'uuid' => $server->uuid, 'uuid' => $server->uuid,
])->setStatusCode(201); ])->setStatusCode(201);
@ -807,6 +825,9 @@ public function delete_server(Request $request)
} }
} }
$deletedUuid = $server->uuid;
$deletedName = $server->name;
$deletedIp = $server->ip;
$server->delete(); $server->delete();
DeleteServer::dispatch( DeleteServer::dispatch(
$server->id, $server->id,
@ -816,6 +837,14 @@ public function delete_server(Request $request)
$server->team_id $server->team_id
); );
auditLog('api.server.deleted', [
'team_id' => $teamId,
'server_uuid' => $deletedUuid,
'server_name' => $deletedName,
'ip' => $deletedIp,
'force' => $force,
]);
return response()->json(['message' => 'Server deleted.']); return response()->json(['message' => 'Server deleted.']);
} }
@ -881,6 +910,12 @@ public function validate_server(Request $request)
} }
ValidateServer::dispatch($server); ValidateServer::dispatch($server);
auditLog('api.server.validated', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
]);
return response()->json(['message' => 'Validation started.'], 201); return response()->json(['message' => 'Validation started.'], 201);
} }
} }

View file

@ -221,7 +221,7 @@ public function services(Request $request)
type: 'object', type: 'object',
properties: [ properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'], 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
], ],
), ),
], ],
@ -486,6 +486,14 @@ public function create_service(Request $request)
StartService::dispatch($service); StartService::dispatch($service);
} }
auditLog('api.service.created', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'service_type' => $oneClickServiceName ?? null,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json([ return response()->json([
'uuid' => $service->uuid, 'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@ -650,6 +658,14 @@ public function create_service(Request $request)
StartService::dispatch($service); StartService::dispatch($service);
} }
auditLog('api.service.created', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'service_type' => 'docker_compose',
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json([ return response()->json([
'uuid' => $service->uuid, 'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@ -792,6 +808,12 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true) dockerCleanup: $request->boolean('docker_cleanup', true)
); );
auditLog('api.service.deleted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
]);
return response()->json([ return response()->json([
'message' => 'Service deletion request queued.', 'message' => 'Service deletion request queued.',
]); ]);
@ -843,7 +865,7 @@ public function delete_by_uuid(Request $request)
type: 'object', type: 'object',
properties: [ properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'], 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
], ],
), ),
], ],
@ -1046,6 +1068,13 @@ public function update_by_uuid(Request $request)
StartService::dispatch($service); StartService::dispatch($service);
} }
auditLog('api.service.updated', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([ return response()->json([
'uuid' => $service->uuid, 'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@ -1255,6 +1284,13 @@ public function update_env_by_uuid(Request $request)
} }
$env->save(); $env->save();
auditLog('api.service.env_updated', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201); return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} }
@ -1384,6 +1420,12 @@ public function create_bulk_envs(Request $request)
$updatedEnvs->push($this->removeSensitiveData($env)); $updatedEnvs->push($this->removeSensitiveData($env));
} }
auditLog('api.service.env_bulk_upserted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'env_count' => $updatedEnvs->count(),
]);
return response()->json($updatedEnvs)->setStatusCode(201); return response()->json($updatedEnvs)->setStatusCode(201);
} }
@ -1506,6 +1548,13 @@ public function create_env(Request $request)
'comment' => $request->comment ?? null, 'comment' => $request->comment ?? null,
]); ]);
auditLog('api.service.env_created', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201); return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} }
@ -1591,8 +1640,17 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404); return response()->json(['message' => 'Environment variable not found.'], 404);
} }
$envKey = $env->key;
$envUuid = $env->uuid;
$env->forceDelete(); $env->forceDelete();
auditLog('api.service.env_deleted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'env_uuid' => $envUuid,
'env_key' => $envKey,
]);
return response()->json(['message' => 'Environment variable deleted.']); return response()->json(['message' => 'Environment variable deleted.']);
} }
@ -1668,6 +1726,12 @@ public function action_deploy(Request $request)
} }
StartService::dispatch($service); StartService::dispatch($service);
auditLog('api.service.deployed', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
]);
return response()->json( return response()->json(
[ [
'message' => 'Service starting request queued.', 'message' => 'Service starting request queued.',
@ -1759,6 +1823,13 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true); $dockerCleanup = $request->boolean('docker_cleanup', true);
StopService::dispatch($service, false, $dockerCleanup); StopService::dispatch($service, false, $dockerCleanup);
auditLog('api.service.stopped', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'docker_cleanup' => $dockerCleanup,
]);
return response()->json( return response()->json(
[ [
'message' => 'Service stopping request queued.', 'message' => 'Service stopping request queued.',
@ -1846,6 +1917,13 @@ public function action_restart(Request $request)
$pullLatest = $request->boolean('latest'); $pullLatest = $request->boolean('latest');
RestartService::dispatch($service, $pullLatest); RestartService::dispatch($service, $pullLatest);
auditLog('api.service.restarted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'pull_latest' => $pullLatest,
]);
return response()->json( return response()->json(
[ [
'message' => 'Service restarting request queued.', 'message' => 'Service restarting request queued.',
@ -2018,7 +2096,7 @@ public function create_storage(Request $request): JsonResponse
'resource_uuid' => 'required|string', 'resource_uuid' => 'required|string',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string', 'mount_path' => 'required|string',
'host_path' => 'string|nullable', 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable', 'content' => 'string|nullable',
'is_directory' => 'boolean', 'is_directory' => 'boolean',
'fs_path' => 'string', 'fs_path' => 'string',
@ -2126,6 +2204,15 @@ public function create_storage(Request $request): JsonResponse
]); ]);
} }
auditLog('api.service.storage_created', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201); return response()->json($storage, 201);
} }
@ -2227,7 +2314,7 @@ public function update_storage(Request $request): JsonResponse
'is_preview_suffix_enabled' => 'boolean', 'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string', 'mount_path' => 'string',
'host_path' => 'string|nullable', 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable', 'content' => 'string|nullable',
]); ]);
@ -2354,6 +2441,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save(); $storage->save();
auditLog('api.service.storage_updated', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage); return response()->json($storage);
} }
@ -2454,8 +2550,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer(); $storage->deleteStorageOnServer();
} }
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
$storageMountPath = $storage->mount_path ?? null;
$storage->delete(); $storage->delete();
auditLog('api.service.storage_deleted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'storage_uuid' => $storageUuid,
'storage_type' => $storageType,
'mount_path' => $storageMountPath,
]);
return response()->json(['message' => 'Storage deleted.']); return response()->json(['message' => 'Storage deleted.']);
} }
} }

View file

@ -6,8 +6,8 @@
use App\Models\TeamInvitation; use App\Models\TeamInvitation;
use App\Models\User; use App\Models\User;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController; use Illuminate\Routing\Controller as BaseController;
@ -39,9 +39,29 @@ public function verify()
return view('auth.verify-email'); return view('auth.verify-email');
} }
public function email_verify(EmailVerificationRequest $request) public function email_verify(Request $request)
{ {
$request->fulfill(); if (! $request->hasValidSignature()) {
abort(403);
}
$user = auth()->user();
if (! $user) {
abort(403);
}
if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) {
abort(403);
}
if (! hash_equals((string) $request->route('hash'), hash('sha256', $user->getEmailForVerification()))) {
abort(403);
}
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified($user));
}
return redirect(RouteServiceProvider::HOME); return redirect(RouteServiceProvider::HOME);
} }
@ -94,10 +114,6 @@ public function link()
} else { } else {
$team = $user->teams()->first(); $team = $user->teams()->first();
} }
if (is_null(data_get($user, 'email_verified_at'))) {
$user->email_verified_at = now();
$user->save();
}
Auth::login($user); Auth::login($user);
session(['currentTeam' => $team]); session(['currentTeam' => $team]);

View file

@ -19,7 +19,12 @@ public function callback(string $provider)
{ {
try { try {
$oauthUser = get_socialite_provider($provider)->user(); $oauthUser = get_socialite_provider($provider)->user();
$user = User::whereEmail($oauthUser->email)->first(); $email = trim((string) $oauthUser->email);
if ($email === '') {
abort(403, 'OAuth provider did not return an email address');
}
$email = strtolower($email);
$user = User::whereEmail($email)->first();
if (! $user) { if (! $user) {
$settings = instanceSettings(); $settings = instanceSettings();
if (! $settings->is_registration_enabled) { if (! $settings->is_registration_enabled) {
@ -28,7 +33,7 @@ public function callback(string $provider)
$user = User::create([ $user = User::create([
'name' => $oauthUser->name, 'name' => $oauthUser->name,
'email' => $oauthUser->email, 'email' => $email,
]); ]);
} }
Auth::login($user); Auth::login($user);

View file

@ -11,6 +11,27 @@
class UploadController extends BaseController class UploadController extends BaseController
{ {
private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
private const ALLOWED_EXTENSIONS = [
'sql',
'sql.gz',
'gz',
'zip',
'tar',
'tar.gz',
'tgz',
'dump',
'bak',
'bson',
'bson.gz',
'archive',
'archive.gz',
'bz2',
'xz',
'dmp',
];
public function upload(Request $request) public function upload(Request $request)
{ {
$databaseIdentifier = request()->route('databaseUuid'); $databaseIdentifier = request()->route('databaseUuid');
@ -18,6 +39,22 @@ public function upload(Request $request)
if (is_null($resource)) { if (is_null($resource)) {
return response()->json(['error' => 'You do not have permission for this database'], 500); return response()->json(['error' => 'You do not have permission for this database'], 500);
} }
$chunk = $request->file('file');
$originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null;
if (blank($originalName) || ! self::hasAllowedExtension($originalName)) {
return response()->json([
'error' => 'Unsupported file type. Allowed extensions: '.implode(', ', self::ALLOWED_EXTENSIONS),
], 422);
}
$declaredTotalSize = (int) $request->input('dzTotalFilesize', 0);
if ($declaredTotalSize > self::MAX_BYTES) {
return response()->json([
'error' => 'File exceeds maximum allowed size of '.self::formatMaxSize().'.',
], 422);
}
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request)); $receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
if ($receiver->isUploaded() === false) { if ($receiver->isUploaded() === false) {
@ -40,29 +77,20 @@ public function upload(Request $request)
'status' => true, 'status' => true,
]); ]);
} }
// protected function saveFileToS3($file)
// {
// $fileName = $this->createFilename($file);
// $disk = Storage::disk('s3');
// // It's better to use streaming Streaming (laravel 5.4+)
// $disk->putFileAs('photos', $file, $fileName);
// // for older laravel
// // $disk->put($fileName, file_get_contents($file), 'public');
// $mime = str_replace('/', '-', $file->getMimeType());
// // We need to delete the file when uploaded to s3
// unlink($file->getPathname());
// return response()->json([
// 'path' => $disk->url($fileName),
// 'name' => $fileName,
// 'mime_type' => $mime
// ]);
// }
protected function saveFile(UploadedFile $file, string $resourceIdentifier) protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{ {
$originalName = $file->getClientOriginalName();
$size = $file->getSize();
if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) {
@unlink($file->getPathname());
return response()->json([
'error' => 'Uploaded file failed validation.',
], 422);
}
$mime = str_replace('/', '-', $file->getMimeType()); $mime = str_replace('/', '-', $file->getMimeType());
$filePath = "upload/{$resourceIdentifier}"; $filePath = "upload/{$resourceIdentifier}";
$finalPath = storage_path('app/'.$filePath); $finalPath = storage_path('app/'.$filePath);
@ -73,13 +101,30 @@ protected function saveFile(UploadedFile $file, string $resourceIdentifier)
]); ]);
} }
protected function createFilename(UploadedFile $file) private static function hasAllowedExtension(string $name): bool
{ {
$extension = $file->getClientOriginalExtension(); $lower = strtolower($name);
$filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension $suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a));
$filename .= '_'.md5(time()).'.'.$extension; foreach ($suffixes as $suffix) {
if (! str_ends_with($lower, $suffix)) {
continue;
}
return $filename; $stem = substr($lower, 0, -strlen($suffix));
if ($stem !== '' && ! str_ends_with($stem, '.')) {
return true;
}
return false;
}
return false;
}
private static function formatMaxSize(): string
{
return (self::MAX_BYTES / (1024 * 1024 * 1024)).' GiB';
} }
} }

View file

@ -4,6 +4,8 @@
use App\Actions\Application\CleanupPreviewDeployment; use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use Exception; use Exception;
@ -12,6 +14,9 @@
class Bitbucket extends Controller class Bitbucket extends Controller
{ {
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request) public function manual(Request $request)
{ {
try { try {
@ -31,6 +36,16 @@ public function manual(Request $request)
$branch = data_get($payload, 'push.changes.0.new.name'); $branch = data_get($payload, 'push.changes.0.new.name');
$full_name = data_get($payload, 'repository.full_name'); $full_name = data_get($payload, 'repository.full_name');
$commit = data_get($payload, 'push.changes.0.new.target.hash'); $commit = data_get($payload, 'push.changes.0.new.target.hash');
// Bitbucket webhooks ship up to 5 commits per change. Larger pushes
// are evaluated only on the visible 5.
$skip_deploy_commits = self::shouldSkipDeploy(
collect(data_get($payload, 'push.changes', []))
->flatMap(fn ($change) => data_get($change, 'commits', []))
->pluck('message')
->filter()
->values()
->all()
);
if (! $branch) { if (! $branch) {
return response([ return response([
@ -45,10 +60,18 @@ public function manual(Request $request)
$full_name = data_get($payload, 'repository.full_name'); $full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'pullrequest.id'); $pull_request_id = data_get($payload, 'pullrequest.id');
$pull_request_html_url = data_get($payload, 'pullrequest.links.html.href'); $pull_request_html_url = data_get($payload, 'pullrequest.links.html.href');
$pull_request_title = data_get($payload, 'pullrequest.title');
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$commit = data_get($payload, 'pullrequest.source.commit.hash'); $commit = data_get($payload, 'pullrequest.source.commit.hash');
} }
$applications = Application::where('git_repository', 'like', "%$full_name%"); $full_name = $this->manualWebhookRepositoryFullName($full_name);
$applications = $applications->where('git_branch', $branch)->get(); if ($full_name === null) {
return response([
'status' => 'failed',
'message' => 'Nothing to do. Invalid repository.',
]);
}
$applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
return response([ return response([
'status' => 'failed', 'status' => 'failed',
@ -57,16 +80,41 @@ public function manual(Request $request)
} }
foreach ($applications as $application) { foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket'); $webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
if (empty($webhook_secret)) {
auditLogWebhookFailure('bitbucket', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
$payload = $request->getContent(); $payload = $request->getContent();
[$algo, $hash] = explode('=', $x_bitbucket_token, 2); $parts = explode('=', $x_bitbucket_token, 2);
$payloadHash = hash_hmac($algo, $payload, $webhook_secret); if (count($parts) !== 2 || $parts[0] !== 'sha256') {
if (! hash_equals($hash, $payloadHash) && ! isDev()) { auditLogWebhookFailure('bitbucket', 'malformed_signature', [
$return_payloads->push([ 'application_uuid' => $application->uuid,
'application' => $application->name, 'application_name' => $application->name,
'status' => 'failed', 'repository' => $full_name ?? null,
'message' => 'Invalid signature.', 'event' => $x_bitbucket_event,
]); ]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
$hash = $parts[1];
$payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
auditLogWebhookFailure('bitbucket', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue; continue;
} }
@ -82,6 +130,17 @@ public function manual(Request $request)
} }
if ($x_bitbucket_event === 'repo:push') { if ($x_bitbucket_event === 'repo:push') {
if ($application->isDeployable()) { if ($application->isDeployable()) {
if ($skip_deploy_commits ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue;
}
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
$result = queue_application_deployment( $result = queue_application_deployment(
application: $application, application: $application,
@ -99,6 +158,15 @@ public function manual(Request $request)
'message' => $result['message'], 'message' => $result['message'],
]); ]);
} else { } else {
auditLog('webhook.deployment.queued', [
'provider' => 'bitbucket',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'commit' => $commit,
'repository' => $full_name ?? null,
]);
$return_payloads->push([ $return_payloads->push([
'application' => $application->name, 'application' => $application->name,
'status' => 'success', 'status' => 'success',
@ -115,6 +183,15 @@ public function manual(Request $request)
} }
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') { if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') {
if ($application->isPRDeployable()) { if ($application->isPRDeployable()) {
if ($skip_deploy_pr ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
]);
continue;
}
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) { if (! $found) {

View file

@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Webhook\Concerns;
trait DetectsSkipDeployCommits
{
/**
* Returns true if there is at least one non-empty message and every message
* contains [skip cd] or [skip ci] (case-insensitive).
*
* Accepts commit messages from a push payload. Null/empty entries are
* filtered before evaluation.
*
* @param array<int, string|null> $messages
*/
public static function shouldSkipDeploy(array $messages): bool
{
$messages = array_values(array_filter($messages, fn ($m) => filled($m)));
if (empty($messages)) {
return false;
}
foreach ($messages as $message) {
$lower = strtolower((string) $message);
if (! str_contains($lower, '[skip cd]') && ! str_contains($lower, '[skip ci]')) {
return false;
}
}
return true;
}
/**
* Returns true if at least one non-empty message contains [skip cd] or
* [skip ci]. Used for PR/MR title + latest-commit signals where any one
* marker should trigger the skip.
*
* @param array<int, string|null> $messages
*/
public static function shouldSkipDeployAny(array $messages): bool
{
foreach ($messages as $message) {
if (! filled($message)) {
continue;
}
$lower = strtolower((string) $message);
if (str_contains($lower, '[skip cd]') || str_contains($lower, '[skip ci]')) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Webhook\Concerns;
use App\Models\Application;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
trait MatchesManualWebhookApplications
{
protected function manualWebhookRepositoryFullName(mixed $fullName): ?string
{
if (! is_string($fullName)) {
return null;
}
$fullName = trim($fullName, " \t\n\r\0\x0B/");
if ($fullName === '') {
return null;
}
if (! preg_match('/\A[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\z/', $fullName)) {
return null;
}
return $this->normalizeManualWebhookRepositoryPath($fullName);
}
/**
* @return Collection<int, Application>
*/
protected function manualWebhookApplications(Builder $query, string $fullName): Collection
{
return $query->get()
->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName))
->values();
}
protected function manualWebhookRepositoryMatches(?string $gitRepository, string $fullName): bool
{
$repositoryPath = $this->canonicalManualWebhookRepository($gitRepository);
if ($repositoryPath === null) {
return false;
}
// Git hosts (GitHub, GitLab, Gitea, Bitbucket) treat owner/repo names
// case-insensitively, so compare the canonical paths case-insensitively.
return hash_equals(mb_strtolower($fullName), mb_strtolower($repositoryPath));
}
/**
* @return array{status: string, message: string}
*/
protected function unauthenticatedManualWebhookFailurePayload(): array
{
return [
'status' => 'failed',
'message' => 'Invalid signature.',
];
}
protected function canonicalManualWebhookRepository(?string $gitRepository): ?string
{
if (! is_string($gitRepository)) {
return null;
}
$gitRepository = trim($gitRepository);
if ($gitRepository === '') {
return null;
}
$path = null;
$parts = parse_url($gitRepository);
if (is_array($parts) && isset($parts['scheme'])) {
$path = data_get($parts, 'path');
} elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
$path = Str::after($gitRepository, ':');
// scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo".
// Strip the leading numeric port segment so the path matches the webhook
// payload's owner/repo, consistent with convertGitUrl() in shared.php.
$path = preg_replace('#^\d+/#', '', $path) ?? $path;
} else {
$path = $gitRepository;
}
if (! is_string($path) || $path === '') {
return null;
}
return $this->normalizeManualWebhookRepositoryPath($path);
}
protected function normalizeManualWebhookRepositoryPath(string $path): string
{
$path = trim($path);
$path = strtok($path, '?#') ?: $path;
$path = trim($path, '/');
$path = preg_replace('/\.git\z/i', '', $path) ?? $path;
return $path;
}
}

View file

@ -4,6 +4,8 @@
use App\Actions\Application\CleanupPreviewDeployment; use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use Exception; use Exception;
@ -13,6 +15,9 @@
class Gitea extends Controller class Gitea extends Controller
{ {
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request) public function manual(Request $request)
{ {
try { try {
@ -40,40 +45,60 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed'); $removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified'); $modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
} }
if ($x_gitea_event === 'pull_request') { if ($x_gitea_event === 'pull_request') {
$action = data_get($payload, 'action'); $action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name'); $full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number'); $pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url'); $pull_request_html_url = data_get($payload, 'pull_request.html_url');
$pull_request_title = data_get($payload, 'pull_request.title');
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$branch = data_get($payload, 'pull_request.head.ref'); $branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref'); $base_branch = data_get($payload, 'pull_request.base.ref');
} }
if (! $branch) { if (! $branch) {
return response('Nothing to do. No branch found in the request.'); return response('Nothing to do. No branch found in the request.');
} }
$applications = Application::where('git_repository', 'like', "%$full_name%"); $full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
return response('Nothing to do. Invalid repository.');
}
$applications = Application::query();
if ($x_gitea_event === 'push') { if ($x_gitea_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get(); $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name."); return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
} }
} }
if ($x_gitea_event === 'pull_request') { if ($x_gitea_event === 'pull_request') {
$applications = $applications->where('git_branch', $base_branch)->get(); $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'."); return response("Nothing to do. No applications found with branch '$base_branch'.");
} }
} }
foreach ($applications as $application) { foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea'); $webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
if (empty($webhook_secret)) {
auditLogWebhookFailure('gitea', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_gitea_event,
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
$return_payloads->push([ auditLogWebhookFailure('gitea', 'invalid_signature', [
'application' => $application->name, 'application_uuid' => $application->uuid,
'status' => 'failed', 'application_name' => $application->name,
'message' => 'Invalid signature.', 'repository' => $full_name ?? null,
'event' => $x_gitea_event,
]); ]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue; continue;
} }
@ -91,6 +116,17 @@ public function manual(Request $request)
if ($application->isDeployable()) { if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) { if ($is_watch_path_triggered || blank($application->watch_paths)) {
if ($skip_deploy_commits ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue;
}
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
$result = queue_application_deployment( $result = queue_application_deployment(
application: $application, application: $application,
@ -108,6 +144,15 @@ public function manual(Request $request)
'message' => $result['message'], 'message' => $result['message'],
]); ]);
} else { } else {
auditLog('webhook.deployment.queued', [
'provider' => 'gitea',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
$return_payloads->push([ $return_payloads->push([
'status' => 'success', 'status' => 'success',
'message' => 'Deployment queued.', 'message' => 'Deployment queued.',
@ -140,6 +185,15 @@ public function manual(Request $request)
if ($x_gitea_event === 'pull_request') { if ($x_gitea_event === 'pull_request') {
if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') { if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') {
if ($application->isPRDeployable()) { if ($application->isPRDeployable()) {
if ($skip_deploy_pr ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
]);
continue;
}
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) { if (! $found) {

View file

@ -3,6 +3,8 @@
namespace App\Http\Controllers\Webhook; namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Jobs\GithubAppPermissionJob; use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook; use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application; use App\Models\Application;
@ -10,12 +12,16 @@
use App\Models\PrivateKey; use App\Models\PrivateKey;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
class Github extends Controller class Github extends Controller
{ {
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request) public function manual(Request $request)
{ {
try { try {
@ -43,17 +49,20 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed'); $removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified'); $modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
} }
if ($x_github_event === 'pull_request') { if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action'); $action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name'); $full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number'); $pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url'); $pull_request_html_url = data_get($payload, 'pull_request.html_url');
$pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref'); $branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref'); $base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before'); $before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association'); $author_association = data_get($payload, 'pull_request.author_association');
$is_fork_pull_request = $this->isForkPullRequest($payload);
} }
if (! in_array($x_github_event, ['push', 'pull_request'])) { if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported."); return response("Nothing to do. Event '$x_github_event' is not supported.");
@ -61,15 +70,19 @@ public function manual(Request $request)
if (! $branch) { if (! $branch) {
return response('Nothing to do. No branch found in the request.'); return response('Nothing to do. No branch found in the request.');
} }
$applications = Application::where('git_repository', 'like', "%$full_name%"); $full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
return response('Nothing to do. Invalid repository.');
}
$applications = Application::query();
if ($x_github_event === 'push') { if ($x_github_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get(); $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name."); return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
} }
} }
if ($x_github_event === 'pull_request') { if ($x_github_event === 'pull_request') {
$applications = $applications->where('git_branch', $base_branch)->get(); $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'."); return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
} }
@ -81,13 +94,26 @@ public function manual(Request $request)
foreach ($applicationsByServer as $serverId => $serverApplications) { foreach ($applicationsByServer as $serverId => $serverApplications) {
foreach ($serverApplications as $application) { foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github'); $webhook_secret = data_get($application, 'manual_webhook_secret_github');
if (empty($webhook_secret)) {
auditLogWebhookFailure('github', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
$return_payloads->push([ auditLogWebhookFailure('github', 'invalid_signature', [
'application' => $application->name, 'application_uuid' => $application->uuid,
'status' => 'failed', 'application_name' => $application->name,
'message' => 'Invalid signature.', 'repository' => $full_name ?? null,
'mode' => 'manual',
]); ]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue; continue;
} }
@ -105,6 +131,17 @@ public function manual(Request $request)
if ($application->isDeployable()) { if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) { if ($is_watch_path_triggered || blank($application->watch_paths)) {
if ($skip_deploy_commits ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue;
}
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
$result = queue_application_deployment( $result = queue_application_deployment(
application: $application, application: $application,
@ -122,6 +159,15 @@ public function manual(Request $request)
'message' => $result['message'], 'message' => $result['message'],
]); ]);
} else { } else {
auditLog('webhook.deployment.queued', [
'provider' => 'github',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
$return_payloads->push([ $return_payloads->push([
'application' => $application->name, 'application' => $application->name,
'status' => 'success', 'status' => 'success',
@ -171,11 +217,13 @@ public function manual(Request $request)
action: $action, action: $action,
pullRequestId: $pull_request_id, pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url, pullRequestHtmlUrl: $pull_request_html_url,
pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha, beforeSha: $before_sha,
afterSha: $after_sha, afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association, authorAssociation: $author_association,
fullName: $full_name, fullName: $full_name,
isForkPullRequest: $is_fork_pull_request ?? false,
); );
$return_payloads->push([ $return_payloads->push([
@ -215,6 +263,13 @@ public function normal(Request $request)
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') { if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) { if (! hash_equals($x_hub_signature_256, $hmac)) {
auditLogWebhookFailure('github', 'invalid_signature', [
'mode' => 'app',
'github_app_id' => $github_app->id,
'github_app_name' => $github_app->name,
'installation_target_id' => $x_github_hook_installation_target_id,
]);
return response('Invalid signature.'); return response('Invalid signature.');
} }
} }
@ -237,17 +292,20 @@ public function normal(Request $request)
$removed_files = data_get($payload, 'commits.*.removed'); $removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified'); $modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
} }
if ($x_github_event === 'pull_request') { if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action'); $action = data_get($payload, 'action');
$id = data_get($payload, 'repository.id'); $id = data_get($payload, 'repository.id');
$pull_request_id = data_get($payload, 'number'); $pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url'); $pull_request_html_url = data_get($payload, 'pull_request.html_url');
$pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref'); $branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref'); $base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before'); $before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association'); $author_association = data_get($payload, 'pull_request.author_association');
$is_fork_pull_request = $this->isForkPullRequest($payload);
} }
if (! in_array($x_github_event, ['push', 'pull_request'])) { if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported."); return response("Nothing to do. Event '$x_github_event' is not supported.");
@ -291,6 +349,17 @@ public function normal(Request $request)
if ($application->isDeployable()) { if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) { if ($is_watch_path_triggered || blank($application->watch_paths)) {
if ($skip_deploy_commits ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue;
}
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
$result = queue_application_deployment( $result = queue_application_deployment(
application: $application, application: $application,
@ -302,6 +371,17 @@ public function normal(Request $request)
if ($result['status'] === 'queue_full') { if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60); return response($result['message'], 429)->header('Retry-After', 60);
} }
if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
auditLog('webhook.deployment.queued', [
'provider' => 'github',
'mode' => 'app',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'commit' => data_get($payload, 'after'),
'github_app_id' => $github_app->id,
]);
}
$return_payloads->push([ $return_payloads->push([
'status' => $result['status'], 'status' => $result['status'],
'message' => $result['message'], 'message' => $result['message'],
@ -351,11 +431,13 @@ public function normal(Request $request)
action: $action, action: $action,
pullRequestId: $pull_request_id, pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url, pullRequestHtmlUrl: $pull_request_html_url,
pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha, beforeSha: $before_sha,
afterSha: $after_sha, afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association, authorAssociation: $author_association,
fullName: $full_name, fullName: $full_name,
isForkPullRequest: $is_fork_pull_request ?? false,
); );
$return_payloads->push([ $return_payloads->push([
@ -373,55 +455,172 @@ public function normal(Request $request)
} }
} }
/**
* Determine whether a pull_request webhook payload originates from a fork.
*
* GitHub's `author_association` is not a reliable trust signal (it grants
* CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
* detection is gated on whether the PR crosses repository boundaries.
*
* The repository id comparison is the canonical signal; the `head.repo.fork`
* flag and a case-insensitive full_name comparison are fallbacks for payloads
* where the ids are unavailable (e.g. a deleted head repository).
*/
private function isForkPullRequest(mixed $payload): bool
{
$headRepoId = data_get($payload, 'pull_request.head.repo.id');
$baseRepoId = data_get($payload, 'pull_request.base.repo.id');
if ($headRepoId !== null && $baseRepoId !== null) {
return (string) $headRepoId !== (string) $baseRepoId;
}
if (data_get($payload, 'pull_request.head.repo.fork') === true) {
return true;
}
$headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name');
$baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name');
if (is_string($headRepoFullName) && is_string($baseRepoFullName)) {
return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName);
}
return false;
}
public function redirect(Request $request) public function redirect(Request $request)
{ {
try { $code = (string) $request->query('code', '');
$code = $request->get('code'); abort_if(blank($code), 422, 'Missing GitHub App manifest code.');
$state = $request->get('state');
$github_app = GithubApp::where('uuid', $state)->firstOrFail();
$api_url = data_get($github_app, 'api_url');
$data = Http::withBody(null)->accept('application/vnd.github+json')->post("$api_url/app-manifests/$code/conversions")->throw()->json();
$id = data_get($data, 'id');
$slug = data_get($data, 'slug');
$client_id = data_get($data, 'client_id');
$client_secret = data_get($data, 'client_secret');
$private_key = data_get($data, 'pem');
$webhook_secret = data_get($data, 'webhook_secret');
$private_key = PrivateKey::create([
'name' => "github-app-{$slug}",
'private_key' => $private_key,
'team_id' => $github_app->team_id,
'is_git_related' => true,
]);
$github_app->name = $slug;
$github_app->app_id = $id;
$github_app->client_id = $client_id;
$github_app->client_secret = $client_secret;
$github_app->webhook_secret = $webhook_secret;
$github_app->private_key_id = $private_key->id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); $github_app = $this->consumeGithubAppSetupState(
} catch (Exception $e) { request: $request,
return handleError($e); state: (string) $request->query('state', ''),
} action: 'manifest',
);
abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.');
$api_url = data_get($github_app, 'api_url');
$data = Http::withBody(null)
->accept('application/vnd.github+json')
->timeout(10)
->connectTimeout(5)
->post("$api_url/app-manifests/$code/conversions")
->throw()
->json();
$id = data_get($data, 'id');
$slug = data_get($data, 'slug');
$client_id = data_get($data, 'client_id');
$client_secret = data_get($data, 'client_secret');
$private_key = data_get($data, 'pem');
$webhook_secret = data_get($data, 'webhook_secret');
abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.');
$private_key = PrivateKey::create([
'name' => "github-app-{$slug}",
'private_key' => $private_key,
'team_id' => $github_app->team_id,
'is_git_related' => true,
]);
$github_app->name = $slug;
$github_app->app_id = $id;
$github_app->client_id = $client_id;
$github_app->client_secret = $client_secret;
$github_app->webhook_secret = $webhook_secret;
$github_app->private_key_id = $private_key->id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
} }
public function install(Request $request) public function install(Request $request)
{ {
try { $source = (string) $request->query('source', '');
$installation_id = $request->get('installation_id'); abort_if(blank($source), 404);
$source = $request->get('source');
$setup_action = $request->get('setup_action');
$github_app = GithubApp::where('uuid', $source)->firstOrFail();
if ($setup_action === 'install') {
$github_app->installation_id = $installation_id;
$github_app->save();
}
$github_app = GithubApp::ownedByCurrentTeam()->where('uuid', $source)->firstOrFail();
$setup_action = (string) $request->query('setup_action', '');
if ($setup_action !== 'install') {
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (Exception $e) { }
return handleError($e);
$installation_id = (string) $request->query('installation_id', '');
abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
abort_unless(
$this->githubInstallationBelongsToApp($github_app, $installation_id),
403,
'GitHub App installation could not be verified.'
);
$github_app->installation_id = $installation_id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
/**
* Verify that the given installation id actually belongs to this GitHub App.
*
* The installation id arrives as an untrusted query parameter on an
* unauthenticated-reachable GET callback, so it must be confirmed against
* the GitHub API using the App's own credentials before it is persisted.
*/
private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool
{
if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) {
return false;
}
try {
$jwt = generateGithubJwt($github_app);
$response = Http::withHeaders([
'Authorization' => "Bearer $jwt",
'Accept' => 'application/vnd.github+json',
])
->timeout(10)
->connectTimeout(5)
->get("{$github_app->api_url}/app/installations/{$installation_id}");
return $response->successful()
&& (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id;
} catch (\Throwable) {
return false;
} }
} }
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
{
abort_if(blank($state), 404);
$payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
abort_unless(is_array($payload), 404);
abort_unless(data_get($payload, 'action') === $action, 404);
$team_id = $request->user()?->currentTeam()?->id;
abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
return GithubApp::whereKey(data_get($payload, 'github_app_id'))
->where('team_id', data_get($payload, 'team_id'))
->firstOrFail();
}
private function githubAppSetupStateCacheKey(string $state): string
{
return 'github-app-setup-state:'.hash('sha256', $state);
}
private function githubAppHasManifestCredentials(GithubApp $github_app): bool
{
return filled($github_app->app_id)
|| filled($github_app->client_id)
|| filled($github_app->client_secret)
|| filled($github_app->webhook_secret)
|| filled($github_app->private_key_id);
}
} }

View file

@ -4,6 +4,8 @@
use App\Actions\Application\CleanupPreviewDeployment; use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use Exception; use Exception;
@ -13,6 +15,9 @@
class Gitlab extends Controller class Gitlab extends Controller
{ {
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request) public function manual(Request $request)
{ {
try { try {
@ -32,6 +37,9 @@ public function manual(Request $request)
} }
if (empty($x_gitlab_token)) { if (empty($x_gitlab_token)) {
auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
'event' => $x_gitlab_event,
]);
$return_payloads->push([ $return_payloads->push([
'status' => 'failed', 'status' => 'failed',
'message' => 'Invalid signature.', 'message' => 'Invalid signature.',
@ -58,6 +66,7 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed'); $removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified'); $modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
} }
if ($x_gitlab_event === 'merge_request') { if ($x_gitlab_event === 'merge_request') {
$action = data_get($payload, 'object_attributes.action'); $action = data_get($payload, 'object_attributes.action');
@ -66,6 +75,9 @@ public function manual(Request $request)
$full_name = data_get($payload, 'project.path_with_namespace'); $full_name = data_get($payload, 'project.path_with_namespace');
$pull_request_id = data_get($payload, 'object_attributes.iid'); $pull_request_id = data_get($payload, 'object_attributes.iid');
$pull_request_html_url = data_get($payload, 'object_attributes.url'); $pull_request_html_url = data_get($payload, 'object_attributes.url');
$pull_request_title = data_get($payload, 'object_attributes.title');
$latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
if (! $branch) { if (! $branch) {
$return_payloads->push([ $return_payloads->push([
'status' => 'failed', 'status' => 'failed',
@ -75,9 +87,18 @@ public function manual(Request $request)
return response($return_payloads); return response($return_payloads);
} }
} }
$applications = Application::where('git_repository', 'like', "%$full_name%"); $full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
$return_payloads->push([
'status' => 'failed',
'message' => 'Nothing to do. Invalid repository.',
]);
return response($return_payloads);
}
$applications = Application::query();
if ($x_gitlab_event === 'push') { if ($x_gitlab_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get(); $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
$return_payloads->push([ $return_payloads->push([
'status' => 'failed', 'status' => 'failed',
@ -88,7 +109,7 @@ public function manual(Request $request)
} }
} }
if ($x_gitlab_event === 'merge_request') { if ($x_gitlab_event === 'merge_request') {
$applications = $applications->where('git_branch', $base_branch)->get(); $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
$return_payloads->push([ $return_payloads->push([
'status' => 'failed', 'status' => 'failed',
@ -100,12 +121,25 @@ public function manual(Request $request)
} }
foreach ($applications as $application) { foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab'); $webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) { if (empty($webhook_secret)) {
$return_payloads->push([ auditLogWebhookFailure('gitlab', 'webhook_secret_missing', [
'application' => $application->name, 'application_uuid' => $application->uuid,
'status' => 'failed', 'application_name' => $application->name,
'message' => 'Invalid signature.', 'repository' => $full_name ?? null,
'event' => $x_gitlab_event,
]); ]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
auditLogWebhookFailure('gitlab', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_gitlab_event,
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue; continue;
} }
@ -123,6 +157,17 @@ public function manual(Request $request)
if ($application->isDeployable()) { if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) { if ($is_watch_path_triggered || blank($application->watch_paths)) {
if ($skip_deploy_commits ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue;
}
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
$result = queue_application_deployment( $result = queue_application_deployment(
application: $application, application: $application,
@ -141,6 +186,15 @@ public function manual(Request $request)
'application_name' => $application->name, 'application_name' => $application->name,
]); ]);
} else { } else {
auditLog('webhook.deployment.queued', [
'provider' => 'gitlab',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
$return_payloads->push([ $return_payloads->push([
'status' => 'success', 'status' => 'success',
'message' => 'Deployment queued.', 'message' => 'Deployment queued.',
@ -173,6 +227,15 @@ public function manual(Request $request)
if ($x_gitlab_event === 'merge_request') { if ($x_gitlab_event === 'merge_request') {
if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') { if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') {
if ($application->isPRDeployable()) { if ($application->isPRDeployable()) {
if ($skip_deploy_pr ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'PR title or latest commit contains [skip cd] or [skip ci]. Skipping preview deployment.',
]);
continue;
}
$deployment_uuid = new Cuid2; $deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) { if (! $found) {

View file

@ -6,6 +6,8 @@
use App\Jobs\StripeProcessJob; use App\Jobs\StripeProcessJob;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
class Stripe extends Controller class Stripe extends Controller
{ {
@ -14,7 +16,7 @@ public function events(Request $request)
try { try {
$webhookSecret = config('subscription.stripe_webhook_secret'); $webhookSecret = config('subscription.stripe_webhook_secret');
$signature = $request->header('Stripe-Signature'); $signature = $request->header('Stripe-Signature');
$event = \Stripe\Webhook::constructEvent( $event = Webhook::constructEvent(
$request->getContent(), $request->getContent(),
$signature, $signature,
$webhookSecret $webhookSecret
@ -22,6 +24,12 @@ public function events(Request $request)
StripeProcessJob::dispatch($event); StripeProcessJob::dispatch($event);
return response('Webhook received. Cool cool cool cool cool.', 200); return response('Webhook received. Cool cool cool cool cool.', 200);
} catch (SignatureVerificationException $e) {
auditLogWebhookFailure('stripe', 'invalid_signature', [
'error' => $e->getMessage(),
]);
return response($e->getMessage(), 400);
} catch (Exception $e) { } catch (Exception $e) {
return response($e->getMessage(), 400); return response($e->getMessage(), 400);
} }

View file

@ -2,7 +2,40 @@
namespace App\Http; namespace App\Http;
use App\Http\Middleware\ApiAbility;
use App\Http\Middleware\ApiSensitiveData;
use App\Http\Middleware\Authenticate;
use App\Http\Middleware\CanAccessTerminal;
use App\Http\Middleware\CanCreateResources;
use App\Http\Middleware\CanUpdateResource;
use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureMcpEnabled;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Http\Middleware\SetCacheHeaders;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class Kernel extends HttpKernel class Kernel extends HttpKernel
{ {
@ -14,13 +47,13 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string> * @var array<int, class-string|string>
*/ */
protected $middleware = [ protected $middleware = [
\App\Http\Middleware\TrustHosts::class, TrustHosts::class,
\App\Http\Middleware\TrustProxies::class, TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class, HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class, PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class, TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, ConvertEmptyStringsToNull::class,
]; ];
@ -31,21 +64,21 @@ class Kernel extends HttpKernel
*/ */
protected $middlewareGroups = [ protected $middlewareGroups = [
'web' => [ 'web' => [
\App\Http\Middleware\EncryptCookies::class, EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class, StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class, ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class, VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class, SubstituteBindings::class,
\App\Http\Middleware\CheckForcePasswordReset::class, CheckForcePasswordReset::class,
\App\Http\Middleware\DecideWhatToDoWithUser::class, DecideWhatToDoWithUser::class,
], ],
'api' => [ 'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api', ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class, SubstituteBindings::class,
], ],
]; ];
@ -57,22 +90,23 @@ class Kernel extends HttpKernel
* @var array<string, class-string|string> * @var array<string, class-string|string>
*/ */
protected $middlewareAliases = [ protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class, 'auth' => Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'auth.basic' => AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, 'auth.session' => AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'cache.headers' => SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class, 'can' => Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'guest' => RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, 'password.confirm' => RequirePassword::class,
'signed' => \App\Http\Middleware\ValidateSignature::class, 'signed' => ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'throttle' => ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, 'verified' => EnsureEmailIsVerified::class,
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class, 'abilities' => CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, 'ability' => CheckForAnyAbility::class,
'api.ability' => \App\Http\Middleware\ApiAbility::class, 'api.ability' => ApiAbility::class,
'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class, 'api.sensitive' => ApiSensitiveData::class,
'can.create.resources' => \App\Http\Middleware\CanCreateResources::class, 'can.create.resources' => CanCreateResources::class,
'can.update.resource' => \App\Http\Middleware\CanUpdateResource::class, 'can.update.resource' => CanUpdateResource::class,
'can.access.terminal' => \App\Http\Middleware\CanAccessTerminal::class, 'can.access.terminal' => CanAccessTerminal::class,
'mcp.enabled' => EnsureMcpEnabled::class,
]; ];
} }

View file

@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use Illuminate\Auth\AuthenticationException;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class ApiAbility extends CheckForAnyAbility class ApiAbility extends CheckForAnyAbility
@ -14,11 +15,22 @@ public function handle($request, $next, ...$abilities)
} }
return parent::handle($request, $next, ...$abilities); return parent::handle($request, $next, ...$abilities);
} catch (\Illuminate\Auth\AuthenticationException $e) { } catch (AuthenticationException $e) {
auditLog('api.auth.unauthenticated', [
'reason' => $e->getMessage(),
'required_abilities' => $abilities,
], 'warning');
return response()->json([ return response()->json([
'message' => 'Unauthenticated.', 'message' => 'Unauthenticated.',
], 401); ], 401);
} catch (\Exception $e) { } catch (\Exception $e) {
auditLog('api.auth.ability_denied', [
'required_abilities' => $abilities,
'token_id' => $request->user()?->currentAccessToken()?->id,
'reason' => $e->getMessage(),
], 'warning');
return response()->json([ return response()->json([
'message' => 'Missing required permissions: '.implode(', ', $abilities), 'message' => 'Missing required permissions: '.implode(', ', $abilities),
], 403); ], 403);

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use App\Models\InstanceSettings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureMcpEnabled
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! InstanceSettings::get()->is_mcp_server_enabled) {
abort(404);
}
return $next($request);
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace App\Jobs;
use App\Models\PersonalAccessToken;
use App\Models\Team;
use App\Models\User;
use App\Notifications\ApiTokenExpiringNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Laravel\Horizon\Contracts\Silenced;
class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Silenced
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 120;
public function handle(): void
{
PersonalAccessToken::query()
->whereNotNull('expires_at')
->where('expires_at', '>', now())
->where('expires_at', '<=', now()->addDay())
->whereNull('api_token_expiration_warning_sent_at')
->where('tokenable_type', User::class)
->chunkById(100, function ($tokens) {
foreach ($tokens as $token) {
if (! $token->team_id) {
continue;
}
$team = Team::find($token->team_id);
if (! $team) {
continue;
}
$warningSentAt = now();
$team->notify(new ApiTokenExpiringNotification($token));
$markedAsSent = PersonalAccessToken::query()
->whereKey($token->getKey())
->whereNotNull('expires_at')
->where('expires_at', '>', now())
->where('expires_at', '<=', now()->addDay())
->whereNull('api_token_expiration_warning_sent_at')
->update(['api_token_expiration_warning_sent_at' => $warningSentAt]);
if ($markedAsSent !== 1) {
continue;
}
$token->forceFill(['api_token_expiration_warning_sent_at' => $warningSentAt]);
}
});
}
}

View file

@ -33,6 +33,7 @@
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Sleep; use Illuminate\Support\Sleep;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use JsonException;
use Spatie\Url\Url; use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
use Throwable; use Throwable;
@ -48,6 +49,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json'; private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json';
private const RAILPACK_REPOSITORY_CONFIG_PATH = 'railpack.json';
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
public $tries = 1; public $tries = 1;
public $timeout = 3600; public $timeout = 3600;
@ -124,6 +129,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_nixpacks_args; private $env_nixpacks_args;
private $env_railpack_args;
private $docker_compose; private $docker_compose;
private $docker_compose_base64; private $docker_compose_base64;
@ -174,6 +181,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $dockerBuildkitSupported = false; private bool $dockerBuildkitSupported = false;
private bool $dockerBuildxAvailable = false;
private bool $dockerSecretsSupported = false; private bool $dockerSecretsSupported = false;
private bool $skip_build = false; private bool $skip_build = false;
@ -188,7 +197,7 @@ public function tags()
public function __construct(public int $application_deployment_queue_id) public function __construct(public int $application_deployment_queue_id)
{ {
$this->onQueue('high'); $this->onQueue(deployment_queue());
$this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id); $this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id);
$this->nixpacks_plan_json = collect([]); $this->nixpacks_plan_json = collect([]);
@ -414,6 +423,7 @@ private function detectBuildKitCapabilities(): void
if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) { if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
$this->dockerBuildkitSupported = false; $this->dockerBuildkitSupported = false;
$this->dockerBuildxAvailable = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+)."); $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+).");
return; return;
@ -427,8 +437,11 @@ private function detectBuildKitCapabilities(): void
if (trim($buildxAvailable) === 'available') { if (trim($buildxAvailable) === 'available') {
$this->dockerBuildkitSupported = true; $this->dockerBuildkitSupported = true;
$this->dockerBuildxAvailable = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}."); $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
} else { } else {
$this->dockerBuildxAvailable = false;
// Fallback: test DOCKER_BUILDKIT=1 support via --progress flag // Fallback: test DOCKER_BUILDKIT=1 support via --progress flag
$buildkitTest = instant_remote_process( $buildkitTest = instant_remote_process(
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"], ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"],
@ -461,6 +474,7 @@ private function detectBuildKitCapabilities(): void
} }
} catch (Exception $e) { } catch (Exception $e) {
$this->dockerBuildkitSupported = false; $this->dockerBuildkitSupported = false;
$this->dockerBuildxAvailable = false;
$this->dockerSecretsSupported = false; $this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
} }
@ -484,8 +498,12 @@ private function decide_what_to_do()
$this->deploy_dockerfile_buildpack(); $this->deploy_dockerfile_buildpack();
} elseif ($this->application->build_pack === 'static') { } elseif ($this->application->build_pack === 'static') {
$this->deploy_static_buildpack(); $this->deploy_static_buildpack();
} else { } elseif ($this->application->build_pack === 'nixpacks') {
$this->deploy_nixpacks_buildpack(); $this->deploy_nixpacks_buildpack();
} elseif ($this->application->build_pack === 'railpack') {
$this->deploy_railpack_buildpack();
} else {
throw new DeploymentException("Unsupported build pack: {$this->application->build_pack}");
} }
$this->post_deployment(); $this->post_deployment();
} }
@ -519,11 +537,6 @@ private function post_deployment()
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage()); \Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
} }
try {
$this->application->isConfigurationChanged(true);
} catch (Exception $e) {
\Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
} }
private function deploy_simple_dockerfile() private function deploy_simple_dockerfile()
@ -938,6 +951,37 @@ private function deploy_nixpacks_buildpack()
$this->rolling_update(); $this->rolling_update();
} }
private function deploy_railpack_buildpack(): void
{
if ($this->use_build_server) {
$this->server = $this->build_server;
}
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
$this->prepare_builder_image();
$this->check_git_if_build_needed();
$this->generate_image_names();
if (! $this->force_rebuild) {
$this->check_image_locally_or_remotely();
if ($this->should_skip_build()) {
return;
}
}
$this->clone_repository();
$this->cleanup_git();
$this->generate_compose_file();
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
$this->generate_build_env_variables();
$this->build_railpack_image();
// Save runtime environment variables AFTER the build
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
}
private function deploy_static_buildpack() private function deploy_static_buildpack()
{ {
if ($this->use_build_server) { if ($this->use_build_server) {
@ -1105,12 +1149,15 @@ private function generate_image_names()
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
} }
} elseif ($this->pull_request_id !== 0) { } elseif ($this->pull_request_id !== 0) {
$previewImageTag = $this->previewImageTag();
$previewBuildImageTag = $this->previewImageTag(build: true);
if ($this->application->docker_registry_image_name) { if ($this->application->docker_registry_image_name) {
$this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build"; $this->build_image_name = "{$this->application->docker_registry_image_name}:{$previewBuildImageTag}";
$this->production_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}"; $this->production_image_name = "{$this->application->docker_registry_image_name}:{$previewImageTag}";
} else { } else {
$this->build_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}-build"; $this->build_image_name = "{$this->application->uuid}:{$previewBuildImageTag}";
$this->production_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}"; $this->production_image_name = "{$this->application->uuid}:{$previewImageTag}";
} }
} else { } else {
$this->dockerImageTag = str($this->commit)->substr(0, 128); $this->dockerImageTag = str($this->commit)->substr(0, 128);
@ -1127,6 +1174,27 @@ private function generate_image_names()
} }
} }
private function previewImageTag(bool $build = false): string
{
$prefix = "pr-{$this->pull_request_id}-";
$suffix = $build ? '-build' : '';
$maxCommitLength = max(1, 128 - strlen($prefix) - strlen($suffix));
$commitSource = ($this->commit === 'HEAD' || blank($this->commit))
? $this->deployment_uuid
: $this->commit;
$commit = Str::of($commitSource)
->replaceMatches('/[^A-Za-z0-9_.-]/', '-')
->substr(0, $maxCommitLength)
->toString();
if ($commit === '') {
$commit = 'HEAD';
}
return "{$prefix}{$commit}{$suffix}";
}
private function just_restart() private function just_restart()
{ {
$this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}."); $this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}.");
@ -1165,8 +1233,9 @@ private function should_skip_build()
return true; return true;
} }
if (! $this->application->isConfigurationChanged()) { $configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
$this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); if (! $configurationDiff->requiresBuild()) {
$this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
$this->skip_build = true; $this->skip_build = true;
$this->generate_compose_file(); $this->generate_compose_file();
@ -1178,7 +1247,7 @@ private function should_skip_build()
return true; return true;
} else { } else {
$this->application_deployment_queue->addLogEntry('Configuration changed. Rebuilding image.'); $this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.');
} }
} else { } else {
$this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image."); $this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image.");
@ -1217,19 +1286,15 @@ private function generate_runtime_environment_variables()
$envs = collect([]); $envs = collect([]);
$sort = $this->application->settings->is_env_sorting_enabled; $sort = $this->application->settings->is_env_sorting_enabled;
if ($sort) { if ($sort) {
$sorted_environment_variables = $this->application->environment_variables->sortBy('key'); $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('key');
$sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('key'); $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('key');
} else { } else {
$sorted_environment_variables = $this->application->environment_variables->sortBy('id'); $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('id');
$sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id'); $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id');
} }
if ($this->build_pack === 'dockercompose') { if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_'); $sorted_environment_variables_preview = $sorted_environment_variables_preview->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
});
$sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
});
} }
$ports = $this->application->main_port(); $ports = $this->application->main_port();
$coolify_envs = $this->generate_coolify_env_variables(); $coolify_envs = $this->generate_coolify_env_variables();
@ -1382,6 +1447,15 @@ private function generate_runtime_environment_variables()
return $envs; return $envs;
} }
private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool
{
$key = str($environmentVariable->key);
return $key->startsWith('SERVICE_FQDN_')
|| $key->startsWith('SERVICE_URL_')
|| $key->startsWith('SERVICE_NAME_');
}
private function save_runtime_environment_variables() private function save_runtime_environment_variables()
{ {
// This method saves the .env file with ALL runtime variables // This method saves the .env file with ALL runtime variables
@ -1592,15 +1666,14 @@ private function generate_buildtime_environment_variables()
// 4. Add user-defined build-time variables LAST (highest priority - can override everything) // 4. Add user-defined build-time variables LAST (highest priority - can override everything)
if ($this->pull_request_id === 0) { if ($this->pull_request_id === 0) {
$sorted_environment_variables = $this->application->environment_variables() $sorted_environment_variables = $this->application->environment_variables()
->withoutBuildpackControlVariables()
->where('is_buildtime', true) // ONLY build-time variables ->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get(); ->get();
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these // For Docker Compose, filter out generated SERVICE_* variables as we generate these
if ($this->build_pack === 'dockercompose') { if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
} }
foreach ($sorted_environment_variables as $env) { foreach ($sorted_environment_variables as $env) {
@ -1644,15 +1717,14 @@ private function generate_buildtime_environment_variables()
} }
} else { } else {
$sorted_environment_variables = $this->application->environment_variables_preview() $sorted_environment_variables = $this->application->environment_variables_preview()
->withoutBuildpackControlVariables()
->where('is_buildtime', true) // ONLY build-time variables ->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get(); ->get();
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values // For Docker Compose, filter out generated SERVICE_* variables as we generate these with PR-specific values
if ($this->build_pack === 'dockercompose') { if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
} }
foreach ($sorted_environment_variables as $env) { foreach ($sorted_environment_variables as $env) {
@ -1983,7 +2055,11 @@ private function deploy_pull_request()
if ($this->application->build_pack === 'dockerfile') { if ($this->application->build_pack === 'dockerfile') {
$this->add_build_env_variables_to_dockerfile(); $this->add_build_env_variables_to_dockerfile();
} }
$this->build_image(); if ($this->application->build_pack === 'railpack') {
$this->build_railpack_image();
} else {
$this->build_image();
}
// This overwrites the build-time .env with ALL variables (build-time + runtime) // This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables(); $this->save_runtime_environment_variables();
@ -2422,7 +2498,409 @@ private function generate_nixpacks_env_variables()
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
} }
private function generate_coolify_env_variables(bool $forBuildTime = false): Collection private function generate_railpack_env_variables(): Collection
{
$variables = $this->railpack_build_variables();
$this->env_railpack_args = $variables
->map(function ($value, $key) {
return '--env '.escapeShellValue("{$key}={$value}");
})
->implode(' ');
return $variables;
}
private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
{
$resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
if (is_null($resolvedValue) || $resolvedValue === '') {
return null;
}
if ($environmentVariable->is_literal || $environmentVariable->is_multiline) {
return trim($resolvedValue, "'");
}
return $resolvedValue;
}
/**
* All buildtime variables that must reach the Railpack build.
*
* Railpack's BuildKit frontend treats every `--env` passed to `railpack prepare`
* as a build secret entry in the generated plan, then pairs it with `--secret id=,env=`
* on `docker buildx build`. Because Railpack's schema disallows top-level `variables`
* (unlike Nixpacks, which bakes variables into the plan), this `--env` `--secret`
* channel is the only way user-defined buildtime variables become available to
* commands declared with `useSecrets: true`.
*/
private function railpack_build_variables(): Collection
{
$genericBuildVariables = $this->pull_request_id === 0
? $this->application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get()
: $this->application->environment_variables_preview()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get();
$railpackVariables = $this->pull_request_id === 0
? $this->application->railpack_environment_variables()->get()
: $this->application->railpack_environment_variables_preview()->get();
$variables = $genericBuildVariables
->merge($railpackVariables)
->mapWithKeys(function (EnvironmentVariable $environmentVariable) {
$value = $this->normalize_resolved_build_variable_value($environmentVariable);
if (is_null($value) || $value === '') {
return [];
}
return [$environmentVariable->key => $value];
});
if ($this->application->install_command) {
$variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command);
}
$variables = $this->merge_railpack_deploy_apt_packages($variables);
// Mirror Nixpacks behavior: expose COOLIFY_* and SOURCE_COMMIT to the build so apps
// (e.g. SPAs baking the public URL) can read them via /run/secrets/<KEY>.
foreach ($this->generate_coolify_env_variables(forBuildTime: true) as $key => $value) {
if (! is_null($value) && $value !== '') {
$variables->put($key, $value);
}
}
return $variables;
}
private function merge_railpack_deploy_apt_packages(Collection $variables): Collection
{
$packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: [])
->filter()
->values();
foreach (['curl', 'wget'] as $package) {
if (! $packages->contains($package)) {
$packages->push($package);
}
}
$variables->put('RAILPACK_DEPLOY_APT_PACKAGES', $packages->implode(' '));
return $variables;
}
private function railpack_build_environment_prefix(Collection $variables): string
{
if ($variables->isEmpty()) {
return '';
}
return 'env '.$variables
->map(function ($value, $key) {
return escapeShellValue("{$key}={$value}");
})
->implode(' ').' ';
}
private function railpack_build_secret_flags(Collection $variables): string
{
if ($variables->isEmpty()) {
return '';
}
return ' '.$variables
->map(function ($value, $key) {
return '--secret '.escapeShellValue("id={$key},env={$key}");
})
->implode(' ');
}
private function railpack_build_command(string $imageName, Collection $variables): string
{
$cacheArgs = '';
if ($this->force_rebuild) {
$cacheArgs = '--no-cache';
} else {
$cacheArgs = "--build-arg cache-key='{$this->application->uuid}'";
}
if ($variables->isNotEmpty()) {
$cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables);
}
$environmentPrefix = $this->railpack_build_environment_prefix($variables);
$secretFlags = $this->railpack_build_secret_flags($variables);
$frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
." && {$environmentPrefix}docker buildx build --builder coolify-railpack"
." {$this->addHosts} --network host"
." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
." {$cacheArgs}"
."{$secretFlags}"
.' -f /artifacts/railpack-plan.json'
.' --progress plain'
.' --load'
." -t {$imageName}"
." {$this->workdir}";
}
private function decode_railpack_config(string $config, string $source): array
{
try {
$decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception);
}
if (! is_array($decoded)) {
throw new DeploymentException("Invalid {$source}: expected a JSON object.");
}
return $decoded;
}
private function is_assoc_array(array $value): bool
{
if ($value === []) {
return false;
}
return array_keys($value) !== range(0, count($value) - 1);
}
private function merge_railpack_config(array $base, array $overrides): array
{
foreach ($overrides as $key => $value) {
if (
array_key_exists($key, $base)
&& is_array($base[$key])
&& is_array($value)
&& $this->is_assoc_array($base[$key])
&& $this->is_assoc_array($value)
) {
$base[$key] = $this->merge_railpack_config($base[$key], $value);
} else {
$base[$key] = $value;
}
}
return $base;
}
private function railpack_config_overrides(): array
{
return [];
}
private function generated_railpack_config_relative_path(): string
{
return self::RAILPACK_GENERATED_CONFIG_PATH;
}
private function generated_railpack_config_absolute_path(): string
{
return "{$this->workdir}/".self::RAILPACK_GENERATED_CONFIG_PATH;
}
private function generate_railpack_config_file(): ?string
{
$repositoryConfig = [];
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH." && echo 'exists' || echo 'missing'"),
'hidden' => true,
'save' => 'railpack_config_exists',
]);
if (str($this->saved_outputs->get('railpack_config_exists'))->trim()->toString() === 'exists') {
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH),
'hidden' => true,
'save' => 'railpack_repository_config',
]);
$repositoryConfig = $this->decode_railpack_config(
$this->saved_outputs->get('railpack_repository_config', ''),
'repository railpack.json'
);
}
$overrides = $this->railpack_config_overrides();
if ($repositoryConfig === [] && $overrides === []) {
return null;
}
$mergedConfig = $this->merge_railpack_config($repositoryConfig, $overrides);
if (! array_key_exists('$schema', $mergedConfig)) {
$mergedConfig['$schema'] = 'https://schema.railpack.com';
}
try {
$encodedConfig = json_encode($mergedConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new DeploymentException("Failed to encode generated Railpack config: {$exception->getMessage()}", $exception->getCode(), $exception);
}
$configPath = $this->generated_railpack_config_absolute_path();
$encodedConfig = base64_encode($encodedConfig);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}/.coolify"),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, "echo '{$encodedConfig}' | base64 -d | tee {$configPath} > /dev/null"),
'hidden' => true,
]
);
if (isDev()) {
$this->application_deployment_queue->addLogEntry('Generated Railpack config: '.json_encode($mergedConfig, JSON_PRETTY_PRINT), hidden: true);
}
return $this->generated_railpack_config_relative_path();
}
private function railpack_prepare_command(?string $configFilePath = null): string
{
$prepare_command = 'railpack prepare';
if ($this->application->build_command) {
$prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
}
if ($this->application->start_command) {
$prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
}
if ($this->env_railpack_args) {
$prepare_command .= " {$this->env_railpack_args}";
}
if ($configFilePath) {
$prepare_command .= ' --config-file '.escapeShellValue($configFilePath);
}
$prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}";
return $prepare_command;
}
private function ensure_docker_buildx_available_for_railpack(): void
{
if ($this->dockerBuildxAvailable) {
return;
}
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 build_railpack_image(): void
{
$this->ensure_docker_buildx_available_for_railpack();
$railpackVariables = $this->generate_railpack_env_variables();
$railpackConfigPath = $this->generate_railpack_config_file();
// Step 1: Generate build plan with railpack prepare
$prepare_command = $this->railpack_prepare_command($railpackConfigPath);
$this->application_deployment_queue->addLogEntry('Generating Railpack build plan.');
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true],
[
executeInDocker($this->deployment_uuid, 'cat /artifacts/railpack-plan.json'),
'hidden' => true,
'save' => 'railpack_plan',
],
);
$railpackPlanRaw = $this->saved_outputs->get('railpack_plan');
if (! empty($railpackPlanRaw)) {
if (isDev()) {
$this->application_deployment_queue->addLogEntry("Final Railpack plan: {$railpackPlanRaw}", hidden: true);
} else {
$parsedPlan = json_decode($railpackPlanRaw, true);
if (is_array($parsedPlan)) {
// Strip secrets array to avoid logging variable names in production.
unset($parsedPlan['secrets']);
$this->application_deployment_queue->addLogEntry('Final Railpack plan: '.json_encode($parsedPlan, JSON_PRETTY_PRINT), hidden: true);
}
}
}
// Step 2: Build image using docker buildx with railpack frontend.
// Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder.
$this->application_deployment_queue->addLogEntry('Building docker image with Railpack.');
$this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.');
$image_name = $this->application->settings->is_static
? $this->build_image_name
: $this->production_image_name;
if ($this->application->settings->is_static && $this->application->static_image) {
$this->pull_latest_image($this->application->static_image);
}
$build_command = $this->railpack_build_command($image_name, $railpackVariables);
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
]
);
// Step 3: If static, copy built assets into nginx image
if ($this->application->settings->is_static) {
$this->build_railpack_static_image();
}
}
private function build_railpack_static_image(): void
{
$publishDir = trim($this->application->publish_directory, '/');
$publishDir = $publishDir ? "/{$publishDir}" : '';
$dockerfile = base64_encode("FROM {$this->application->static_image}
WORKDIR /usr/share/nginx/html/
LABEL coolify.deploymentId={$this->deployment_uuid}
COPY --from={$this->build_image_name} /app{$publishDir} .
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
} else {
$nginx_config = $this->application->settings->is_spa
? base64_encode(defaultNginxConfiguration('spa'))
: base64_encode(defaultNginxConfiguration());
}
$static_build = $this->dockerBuildkitSupported
? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"
: "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
$base64_static_build = base64_encode($static_build);
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")],
[executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")],
[executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true],
[executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
[executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
);
}
protected function generate_coolify_env_variables(bool $forBuildTime = false): Collection
{ {
$coolify_envs = collect([]); $coolify_envs = collect([]);
$local_branch = $this->branch; $local_branch = $this->branch;
@ -2538,10 +3016,14 @@ private function generate_env_variables()
// For build process, include only environment variables where is_buildtime = true // For build process, include only environment variables where is_buildtime = true
if ($this->pull_request_id === 0) { if ($this->pull_request_id === 0) {
$envs = $this->application->environment_variables() $envs = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%') ->withoutBuildpackControlVariables()
->where('is_buildtime', true) ->where('is_buildtime', true)
->get(); ->get();
if ($this->build_pack === 'dockercompose') {
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
foreach ($envs as $env) { foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer); $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) { if (! is_null($resolvedValue)) {
@ -2550,10 +3032,14 @@ private function generate_env_variables()
} }
} else { } else {
$envs = $this->application->environment_variables_preview() $envs = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%') ->withoutBuildpackControlVariables()
->where('is_buildtime', true) ->where('is_buildtime', true)
->get(); ->get();
if ($this->build_pack === 'dockercompose') {
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
foreach ($envs as $env) { foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer); $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) { if (! is_null($resolvedValue)) {
@ -2877,7 +3363,7 @@ private function generate_healthcheck_commands()
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http'); $scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
$host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost'); $host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost');
$path = $this->application->health_check_path $path = $this->application->health_check_path
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%]+$#', '/') ? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/')
: null; : null;
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/')); $url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
@ -3075,29 +3561,28 @@ private function build_image()
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]); $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else { } else {
// Dockerfile buildpack // Dockerfile buildpack
$safeNetwork = escapeshellarg($this->destination->network);
if ($this->dockerSecretsSupported) { if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets // Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) { if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
} else { } else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
} }
} elseif ($this->dockerBuildkitSupported) { } elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets // BuildKit without secrets
if ($this->force_rebuild) { if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
} else { } else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
} }
} else { } else {
// Traditional build with args // Traditional build with args
if ($this->force_rebuild) { if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
} else { } else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
} }
} }
$base64_build_command = base64_encode($build_command); $base64_build_command = base64_encode($build_command);
@ -3310,14 +3795,15 @@ private function build_image()
private function graceful_shutdown_container(string $containerName, bool $skipRemove = false) private function graceful_shutdown_container(string $containerName, bool $skipRemove = false)
{ {
try { try {
$timeout = isDev() ? 1 : 30; $timeout = $this->application->settings->deploymentStopGracePeriodSeconds();
if ($skipRemove) { if ($skipRemove) {
$this->execute_remote_command( $this->execute_remote_command(
["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true] ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
); );
} else { } else {
$this->execute_remote_command( $this->execute_remote_command(
["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true], ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
); );
} }
@ -3631,7 +4117,7 @@ private function add_build_env_variables_to_dockerfile()
if ($this->pull_request_id === 0) { if ($this->pull_request_id === 0) {
// Only add environment variables that are available during build // Only add environment variables that are available during build
$envs = $this->application->environment_variables() $envs = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%') ->withoutBuildpackControlVariables()
->where('is_buildtime', true) ->where('is_buildtime', true)
->get(); ->get();
foreach ($envs as $env) { foreach ($envs as $env) {
@ -3653,7 +4139,7 @@ private function add_build_env_variables_to_dockerfile()
} else { } else {
// Only add preview environment variables that are available during build // Only add preview environment variables that are available during build
$envs = $this->application->environment_variables_preview() $envs = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%') ->withoutBuildpackControlVariables()
->where('is_buildtime', true) ->where('is_buildtime', true)
->get(); ->get();
foreach ($envs as $env) { foreach ($envs as $env) {
@ -4257,6 +4743,12 @@ private function handleSuccessfulDeployment(): void
'last_restart_type' => null, 'last_restart_type' => null,
]); ]);
try {
$this->application->markDeploymentConfigurationApplied($this->application_deployment_queue);
} catch (Exception $e) {
\Log::warning('Failed to mark configuration as applied for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
event(new ApplicationConfigurationChanged($this->application->team()->id)); event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) { if (! $this->only_this_server) {

View file

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\ScheduledDatabaseBackup;
use App\Models\TeamInvitation; use App\Models\TeamInvitation;
use App\Models\User; use App\Models\User;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@ -12,6 +13,7 @@
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
@ -32,6 +34,7 @@ public function handle(): void
try { try {
$this->cleanupInvitationLink(); $this->cleanupInvitationLink();
$this->cleanupExpiredEmailChangeRequests(); $this->cleanupExpiredEmailChangeRequests();
$this->enforceBackupRetention();
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage()); Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage());
} }
@ -55,4 +58,25 @@ private function cleanupExpiredEmailChangeRequests()
'email_change_code_expires_at' => null, 'email_change_code_expires_at' => null,
]); ]);
} }
private function enforceBackupRetention(): void
{
if (! Cache::add('backup-retention-enforcement', true, 1800)) {
return;
}
try {
$backups = ScheduledDatabaseBackup::where('enabled', true)->get();
foreach ($backups as $backup) {
try {
removeOldBackups($backup);
} catch (\Throwable $e) {
Log::warning('Failed to enforce retention for backup '.$backup->id.': '.$e->getMessage());
}
}
} catch (\Throwable $e) {
Log::error('Failed to enforce backup retention: '.$e->getMessage());
Cache::forget('backup-retention-enforcement');
}
}
} }

View file

@ -1,82 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class CleanupStaleMultiplexedConnections implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle()
{
$this->cleanupStaleConnections();
$this->cleanupNonExistentServerConnections();
}
private function cleanupStaleConnections()
{
$muxFiles = Storage::disk('ssh-mux')->files();
foreach ($muxFiles as $muxFile) {
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
$server = Server::where('uuid', $serverUuid)->first();
if (! $server) {
$this->removeMultiplexFile($muxFile);
continue;
}
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$checkCommand = "ssh -O check -o ControlPath={$muxSocket} {$server->user}@{$server->ip} 2>/dev/null";
$checkProcess = Process::run($checkCommand);
if ($checkProcess->exitCode() !== 0) {
$this->removeMultiplexFile($muxFile);
} else {
$muxContent = Storage::disk('ssh-mux')->get($muxFile);
$establishedAt = Carbon::parse(substr($muxContent, 37));
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
if (Carbon::now()->isAfter($expirationTime)) {
$this->removeMultiplexFile($muxFile);
}
}
}
}
private function cleanupNonExistentServerConnections()
{
$muxFiles = Storage::disk('ssh-mux')->files();
$existingServerUuids = Server::pluck('uuid')->toArray();
foreach ($muxFiles as $muxFile) {
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
if (! in_array($serverUuid, $existingServerUuids)) {
$this->removeMultiplexFile($muxFile);
}
}
}
private function extractServerUuidFromMuxFile($muxFile)
{
return substr($muxFile, 4);
}
private function removeMultiplexFile($muxFile)
{
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null";
Process::run($closeCommand);
Storage::disk('ssh-mux')->delete($muxFile);
}
}

View file

@ -22,6 +22,7 @@
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -76,10 +77,17 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public ScheduledDatabaseBackup $backup) public function __construct(public ScheduledDatabaseBackup $backup)
{ {
$this->onQueue('high'); $this->onQueue(crons_queue());
$this->timeout = $backup->timeout ?? 3600; $this->timeout = $backup->timeout ?? 3600;
} }
public function middleware(): array
{
$expireAfter = ($this->backup->timeout ?? 3600) + 300;
return [(new WithoutOverlapping('database-backup-'.$this->backup->id))->expireAfter($expireAfter)->dontRelease()];
}
public function handle(): void public function handle(): void
{ {
try { try {
@ -107,6 +115,8 @@ public function handle(): void
throw new \Exception('Database not found?!'); throw new \Exception('Database not found?!');
} }
$this->markStaleExecutionsAsFailed();
BackupCreated::dispatch($this->team->id); BackupCreated::dispatch($this->team->id);
$status = str(data_get($this->database, 'status')); $status = str(data_get($this->database, 'status'));
@ -658,12 +668,14 @@ private function calculate_size()
private function upload_to_s3(): void private function upload_to_s3(): void
{ {
if (is_null($this->s3)) { if (is_null($this->s3)) {
$previousS3StorageId = $this->backup->s3_storage_id;
$this->backup->update([ $this->backup->update([
'save_s3' => false, 'save_s3' => false,
's3_storage_id' => null, 's3_storage_id' => null,
]); ]);
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.'); throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.');
} }
try { try {
@ -727,6 +739,31 @@ private function getFullImageName(): string
return "{$helperImage}:{$latestVersion}"; return "{$helperImage}:{$latestVersion}";
} }
private function markStaleExecutionsAsFailed(): void
{
try {
$timeoutSeconds = ($this->backup->timeout ?? 3600) * 2;
$staleExecutions = $this->backup->executions()
->where('status', 'running')
->where('created_at', '<', now()->subSeconds($timeoutSeconds))
->get();
foreach ($staleExecutions as $execution) {
$execution->update([
'status' => 'failed',
'message' => 'Marked as failed - backup execution exceeded maximum allowed time',
'finished_at' => now(),
]);
}
} catch (Throwable $e) {
Log::channel('scheduled-errors')->warning('Failed to clean up stale backup executions', [
'backup_id' => $this->backup->uuid,
'error' => $e->getMessage(),
]);
}
}
public function failed(?Throwable $exception): void public function failed(?Throwable $exception): void
{ {
Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [ Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [

View file

@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment; use App\Actions\Application\CleanupPreviewDeployment;
use App\Enums\ProcessStatus; use App\Enums\ProcessStatus;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Models\GithubApp; use App\Models\GithubApp;
@ -17,6 +18,7 @@
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
{ {
use DetectsSkipDeployCommits;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3; public int $tries = 3;
@ -31,11 +33,13 @@ public function __construct(
public string $action, public string $action,
public int $pullRequestId, public int $pullRequestId,
public string $pullRequestHtmlUrl, public string $pullRequestHtmlUrl,
public ?string $pullRequestTitle,
public ?string $beforeSha, public ?string $beforeSha,
public ?string $afterSha, public ?string $afterSha,
public string $commitSha, public string $commitSha,
public ?string $authorAssociation, public ?string $authorAssociation,
public string $fullName, public string $fullName,
public bool $isForkPullRequest = false,
) { ) {
$this->onQueue('high'); $this->onQueue('high');
} }
@ -83,9 +87,23 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
return; return;
} }
if (self::shouldSkipDeployAny([$this->pullRequestTitle])) {
return;
}
// Check if PR deployments from public contributors are restricted // Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) { if (! $application->settings->is_pr_deployments_public_enabled) {
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; // Fork PRs carry untrusted code from a repository outside our control.
// GitHub's author_association cannot be trusted to gate these (it grants
// CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
// PRs are never deployed automatically when public previews are off.
if ($this->isForkPullRequest) {
return;
}
// Same-repo (non-fork) branch PRs require push access to the base repo,
// so only trusted associations are allowed to trigger a deployment.
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
if (! in_array($this->authorAssociation, $trustedAssociations)) { if (! in_array($this->authorAssociation, $trustedAssociations)) {
return; return;
} }

View file

@ -13,6 +13,16 @@
use App\Models\Server; use App\Models\Server;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\SwarmDocker;
use App\Notifications\Container\ContainerRestarted; use App\Notifications\Container\ContainerRestarted;
use App\Services\ContainerStatusAggregator; use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus; use App\Traits\CalculatesExcludedStatus;
@ -25,6 +35,7 @@
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Laravel\Horizon\Contracts\Silenced; use Laravel\Horizon\Contracts\Silenced;
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
@ -46,6 +57,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public Collection $services; public Collection $services;
public Collection $applicationsById;
public Collection $previewsByKey;
public Collection $databasesByUuid;
public Collection $servicesById;
public Collection $serviceApplicationsById;
public Collection $serviceDatabasesById;
public Collection $allApplicationIds; public Collection $allApplicationIds;
public Collection $allDatabaseUuids; public Collection $allDatabaseUuids;
@ -78,6 +101,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public bool $foundLogDrainContainer = false; public bool $foundLogDrainContainer = false;
private ?array $cachedDestinationIds = null;
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()]; return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()];
@ -103,6 +128,12 @@ public function __construct(public Server $server, public $data)
$this->allTcpProxyUuids = collect(); $this->allTcpProxyUuids = collect();
$this->allServiceApplicationIds = collect(); $this->allServiceApplicationIds = collect();
$this->allServiceDatabaseIds = collect(); $this->allServiceDatabaseIds = collect();
$this->applicationsById = collect();
$this->previewsByKey = collect();
$this->databasesByUuid = collect();
$this->servicesById = collect();
$this->serviceApplicationsById = collect();
$this->serviceDatabasesById = collect();
} }
public function handle() public function handle()
@ -120,6 +151,16 @@ public function handle()
$this->allTcpProxyUuids ??= collect(); $this->allTcpProxyUuids ??= collect();
$this->allServiceApplicationIds ??= collect(); $this->allServiceApplicationIds ??= collect();
$this->allServiceDatabaseIds ??= collect(); $this->allServiceDatabaseIds ??= collect();
$this->applicationsById ??= collect();
$this->previewsByKey ??= collect();
$this->databasesByUuid ??= collect();
$this->servicesById ??= collect();
$this->serviceApplicationsById ??= collect();
$this->serviceDatabasesById ??= collect();
// Eager-load relations the job touches repeatedly to avoid lazy-load queries
// (settings: disk threshold, isProxyShouldRun, isLogDrainEnabled; team: notifications).
$this->server->loadMissing(['settings', 'team']);
// TODO: Swarm is not supported yet // TODO: Swarm is not supported yet
if (! $this->data) { if (! $this->data) {
@ -127,30 +168,40 @@ public function handle()
} }
$data = collect($this->data); $data = collect($this->data);
$this->server->sentinelHeartbeat(); // Heartbeat is updated by SentinelController on every push, before dispatch.
$this->containers = collect(data_get($data, 'containers')); $this->containers = collect(data_get($data, 'containers'));
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage'); $filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
// Only dispatch storage check when disk percentage actually changes // Only dispatch the storage check when disk usage is at/above the notification
// threshold AND the value changed. Below the threshold ServerStorageCheckJob
// has nothing to do (it only sends a HighDiskUsage notification), so dispatching
// it is wasted work — and most servers sit well below the threshold.
$diskThreshold = data_get($this->server, 'settings.server_disk_usage_notification_threshold', 80);
$storageCacheKey = 'storage-check:'.$this->server->id; $storageCacheKey = 'storage-check:'.$this->server->id;
$lastPercentage = Cache::get($storageCacheKey); $lastPercentage = Cache::get($storageCacheKey);
if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) { if ($filesystemUsageRoot !== null
&& $filesystemUsageRoot >= $diskThreshold
&& (string) $lastPercentage !== (string) $filesystemUsageRoot) {
Cache::put($storageCacheKey, $filesystemUsageRoot, 600); Cache::put($storageCacheKey, $filesystemUsageRoot, 600);
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
} elseif ($filesystemUsageRoot !== null && $filesystemUsageRoot < $diskThreshold) {
Cache::forget($storageCacheKey);
} }
if ($this->containers->isEmpty()) { if ($this->containers->isEmpty()) {
return; return;
} }
$this->applications = $this->server->applications(); $this->applications = $this->loadApplications();
$this->databases = $this->server->databases(); $this->databases = $this->loadDatabases();
$this->previews = $this->server->previews(); $this->previews = $this->loadPreviews();
// Eager load service applications and databases to avoid N+1 queries $this->services = $this->loadServices();
$this->services = $this->server->services() $this->applicationsById = $this->applications->keyBy(fn ($application) => (string) $application->id);
->with(['applications:id,service_id', 'databases:id,service_id']) $this->previewsByKey = $this->previews->keyBy(fn ($preview) => $preview->application_id.':'.$preview->pull_request_id);
->get(); $this->databasesByUuid = $this->databases->keyBy('uuid');
$this->servicesById = $this->services->keyBy(fn ($service) => (string) $service->id);
$this->serviceApplicationsById = $this->services->flatMap(fn ($service) => $service->applications)->keyBy(fn ($application) => (string) $application->id);
$this->serviceDatabasesById = $this->services->flatMap(fn ($service) => $service->databases)->keyBy(fn ($database) => (string) $database->id);
$this->allApplicationIds = $this->applications->filter(function ($application) { $this->allApplicationIds = $this->applications->filter(function ($application) {
return $application->additional_servers_count === 0; return $application->additional_servers_count === 0;
@ -163,9 +214,8 @@ public function handle()
}); });
$this->allDatabaseUuids = $this->databases->pluck('uuid'); $this->allDatabaseUuids = $this->databases->pluck('uuid');
$this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid'); $this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid');
// Use eager-loaded relationships instead of querying in loop $this->allServiceApplicationIds = $this->serviceApplicationsById->keys();
$this->allServiceApplicationIds = $this->services->flatMap(fn ($service) => $service->applications->pluck('id')); $this->allServiceDatabaseIds = $this->serviceDatabasesById->keys();
$this->allServiceDatabaseIds = $this->services->flatMap(fn ($service) => $service->databases->pluck('id'));
foreach ($this->containers as $container) { foreach ($this->containers as $container) {
$containerStatus = data_get($container, 'state', 'exited'); $containerStatus = data_get($container, 'state', 'exited');
@ -279,6 +329,151 @@ public function handle()
$this->checkLogDrainContainer(); $this->checkLogDrainContainer();
} }
private function loadApplications(): Collection
{
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
$applications = ($standaloneDockerIds->isNotEmpty() || $swarmDockerIds->isNotEmpty())
? Application::withoutGlobalScope('withRelations')
->select([
'id',
'uuid',
'name',
'status',
'build_pack',
'docker_compose_raw',
'destination_id',
'destination_type',
'last_online_at',
])
->withCount('additional_servers')
->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
->get()
: collect();
$additionalApplicationIds = DB::table('additional_destinations')
->where('server_id', $this->server->id)
->pluck('application_id');
if ($additionalApplicationIds->isNotEmpty()) {
$applications = $applications->concat(
Application::withoutGlobalScope('withRelations')
->select([
'id',
'uuid',
'name',
'status',
'build_pack',
'docker_compose_raw',
'destination_id',
'destination_type',
'last_online_at',
])
->withCount('additional_servers')
->whereIn('id', $additionalApplicationIds)
->get()
);
}
return $applications->unique('id')->values();
}
private function loadPreviews(): Collection
{
$applicationIds = $this->applications->pluck('id');
if ($applicationIds->isEmpty()) {
return collect();
}
return ApplicationPreview::query()
->select([
'id',
'application_id',
'pull_request_id',
'status',
'last_online_at',
])
->whereIn('application_id', $applicationIds)
->get();
}
private function loadServices(): Collection
{
return $this->server->services()
->select([
'id',
'server_id',
'uuid',
'docker_compose_raw',
])
->with([
'applications:id,service_id,status,last_online_at',
'databases:id,service_id,status,last_online_at,is_public,name',
])
->get();
}
private function loadDatabases(): Collection
{
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
if ($standaloneDockerIds->isEmpty() && $swarmDockerIds->isEmpty()) {
return collect();
}
$databaseColumns = [
'id',
'uuid',
'name',
'status',
'is_public',
'destination_id',
'destination_type',
'last_online_at',
'restart_count',
'last_restart_at',
'last_restart_type',
];
return collect([
StandalonePostgresql::class,
StandaloneRedis::class,
StandaloneMongodb::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
])->flatMap(function (string $databaseClass) use ($databaseColumns, $standaloneDockerIds, $swarmDockerIds) {
return $databaseClass::query()
->select($databaseColumns)
->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
->get();
})->filter(fn ($database) => data_get($database, 'name') !== 'coolify-db')->values();
}
private function serverDestinationIds(): array
{
if ($this->cachedDestinationIds !== null) {
return $this->cachedDestinationIds;
}
return $this->cachedDestinationIds = [
StandaloneDocker::where('server_id', $this->server->id)->pluck('id'),
SwarmDocker::where('server_id', $this->server->id)->pluck('id'),
];
}
private function scopeDestination($query, Collection $standaloneDockerIds, Collection $swarmDockerIds): void
{
$query->where(function ($query) use ($standaloneDockerIds) {
$query->where('destination_type', StandaloneDocker::class)
->whereIn('destination_id', $standaloneDockerIds);
})->orWhere(function ($query) use ($swarmDockerIds) {
$query->where('destination_type', SwarmDocker::class)
->whereIn('destination_id', $swarmDockerIds);
});
}
private function aggregateMultiContainerStatuses() private function aggregateMultiContainerStatuses()
{ {
if ($this->applicationContainerStatuses->isEmpty()) { if ($this->applicationContainerStatuses->isEmpty()) {
@ -286,7 +481,7 @@ private function aggregateMultiContainerStatuses()
} }
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
$application = $this->applications->where('id', $applicationId)->first(); $application = $this->applicationsById->get((string) $applicationId);
if (! $application) { if (! $application) {
continue; continue;
} }
@ -307,8 +502,6 @@ private function aggregateMultiContainerStatuses()
if ($aggregatedStatus && $application->status !== $aggregatedStatus) { if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus; $application->status = $aggregatedStatus;
$application->save(); $application->save();
} elseif ($aggregatedStatus) {
$application->update(['last_online_at' => now()]);
} }
continue; continue;
@ -323,8 +516,6 @@ private function aggregateMultiContainerStatuses()
if ($aggregatedStatus && $application->status !== $aggregatedStatus) { if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus; $application->status = $aggregatedStatus;
$application->save(); $application->save();
} elseif ($aggregatedStatus) {
$application->update(['last_online_at' => now()]);
} }
} }
} }
@ -343,7 +534,7 @@ private function aggregateServiceContainerStatuses()
continue; continue;
} }
$service = $this->services->where('id', $serviceId)->first(); $service = $this->servicesById->get((string) $serviceId);
if (! $service) { if (! $service) {
continue; continue;
} }
@ -351,9 +542,9 @@ private function aggregateServiceContainerStatuses()
// Get the service sub-resource (ServiceApplication or ServiceDatabase) // Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null; $subResource = null;
if ($subType === 'application') { if ($subType === 'application') {
$subResource = $service->applications->where('id', $subId)->first(); $subResource = $this->serviceApplicationsById->get((string) $subId);
} elseif ($subType === 'database') { } elseif ($subType === 'database') {
$subResource = $service->databases->where('id', $subId)->first(); $subResource = $this->serviceDatabasesById->get((string) $subId);
} }
if (! $subResource) { if (! $subResource) {
@ -375,8 +566,6 @@ private function aggregateServiceContainerStatuses()
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus; $subResource->status = $aggregatedStatus;
$subResource->save(); $subResource->save();
} elseif ($aggregatedStatus) {
$subResource->update(['last_online_at' => now()]);
} }
continue; continue;
@ -392,39 +581,31 @@ private function aggregateServiceContainerStatuses()
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus; $subResource->status = $aggregatedStatus;
$subResource->save(); $subResource->save();
} elseif ($aggregatedStatus) {
$subResource->update(['last_online_at' => now()]);
} }
} }
} }
private function updateApplicationStatus(string $applicationId, string $containerStatus) private function updateApplicationStatus(string $applicationId, string $containerStatus)
{ {
$application = $this->applications->where('id', $applicationId)->first(); $application = $this->applicationsById->get((string) $applicationId);
if (! $application) { if (! $application) {
return; return;
} }
if ($application->status !== $containerStatus) { if ($application->status !== $containerStatus) {
$application->status = $containerStatus; $application->status = $containerStatus;
$application->save(); $application->save();
} else {
$application->update(['last_online_at' => now()]);
} }
} }
private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus) private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus)
{ {
$application = $this->previews->where('application_id', $applicationId) $application = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
->where('pull_request_id', $pullRequestId)
->first();
if (! $application) { if (! $application) {
return; return;
} }
if ($application->status !== $containerStatus) { if ($application->status !== $containerStatus) {
$application->status = $containerStatus; $application->status = $containerStatus;
$application->save(); $application->save();
} else {
$application->update(['last_online_at' => now()]);
} }
} }
@ -472,9 +653,7 @@ private function updateNotFoundApplicationPreviewStatus()
$applicationId = $parts[0]; $applicationId = $parts[0];
$pullRequestId = $parts[1]; $pullRequestId = $parts[1];
$applicationPreview = $this->previews->where('application_id', $applicationId) $applicationPreview = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
->where('pull_request_id', $pullRequestId)
->first();
if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) { if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) {
$previewIdsToUpdate->push($applicationPreview->id); $previewIdsToUpdate->push($applicationPreview->id);
@ -500,11 +679,11 @@ private function updateProxyStatus()
} catch (\Throwable $e) { } catch (\Throwable $e) {
} }
} else { } else {
// Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches. // Connect proxy to networks periodically as a safety net to avoid excessive job dispatches.
// On-demand triggers (new network, service deploy) use dispatchSync() and bypass this. // On-demand triggers (new network, service deploy) use dispatchSync() and bypass this.
$proxyCacheKey = 'connect-proxy:'.$this->server->id; $proxyCacheKey = 'connect-proxy:'.$this->server->id;
if (! Cache::has($proxyCacheKey)) { if (! Cache::has($proxyCacheKey)) {
Cache::put($proxyCacheKey, true, 600); Cache::put($proxyCacheKey, true, config('constants.proxy.connect_networks_interval_seconds', 3600));
ConnectProxyToNetworksJob::dispatch($this->server); ConnectProxyToNetworksJob::dispatch($this->server);
} }
} }
@ -513,15 +692,13 @@ private function updateProxyStatus()
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false) private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false)
{ {
$database = $this->databases->where('uuid', $databaseUuid)->first(); $database = $this->databasesByUuid->get($databaseUuid);
if (! $database) { if (! $database) {
return; return;
} }
if ($database->status !== $containerStatus) { if ($database->status !== $containerStatus) {
$database->status = $containerStatus; $database->status = $containerStatus;
$database->save(); $database->save();
} else {
$database->update(['last_online_at' => now()]);
} }
if ($this->isRunning($containerStatus) && $tcpProxy) { if ($this->isRunning($containerStatus) && $tcpProxy) {
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
@ -556,7 +733,7 @@ private function updateNotFoundDatabaseStatus()
} }
$notFoundDatabaseUuids->each(function ($databaseUuid) { $notFoundDatabaseUuids->each(function ($databaseUuid) {
$database = $this->databases->where('uuid', $databaseUuid)->first(); $database = $this->databasesByUuid->get($databaseUuid);
if ($database) { if ($database) {
if (! str($database->status)->startsWith('exited')) { if (! str($database->status)->startsWith('exited')) {
$database->update([ $database->update([

View file

@ -6,14 +6,15 @@
use App\Models\ScheduledTask; use App\Models\ScheduledTask;
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use Cron\CronExpression;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
@ -22,6 +23,8 @@ class ScheduledJobManager implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const CHUNK_SIZE = 100;
/** /**
* The time when this job execution started. * The time when this job execution started.
* Used to ensure all scheduled items are evaluated against the same point in time. * Used to ensure all scheduled items are evaluated against the same point in time.
@ -37,17 +40,7 @@ class ScheduledJobManager implements ShouldQueue
*/ */
public function __construct() public function __construct()
{ {
$this->onQueue($this->determineQueue()); $this->onQueue(crons_queue());
}
private function determineQueue(): string
{
$preferredQueue = 'crons';
$fallbackQueue = 'high';
$configuredQueues = explode(',', env('HORIZON_QUEUES', 'high,default'));
return in_array($preferredQueue, $configuredQueues) ? $preferredQueue : $fallbackQueue;
} }
/** /**
@ -106,21 +99,11 @@ public function handle(): void
'execution_time' => $this->executionTime->toIso8601String(), 'execution_time' => $this->executionTime->toIso8601String(),
]); ]);
// Process backups - don't let failures stop task processing // Process scheduled backups and tasks together so neither type starves the other.
try { try {
$this->processScheduledBackups(); $this->processScheduledBackupsAndTasks();
} catch (\Exception $e) { } catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process scheduled backups', [ Log::channel('scheduled-errors')->error('Failed to process scheduled backups and tasks', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
// Process tasks - don't let failures stop the job manager
try {
$this->processScheduledTasks();
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process scheduled tasks', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(), 'trace' => $e->getTraceAsString(),
]); ]);
@ -151,125 +134,211 @@ public function handle(): void
} }
} }
private function processScheduledBackups(): void private function processScheduledBackupsAndTasks(): void
{ {
$backups = ScheduledDatabaseBackup::with(['database']) $lastBackupId = 0;
$lastTaskId = 0;
do {
$backups = $this->scheduledBackupQuery($lastBackupId)->get();
$tasks = $this->scheduledTaskQuery($lastTaskId)->get();
if ($backups->isNotEmpty()) {
$lastBackupId = $backups->last()->id;
}
if ($tasks->isNotEmpty()) {
$lastTaskId = $tasks->last()->id;
}
$this->processInterleavedDueSchedules(
$this->dueScheduledBackups($backups),
$this->dueScheduledTasks($tasks),
);
} while ($backups->isNotEmpty() || $tasks->isNotEmpty());
}
/**
* @param array<int, array{backup: ScheduledDatabaseBackup, server: Server}> $dueBackups
* @param array<int, array{task: ScheduledTask, server: Server}> $dueTasks
*/
private function processInterleavedDueSchedules(array $dueBackups, array $dueTasks): void
{
$maxCount = max(count($dueBackups), count($dueTasks));
for ($index = 0; $index < $maxCount; $index++) {
if (isset($dueBackups[$index])) {
$this->processScheduledBackup($dueBackups[$index]['backup'], $dueBackups[$index]['server']);
}
if (isset($dueTasks[$index])) {
$this->processScheduledTask($dueTasks[$index]['task'], $dueTasks[$index]['server']);
}
}
}
private function scheduledBackupQuery(int $lastBackupId): Builder
{
return ScheduledDatabaseBackup::with(['database', 'team.subscription'])
->where('enabled', true) ->where('enabled', true)
->get(); ->where('id', '>', $lastBackupId)
->orderBy('id')
->limit(self::CHUNK_SIZE);
}
private function scheduledTaskQuery(int $lastTaskId): Builder
{
return ScheduledTask::with([
'service.destination.server.settings',
'service.destination.server.team.subscription',
'application.destination.server.settings',
'application.destination.server.team.subscription',
])
->where('enabled', true)
->where('id', '>', $lastTaskId)
->orderBy('id')
->limit(self::CHUNK_SIZE);
}
/**
* @param iterable<ScheduledDatabaseBackup> $backups
* @return array<int, array{backup: ScheduledDatabaseBackup, server: Server}>
*/
private function dueScheduledBackups(iterable $backups): array
{
$dueBackups = [];
foreach ($backups as $backup) { foreach ($backups as $backup) {
try { try {
$server = $backup->server(); $server = $backup->server();
$skipReason = $this->getBackupSkipReason($backup, $server);
if ($skipReason !== null) { if (blank(data_get($backup, 'database')) || blank($server)) {
$this->skippedCount++; $this->processScheduledBackup($backup, $server);
$this->logSkip('backup', $skipReason, [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
]);
continue; continue;
} }
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); if ($this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
$dueBackups[] = [
if (validate_timezone($serverTimezone) === false) { 'backup' => $backup,
$serverTimezone = config('app.timezone'); 'server' => $server,
} ];
$frequency = $backup->frequency;
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) {
DatabaseBackupJob::dispatch($backup);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Backup dispatched', [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
'server_id' => $server->id,
]);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing backup', [ Log::channel('scheduled-errors')->error('Error prechecking backup', [
'backup_id' => $backup->id, 'backup_id' => $backup->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
} }
return $dueBackups;
} }
private function processScheduledTasks(): void /**
* @param iterable<ScheduledTask> $tasks
* @return array<int, array{task: ScheduledTask, server: Server}>
*/
private function dueScheduledTasks(iterable $tasks): array
{ {
$tasks = ScheduledTask::with(['service', 'application']) $dueTasks = [];
->where('enabled', true)
->get();
foreach ($tasks as $task) { foreach ($tasks as $task) {
try { try {
$server = $task->server(); $server = $task->server();
// Phase 1: Critical checks (always — cheap, handles orphans and infra issues) if (blank($server) || (! $task->service && ! $task->application)) {
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server); $this->processScheduledTask($task, $server);
if ($criticalSkip !== null) {
$this->skippedCount++;
$this->logSkip('task', $criticalSkip, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server?->team_id,
]);
continue; continue;
} }
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); if ($this->isDueCandidateBeforeExpensiveChecks($task->frequency, $server, "scheduled-task:{$task->id}")) {
$dueTasks[] = [
if (validate_timezone($serverTimezone) === false) { 'task' => $task,
$serverTimezone = config('app.timezone'); 'server' => $server,
];
} }
$frequency = $task->frequency;
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) {
continue;
}
// Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources)
$runtimeSkip = $this->getTaskRuntimeSkipReason($task);
if ($runtimeSkip !== null) {
$this->skippedCount++;
$this->logSkip('task', $runtimeSkip, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
]);
continue;
}
ScheduledTaskJob::dispatch($task);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Task dispatched', [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
'server_id' => $server->id,
]);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing task', [ Log::channel('scheduled-errors')->error('Error prechecking task', [
'task_id' => $task->id, 'task_id' => $task->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
} }
return $dueTasks;
}
private function processScheduledBackup(ScheduledDatabaseBackup $backup, ?Server $precheckedServer = null): void
{
try {
$server = $precheckedServer ?? $backup->server();
$skipReason = $this->getBackupSkipReason($backup, $server);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logBackupSkip($backup, $skipReason);
return;
}
if ($this->shouldDispatch($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
DatabaseBackupJob::dispatch($backup);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Backup dispatched', [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
'server_id' => $server->id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing backup', [
'backup_id' => $backup->id,
'error' => $e->getMessage(),
]);
}
}
private function processScheduledTask(ScheduledTask $task, ?Server $precheckedServer = null): void
{
try {
$server = $precheckedServer ?? $task->server();
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
if ($criticalSkip !== null) {
$this->skippedCount++;
$this->logTaskSkip($task, $criticalSkip, $server);
return;
}
if (! $this->shouldDispatch($task->frequency, $server, "scheduled-task:{$task->id}")) {
return;
}
$runtimeSkip = $this->getTaskRuntimeSkipReason($task);
if ($runtimeSkip !== null) {
$this->skippedCount++;
$this->logTaskSkip($task, $runtimeSkip, $server);
return;
}
ScheduledTaskJob::dispatch($task);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Task dispatched', [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
'server_id' => $server->id,
]);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing task', [
'task_id' => $task->id,
'error' => $e->getMessage(),
]);
}
} }
private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string
@ -337,71 +406,70 @@ private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
private function processDockerCleanups(): void private function processDockerCleanups(): void
{ {
// Get all servers that need cleanup checks $this->getServersForCleanupQuery()
$servers = $this->getServersForCleanup(); ->chunkById(self::CHUNK_SIZE, function ($servers): void {
foreach ($servers as $server) {
foreach ($servers as $server) { $this->processDockerCleanup($server);
try {
$skipReason = $this->getDockerCleanupSkipReason($server);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logSkip('docker_cleanup', $skipReason, [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]);
continue;
} }
});
}
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); private function processDockerCleanup(Server $server): void
if (validate_timezone($serverTimezone) === false) { {
$serverTimezone = config('app.timezone'); try {
} $skipReason = $this->getDockerCleanupSkipReason($server);
if ($skipReason !== null) {
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); $this->skippedCount++;
if (isset(VALID_CRON_STRINGS[$frequency])) { $this->logSkip('docker_cleanup', $skipReason, [
$frequency = VALID_CRON_STRINGS[$frequency];
}
// Use the frozen execution time for consistent evaluation
if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) {
DockerCleanupJob::dispatch(
$server,
false,
$server->settings->delete_unused_volumes,
$server->settings->delete_unused_networks
);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Docker cleanup dispatched', [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
'server_id' => $server->id, 'server_id' => $server->id,
'server_name' => $server->name, 'server_name' => $server->name,
'error' => $e->getMessage(), 'team_id' => $server->team_id,
]);
return;
}
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
if ($this->shouldDispatch($frequency, $server, "docker-cleanup:{$server->id}")) {
DockerCleanupJob::dispatch(
$server,
false,
$server->settings->delete_unused_volumes,
$server->settings->delete_unused_networks
);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Docker cleanup dispatched', [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]); ]);
} }
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
]);
} }
} }
private function getServersForCleanup(): Collection private function getServersForCleanupQuery(): Builder
{ {
$query = Server::with('settings') $query = Server::with('settings')
->where('ip', '!=', '1.2.3.4'); ->where('ip', '!=', '1.2.3.4');
if (isCloud()) { if (isCloud()) {
$servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); $query
$own = Team::find(0)->servers()->with('settings')->get(); ->with('team.subscription')
->where(function (Builder $query): void {
return $servers->merge($own); $query
->where('team_id', 0)
->orWhereRelation('team.subscription', 'stripe_invoice_paid', true);
});
} }
return $query->get(); return $query;
} }
private function getDockerCleanupSkipReason(Server $server): ?string private function getDockerCleanupSkipReason(Server $server): ?string
@ -428,4 +496,71 @@ private function logSkip(string $type, string $reason, array $context = []): voi
'execution_time' => $this->executionTime?->toIso8601String(), 'execution_time' => $this->executionTime?->toIso8601String(),
], $context)); ], $context));
} }
private function shouldDispatch(string $frequency, Server $server, string $dedupKey): bool
{
return shouldRunCronNow(
$this->normalizeFrequency($frequency),
$this->serverTimezone($server),
$dedupKey,
$this->executionTime,
);
}
private function isDueCandidateBeforeExpensiveChecks(string $frequency, Server $server, string $dedupKey): bool
{
$cron = new CronExpression($this->normalizeFrequency($frequency));
$executionTime = ($this->executionTime ?? Carbon::now())->copy()->setTimezone($this->serverTimezone($server));
$lastDispatched = Cache::get($dedupKey);
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
if ($lastDispatched === null) {
$isDue = $cron->isDue($executionTime);
if (! $isDue) {
Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000);
}
return $isDue;
}
$shouldFire = $previousDue->gt(Carbon::parse($lastDispatched));
if (! $shouldFire) {
Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000);
}
return $shouldFire;
}
private function normalizeFrequency(string $frequency): string
{
return VALID_CRON_STRINGS[$frequency] ?? $frequency;
}
private function serverTimezone(Server $server): string
{
$timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
return validate_timezone($timezone) ? $timezone : config('app.timezone');
}
private function logBackupSkip(ScheduledDatabaseBackup $backup, string $reason): void
{
$this->logSkip('backup', $reason, [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
]);
}
private function logTaskSkip(ScheduledTask $task, string $reason, ?Server $server): void
{
$this->logSkip('task', $reason, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server?->team_id,
]);
}
} }

View file

@ -40,13 +40,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
*/ */
public $timeout = 300; public $timeout = 300;
public Team $team; public ?Team $team = null;
public ?Server $server = null; public ?Server $server = null;
public ScheduledTask $task; public ScheduledTask $task;
public Application|Service $resource; public Application|Service|null $resource = null;
public ?ScheduledTaskExecution $task_log = null; public ?ScheduledTaskExecution $task_log = null;
@ -61,25 +61,34 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
public array $containers = []; public array $containers = [];
public string $server_timezone; public string $server_timezone = 'UTC';
public function __construct($task) public function __construct(ScheduledTask $task)
{ {
$this->onQueue('high'); $this->onQueue(crons_queue());
$this->task = $task; $this->task = $task;
if ($service = $task->service()->first()) { $this->timeout = $this->task->timeout ?? 300;
$this->resource = $service; }
} elseif ($application = $task->application()->first()) {
$this->resource = $application; private function initializeExecutionContext(): void
{
$this->task->loadMissing([
'service.destination.server.settings',
'application.destination.server.settings',
]);
if ($this->task->service) {
$this->resource = $this->task->service;
} elseif ($this->task->application) {
$this->resource = $this->task->application;
} else { } else {
throw new \RuntimeException('ScheduledTaskJob failed: No resource found.'); throw new \RuntimeException('ScheduledTaskJob failed: No resource found.');
} }
$this->team = Team::findOrFail($task->team_id);
$this->server_timezone = $this->getServerTimezone();
// Set timeout from task configuration $this->team = Team::findOrFail($this->task->team_id);
$this->timeout = $this->task->timeout ?? 300; $this->server_timezone = $this->getServerTimezone();
$this->server = $this->resource->destination->server;
} }
private function getServerTimezone(): string private function getServerTimezone(): string
@ -98,6 +107,8 @@ public function handle(): void
$startTime = Carbon::now(); $startTime = Carbon::now();
try { try {
$this->initializeExecutionContext();
$this->task_log = ScheduledTaskExecution::create([ $this->task_log = ScheduledTaskExecution::create([
'scheduled_task_id' => $this->task->id, 'scheduled_task_id' => $this->task->id,
'started_at' => $startTime, 'started_at' => $startTime,
@ -107,8 +118,6 @@ public function handle(): void
// Store execution ID for timeout handling // Store execution ID for timeout handling
$this->executionId = $this->task_log->id; $this->executionId = $this->task_log->id;
$this->server = $this->resource->destination->server;
if ($this->resource->type() === 'application') { if ($this->resource->type() === 'application') {
$containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0); $containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0);
if ($containers->count() > 0) { if ($containers->count() > 0) {
@ -179,7 +188,10 @@ public function handle(): void
// Re-throw to trigger Laravel's retry mechanism with backoff // Re-throw to trigger Laravel's retry mechanism with backoff
throw $e; throw $e;
} finally { } finally {
ScheduledTaskDone::dispatch($this->team->id); if ($this->team) {
ScheduledTaskDone::dispatch($this->team->id);
}
if ($this->task_log) { if ($this->task_log) {
$finishedAt = Carbon::now(); $finishedAt = Carbon::now();
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2); $duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
@ -205,6 +217,8 @@ public function backoff(): array
*/ */
public function failed(?\Throwable $exception): void public function failed(?\Throwable $exception): void
{ {
$this->team ??= Team::find($this->task->team_id);
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [ Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
'job' => 'ScheduledTaskJob', 'job' => 'ScheduledTaskJob',
'task_id' => $this->task->uuid, 'task_id' => $this->task->uuid,

View file

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Events\ServerReachabilityChanged;
use App\Helpers\SshMultiplexingHelper; use App\Helpers\SshMultiplexingHelper;
use App\Models\Server; use App\Models\Server;
use App\Services\ConfigurationRepository; use App\Services\ConfigurationRepository;
@ -43,6 +44,9 @@ private function disableSshMux(): void
public function handle() public function handle()
{ {
$wasReachable = (bool) $this->server->settings->is_reachable;
$wasNotified = (bool) $this->server->unreachable_notification_sent;
try { try {
// Check if server is disabled // Check if server is disabled
if ($this->server->settings->force_disabled) { if ($this->server->settings->force_disabled) {
@ -84,6 +88,8 @@ public function handle()
'server_ip' => $this->server->ip, 'server_ip' => $this->server->ip,
]); ]);
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
return; return;
} }
@ -99,6 +105,8 @@ public function handle()
$this->server->update(['unreachable_count' => 0]); $this->server->update(['unreachable_count' => 0]);
} }
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('ServerConnectionCheckJob failed', [ Log::error('ServerConnectionCheckJob failed', [
@ -111,6 +119,8 @@ public function handle()
]); ]);
$this->server->increment('unreachable_count'); $this->server->increment('unreachable_count');
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
return; return;
} }
} }
@ -118,17 +128,41 @@ public function handle()
public function failed(?\Throwable $exception): void public function failed(?\Throwable $exception): void
{ {
if ($exception instanceof TimeoutExceededException) { if ($exception instanceof TimeoutExceededException) {
$wasReachable = (bool) $this->server->settings->is_reachable;
$wasNotified = (bool) $this->server->unreachable_notification_sent;
$this->server->settings->update([ $this->server->settings->update([
'is_reachable' => false, 'is_reachable' => false,
'is_usable' => false, 'is_usable' => false,
]); ]);
$this->server->increment('unreachable_count'); $this->server->increment('unreachable_count');
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
// Delete the queue job so it doesn't appear in Horizon's failed list. // Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete(); $this->job?->delete();
} }
} }
/**
* Fire ServerReachabilityChanged when state crosses the unreachable threshold (count >= 2)
* or when a previously-notified server recovers. Skips noise from single transient flaps.
*/
private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $wasNotified, bool $isReachable): void
{
if ($isReachable) {
if (! $wasReachable || $wasNotified) {
ServerReachabilityChanged::dispatch($this->server);
}
return;
}
if ($this->server->unreachable_count >= 2 && ! $wasNotified) {
ServerReachabilityChanged::dispatch($this->server);
}
}
private function checkHetznerStatus(): void private function checkHetznerStatus(): void
{ {
$status = null; $status = null;

View file

@ -9,6 +9,7 @@
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Stripe\StripeClient;
class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
{ {
@ -35,7 +36,7 @@ public function handle(): void
$data = data_get($this->event, 'data.object'); $data = data_get($this->event, 'data.object');
switch ($type) { switch ($type) {
case 'radar.early_fraud_warning.created': case 'radar.early_fraud_warning.created':
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = new StripeClient(config('subscription.stripe_api_key'));
$id = data_get($data, 'id'); $id = data_get($data, 'id');
$charge = data_get($data, 'charge'); $charge = data_get($data, 'charge');
if ($charge) { if ($charge) {
@ -94,12 +95,12 @@ public function handle(): void
} }
$subscription = Subscription::where('stripe_customer_id', $customerId)->first(); $subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) { if (! $subscription) {
throw new \RuntimeException("No subscription found for customer: {$customerId}"); break;
} }
if ($subscription->stripe_subscription_id) { if ($subscription->stripe_subscription_id) {
try { try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripeSubscription = $stripe->subscriptions->retrieve( $stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id $subscription->stripe_subscription_id
); );
@ -154,7 +155,7 @@ public function handle(): void
$subscription = Subscription::where('stripe_customer_id', $customerId)->first(); $subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) { if (! $subscription) {
// send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId); // send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No subscription found for customer: {$customerId}"); break;
} }
$team = data_get($subscription, 'team'); $team = data_get($subscription, 'team');
if (! $team) { if (! $team) {
@ -165,7 +166,7 @@ public function handle(): void
// Verify payment status with Stripe API before sending failure notification // Verify payment status with Stripe API before sending failure notification
if ($paymentIntentId) { if ($paymentIntentId) {
try { try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); $stripe = new StripeClient(config('subscription.stripe_api_key'));
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId); $paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) { if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
@ -190,7 +191,7 @@ public function handle(): void
$subscription = Subscription::where('stripe_customer_id', $customerId)->first(); $subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) { if (! $subscription) {
// send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId); // send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}"); break;
} }
if ($subscription->stripe_invoice_paid) { if ($subscription->stripe_invoice_paid) {
// send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId); // send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
@ -334,7 +335,7 @@ public function handle(): void
} }
} else { } else {
// send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId); // send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}"); break;
} }
break; break;
default: default:

View file

@ -43,27 +43,34 @@ public function handle()
protected function cloneLocalVolume() protected function cloneLocalVolume()
{ {
$srcVol = escapeshellarg($this->sourceVolume);
$tgtVol = escapeshellarg($this->targetVolume);
instant_remote_process([ instant_remote_process([
"docker volume create $this->targetVolume", "docker volume create {$tgtVol}",
"docker run --rm -v $this->sourceVolume:/source -v $this->targetVolume:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'", "docker run --rm -v {$srcVol}:/source -v {$tgtVol}:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'",
], $this->sourceServer); ], $this->sourceServer);
} }
protected function cloneRemoteVolume() protected function cloneRemoteVolume()
{ {
$srcVol = escapeshellarg($this->sourceVolume);
$tgtVol = escapeshellarg($this->targetVolume);
$sourceCloneDir = "{$this->cloneDir}/{$this->sourceVolume}"; $sourceCloneDir = "{$this->cloneDir}/{$this->sourceVolume}";
$targetCloneDir = "{$this->cloneDir}/{$this->targetVolume}"; $targetCloneDir = "{$this->cloneDir}/{$this->targetVolume}";
$srcDir = escapeshellarg($sourceCloneDir);
$tgtDir = escapeshellarg($targetCloneDir);
try { try {
instant_remote_process([ instant_remote_process([
"mkdir -p $sourceCloneDir", "mkdir -p {$srcDir}",
"chmod 777 $sourceCloneDir", "chmod 777 {$srcDir}",
"docker run --rm -v $this->sourceVolume:/source -v $sourceCloneDir:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'", "docker run --rm -v {$srcVol}:/source -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'",
], $this->sourceServer); ], $this->sourceServer);
instant_remote_process([ instant_remote_process([
"mkdir -p $targetCloneDir", "mkdir -p {$tgtDir}",
"chmod 777 $targetCloneDir", "chmod 777 {$tgtDir}",
], $this->targetServer); ], $this->targetServer);
instant_scp( instant_scp(
@ -74,8 +81,8 @@ protected function cloneRemoteVolume()
); );
instant_remote_process([ instant_remote_process([
"docker volume create $this->targetVolume", "docker volume create {$tgtVol}",
"docker run --rm -v $this->targetVolume:/target -v $targetCloneDir:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'", "docker run --rm -v {$tgtVol}:/target -v {$tgtDir}:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'",
], $this->targetServer); ], $this->targetServer);
} catch (\Exception $e) { } catch (\Exception $e) {
@ -84,7 +91,7 @@ protected function cloneRemoteVolume()
} finally { } finally {
try { try {
instant_remote_process([ instant_remote_process([
"rm -rf $sourceCloneDir", "rm -rf {$srcDir}",
], $this->sourceServer, false); ], $this->sourceServer, false);
} catch (\Exception $e) { } catch (\Exception $e) {
\Log::warning('Failed to clean up source server clone directory: '.$e->getMessage()); \Log::warning('Failed to clean up source server clone directory: '.$e->getMessage());
@ -93,7 +100,7 @@ protected function cloneRemoteVolume()
try { try {
if ($this->targetServer) { if ($this->targetServer) {
instant_remote_process([ instant_remote_process([
"rm -rf $targetCloneDir", "rm -rf {$tgtDir}",
], $this->targetServer, false); ], $this->targetServer, false);
} }
} catch (\Exception $e) { } catch (\Exception $e) {

View file

@ -37,7 +37,7 @@ public function back()
Auth::login($user); Auth::login($user);
refreshSession($team_to_switch_to); refreshSession($team_to_switch_to);
return redirect(request()->header('Referer')); return redirect()->route('admin.index');
} }
} }
@ -70,7 +70,7 @@ public function switchUser(int $user_id)
Auth::login($user); Auth::login($user);
refreshSession($team_to_switch_to); refreshSession($team_to_switch_to);
return redirect(request()->header('Referer')); return redirect()->route('dashboard');
} }
private function authorizeAdminAccess(): void private function authorizeAdminAccess(): void

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Destination; namespace App\Livewire\Destination;
use App\Models\Server; use App\Models\Server;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
@ -11,9 +12,15 @@ class Index extends Component
#[Locked] #[Locked]
public $servers; public $servers;
public function mount() #[Locked]
public Collection $destinations;
public function mount(): void
{ {
$this->servers = Server::isUsable()->get(); $this->servers = Server::isUsable()->get();
$this->destinations = $this->servers
->flatMap(fn (Server $server) => $server->standaloneDockers->concat($server->swarmDockers))
->values();
} }
public function render() public function render()

View file

@ -33,44 +33,49 @@ class Docker extends Component
#[Validate(['required', 'boolean'])] #[Validate(['required', 'boolean'])]
public bool $isSwarm = false; public bool $isSwarm = false;
public function mount(?string $server_id = null) public function mount(?string $server_id = null): void
{ {
$this->network = new Cuid2; $this->network = (string) new Cuid2;
$this->servers = Server::isUsable()->get(); $this->servers = Server::isUsable()->get();
if ($server_id) {
$foundServer = $this->servers->find($server_id) ?: $this->servers->first(); if (filled($server_id)) {
if (! $foundServer) { $this->selectedServer = Server::ownedByCurrentTeam()->whereKey($server_id)->firstOrFail();
throw new \Exception('Server not found.');
if (! $this->servers->contains('id', $this->selectedServer->id)) {
$this->servers->push($this->selectedServer);
} }
$this->selectedServer = $foundServer;
$this->serverId = $this->selectedServer->id; $this->serverId = (string) $this->selectedServer->id;
} else { } else {
$foundServer = $this->servers->first(); $foundServer = $this->servers->first();
if (! $foundServer) { if (! $foundServer) {
throw new \Exception('Server not found.'); throw new \Exception('Server not found.');
} }
$this->selectedServer = $foundServer; $this->selectedServer = $foundServer;
$this->serverId = $this->selectedServer->id; $this->serverId = (string) $this->selectedServer->id;
} }
$this->generateName(); $this->generateName();
} }
public function updatedServerId() public function updatedServerId(): void
{ {
$this->selectedServer = $this->servers->find($this->serverId); $this->selectedServer = $this->servers->find($this->serverId);
if (! $this->selectedServer) {
throw new \Exception('Server not found.');
}
$this->generateName(); $this->generateName();
} }
public function generateName() public function generateName(): void
{ {
$name = data_get($this->selectedServer, 'name', new Cuid2); $name = data_get($this->selectedServer, 'name', new Cuid2);
$this->name = str("{$name}-{$this->network}")->kebab(); $this->name = str("{$name}-{$this->network}")->kebab();
} }
public function submit() public function submit(): mixed
{ {
try { try {
$this->authorize('create', StandaloneDocker::class); $this->authorize('create', $this->isSwarm ? SwarmDocker::class : StandaloneDocker::class);
$this->validate(); $this->validate();
if ($this->isSwarm) { if ($this->isSwarm) {
$found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first(); $found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first();

View file

@ -2,9 +2,7 @@
namespace App\Livewire\Destination; namespace App\Livewire\Destination;
use App\Models\Server;
use App\Models\StandaloneDocker; use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
@ -29,16 +27,8 @@ class Show extends Component
public function mount(string $destination_uuid) public function mount(string $destination_uuid)
{ {
try { try {
$destination = StandaloneDocker::whereUuid($destination_uuid)->first() ?? $destination = find_destination_for_current_team($destination_uuid);
SwarmDocker::whereUuid($destination_uuid)->firstOrFail(); if (! $destination) {
$ownedByTeam = Server::ownedByCurrentTeam()->each(function ($server) use ($destination) {
if ($server->standaloneDockers->contains($destination) || $server->swarmDockers->contains($destination)) {
$this->destination = $destination;
$this->syncData();
}
});
if ($ownedByTeam === false) {
return redirect()->route('destination.index'); return redirect()->route('destination.index');
} }
$this->destination = $destination; $this->destination = $destination;
@ -80,7 +70,7 @@ public function delete()
try { try {
$this->authorize('delete', $this->destination); $this->authorize('delete', $this->destination);
if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) { if ($this->destination->getMorphClass() === StandaloneDocker::class) {
if ($this->destination->attachedTo()) { if ($this->destination->attachedTo()) {
return $this->dispatch('error', 'You must delete all resources before deleting this destination.'); return $this->dispatch('error', 'You must delete all resources before deleting this destination.');
} }

View file

@ -47,14 +47,10 @@ public function submit()
try { try {
$this->rateLimit(10); $this->rateLimit(10);
$this->validate(); $this->validate();
$firstLogin = auth()->user()->created_at == auth()->user()->updated_at;
auth()->user()->fill([ auth()->user()->fill([
'password' => Hash::make($this->password), 'password' => Hash::make($this->password),
'force_password_reset' => false, 'force_password_reset' => false,
])->save(); ])->save();
if ($firstLogin) {
send_internal_notification('First login for '.auth()->user()->email);
}
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} catch (\Throwable $e) { } catch (\Throwable $e) {

View file

@ -1496,7 +1496,10 @@ public function getServicesProperty()
'category' => 'Services', 'category' => 'Services',
'resourceType' => 'service', 'resourceType' => 'service',
'logo' => data_get($service, 'logo'), 'logo' => data_get($service, 'logo'),
]); ] + array_filter([
'amd_only' => data_get($service, 'amd_only') ? true : null,
'arm_only' => data_get($service, 'arm_only') ? true : null,
]));
} }
$cachedServices = $items->toArray(); $cachedServices = $items->toArray();

View file

@ -15,7 +15,7 @@ class Help extends Component
#[Validate(['required', 'min:10', 'max:1000'])] #[Validate(['required', 'min:10', 'max:1000'])]
public string $description; public string $description;
#[Validate(['required', 'min:3'])] #[Validate(['required', 'min:3', 'max:600'])]
public string $subject; public string $subject;
public function submit() public function submit()

View file

@ -45,7 +45,7 @@ class Email extends Component
public ?string $smtpPort = null; public ?string $smtpPort = null;
#[Validate(['nullable', 'string', 'in:starttls,tls,none'])] #[Validate(['nullable', 'string', 'in:starttls,tls,none'])]
public ?string $smtpEncryption = null; public ?string $smtpEncryption = 'starttls';
#[Validate(['nullable', 'string'])] #[Validate(['nullable', 'string'])]
public ?string $smtpUsername = null; public ?string $smtpUsername = null;

View file

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Profile;
use Livewire\Component;
class Appearance extends Component
{
public function render()
{
return view('livewire.profile.appearance');
}
}

View file

@ -4,6 +4,8 @@
use App\Models\Application; use App\Models\Application;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
@ -61,6 +63,9 @@ class Advanced extends Component
#[Validate(['string', 'nullable'])] #[Validate(['string', 'nullable'])]
public ?string $gpuOptions = null; public ?string $gpuOptions = null;
#[Validate(['string', 'nullable'])]
public ?string $stopGracePeriod = null;
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $isBuildServerEnabled = false; public bool $isBuildServerEnabled = false;
@ -145,6 +150,10 @@ public function syncData(bool $toModel = false)
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
} }
// Load stop_grace_period separately since it has its own save handler
// Convert null to empty string to prevent dirty detection issues
$this->stopGracePeriod = $this->application->settings->stop_grace_period ?? '';
} }
private function resetDefaultLabels() private function resetDefaultLabels()
@ -210,6 +219,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Settings saved.'); $this->dispatch('success', 'Settings saved.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -228,6 +238,7 @@ public function saveCustomName()
if (is_null($this->customInternalName)) { if (is_null($this->customInternalName)) {
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Custom name saved.'); $this->dispatch('success', 'Custom name saved.');
$this->dispatch('configurationChanged');
return; return;
} }
@ -247,6 +258,32 @@ public function saveCustomName()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Custom name saved.'); $this->dispatch('success', 'Custom name saved.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function saveStopGracePeriod()
{
try {
$this->authorize('update', $this->application);
$validated = Validator::make(
['stopGracePeriod' => $this->stopGracePeriod === '' ? null : $this->stopGracePeriod],
['stopGracePeriod' => ['nullable', 'integer', 'min:'.MIN_STOP_GRACE_PERIOD_SECONDS, 'max:'.MAX_STOP_GRACE_PERIOD_SECONDS]],
[],
['stopGracePeriod' => 'stop grace period']
)->validate();
$this->application->settings->stop_grace_period = $validated['stopGracePeriod'] === null
? null
: (int) $validated['stopGracePeriod'];
$this->application->settings->save();
$this->dispatch('success', 'Stop grace period updated.');
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View file

@ -17,17 +17,10 @@ class Configuration extends Component
public $servers; public $servers;
public function getListeners() protected $listeners = [
{ 'buildPackUpdated' => '$refresh',
$teamId = auth()->user()->currentTeam()->id; 'refresh' => '$refresh',
];
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
"echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh',
'buildPackUpdated' => '$refresh',
'refresh' => '$refresh',
];
}
public function mount() public function mount()
{ {
@ -51,8 +44,6 @@ public function mount()
$this->environment = $environment; $this->environment = $environment;
$this->application = $application; $this->application = $application;
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
} }

View file

@ -108,19 +108,6 @@ public function getLogLinesProperty()
return decode_remote_command_output($this->application_deployment_queue); return decode_remote_command_output($this->application_deployment_queue);
} }
public function copyLogs(): string
{
$logs = decode_remote_command_output($this->application_deployment_queue)
->map(function ($line) {
return $line['timestamp'].' '.
(isset($line['command']) && $line['command'] ? '[CMD]: ' : '').
trim($line['line']);
})
->join("\n");
return sanitizeLogsForExport($logs);
}
public function downloadAllLogs(): string public function downloadAllLogs(): string
{ {
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true) $logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)

View file

@ -197,12 +197,12 @@ protected function messages(): array
'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.', 'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.',
'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.', 'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.',
'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.', 'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.',
'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'installCommand.regex' => 'The install command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', 'installCommand.regex' => 'The install command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'buildCommand.regex' => 'The build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', 'buildCommand.regex' => 'The build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'startCommand.regex' => 'The start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', 'startCommand.regex' => 'The start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'name.required' => 'The Name field is required.', 'name.required' => 'The Name field is required.',
@ -606,7 +606,7 @@ public function updatedBuildPack()
// Sync property to model before checking/modifying // Sync property to model before checking/modifying
$this->syncData(toModel: true); $this->syncData(toModel: true);
if ($this->buildPack !== 'nixpacks') { if ($this->buildPack !== 'nixpacks' && $this->buildPack !== 'railpack') {
$this->isStatic = false; $this->isStatic = false;
$this->application->settings->is_static = false; $this->application->settings->is_static = false;
$this->application->settings->save(); $this->application->settings->save();

View file

@ -338,10 +338,11 @@ public function addDockerImagePreview()
private function stopContainers(array $containers, $server) private function stopContainers(array $containers, $server)
{ {
$containersToStop = collect($containers)->pluck('Names')->toArray(); $containersToStop = collect($containers)->pluck('Names')->toArray();
$timeout = $this->application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) { foreach ($containersToStop as $containerName) {
instant_remote_process(command: [ instant_remote_process(command: [
"docker stop -t 30 $containerName", "docker stop --time=$timeout $containerName",
"docker rm -f $containerName", "docker rm -f $containerName",
], server: $server, throwError: false); ], server: $server, throwError: false);
} }

View file

@ -0,0 +1,41 @@
<?php
namespace App\Livewire\Project\Application;
use App\Models\Application;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class ServerStatusBadge extends Component
{
public Application $application;
public function getListeners(): array
{
$user = Auth::user();
if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [
"echo-private:team.{$team->id},ServiceStatusChanged" => 'refreshStatus',
"echo-private:team.{$team->id},ServiceChecked" => 'refreshStatus',
];
}
public function refreshStatus(): void
{
$this->application->refresh();
}
public function render(): View
{
return view('livewire.project.application.server-status-badge');
}
}

View file

@ -3,6 +3,8 @@
namespace App\Livewire\Project\Application; namespace App\Livewire\Project\Application;
use App\Models\Application; use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -21,7 +23,7 @@ class Source extends Component
#[Validate(['nullable', 'string'])] #[Validate(['nullable', 'string'])]
public ?string $privateKeyName = null; public ?string $privateKeyName = null;
#[Validate(['nullable', 'integer'])] #[Locked]
public ?int $privateKeyId = null; public ?int $privateKeyId = null;
#[Validate(['required', 'string'])] #[Validate(['required', 'string'])]
@ -103,12 +105,14 @@ public function setPrivateKey(int $privateKeyId)
{ {
try { try {
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
$this->privateKeyId = $privateKeyId; $key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId);
$this->privateKeyId = $key->id;
$this->syncData(true); $this->syncData(true);
$this->getPrivateKeys(); $this->getPrivateKeys();
$this->application->refresh(); $this->application->refresh();
$this->privateKeyName = $this->application->private_key->name; $this->privateKeyName = $this->application->private_key->name;
$this->dispatch('success', 'Private key updated!'); $this->dispatch('success', 'Private key updated!');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -124,6 +128,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Application source updated!'); $this->dispatch('success', 'Application source updated!');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -134,8 +139,11 @@ public function changeSource($sourceId, $sourceType)
try { try {
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
$allowedSourceTypes = [GithubApp::class, GitlabApp::class];
abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404);
$source = $sourceType::ownedByCurrentTeam()->findOrFail($sourceId);
$this->application->update([ $this->application->update([
'source_id' => $sourceId, 'source_id' => $source->id,
'source_type' => $sourceType, 'source_type' => $sourceType,
]); ]);

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -144,7 +145,7 @@ public function delete($password, $selectedActions = [])
try { try {
$server = null; $server = null;
if ($this->backup->database instanceof \App\Models\ServiceDatabase) { if ($this->backup->database instanceof ServiceDatabase) {
$server = $this->backup->database->service->destination->server; $server = $this->backup->database->service->destination->server;
} elseif ($this->backup->database->destination && $this->backup->database->destination->server) { } elseif ($this->backup->database->destination && $this->backup->database->destination->server) {
$server = $this->backup->database->destination->server; $server = $this->backup->database->destination->server;
@ -170,7 +171,7 @@ public function delete($password, $selectedActions = [])
$this->backup->delete(); $this->backup->delete();
if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $this->backup->database; $serviceDatabase = $this->backup->database;
return redirect()->route('project.service.database.backups', [ return redirect()->route('project.service.database.backups', [
@ -182,7 +183,7 @@ public function delete($password, $selectedActions = [])
} else { } else {
return redirect()->route('project.database.backup.index', $this->parameters); return redirect()->route('project.database.backup.index', $this->parameters);
} }
} catch (\Exception $e) { } catch (Exception $e) {
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
return handleError($e, $this); return handleError($e, $this);
@ -207,6 +208,13 @@ private function customValidate()
$this->backup->s3_storage_id = null; $this->backup->s3_storage_id = null;
} }
// S3 backup cannot be enabled without a valid S3 storage owned by the team
$availableS3Ids = collect($this->s3s)->pluck('id');
if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) {
$this->backup->save_s3 = $this->saveS3 = false;
$this->backup->s3_storage_id = $this->s3StorageId = null;
}
// Validate that disable_local_backup can only be true when S3 backup is enabled // Validate that disable_local_backup can only be true when S3 backup is enabled
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) { if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
$this->backup->disable_local_backup = $this->disableLocalBackup = false; $this->backup->disable_local_backup = $this->disableLocalBackup = false;
@ -214,7 +222,7 @@ private function customValidate()
$isValid = validate_cron_expression($this->backup->frequency); $isValid = validate_cron_expression($this->backup->frequency);
if (! $isValid) { if (! $isValid) {
throw new \Exception('Invalid Cron / Human expression'); throw new Exception('Invalid Cron / Human expression');
} }
$this->validate(); $this->validate();
} }

View file

@ -40,18 +40,21 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public function getListeners() public function getListeners(): array
{ {
$teamId = Auth::user()->currentTeam()->id; $user = Auth::user();
if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [ return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
]; ];
} }
@ -76,16 +79,18 @@ protected function rules(): array
return [ return [
'name' => ValidationPatterns::nameRules(), 'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'clickhouseAdminUser' => 'required|string', 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules(
'clickhouseAdminPassword' => 'required|string', enforcePattern: $this->clickhouseAdminUser !== $this->database->clickhouse_admin_user,
),
'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->clickhouseAdminPassword !== $this->database->clickhouse_admin_password,
),
'image' => 'required|string', 'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(), 'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean', 'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string', 'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
]; ];
} }
@ -96,10 +101,8 @@ protected function messages(): array
ValidationPatterns::combinedMessages(), ValidationPatterns::combinedMessages(),
ValidationPatterns::portMappingMessages(), ValidationPatterns::portMappingMessages(),
[ [
'clickhouseAdminUser.required' => 'The Admin User field is required.', ...ValidationPatterns::databaseIdentifierMessages('clickhouseAdminUser', 'Admin User'),
'clickhouseAdminUser.string' => 'The Admin User must be a string.', ...ValidationPatterns::databasePasswordMessages('clickhouseAdminPassword', 'Admin Password'),
'clickhouseAdminPassword.required' => 'The Admin Password field is required.',
'clickhouseAdminPassword.string' => 'The Admin Password must be a string.',
'image.required' => 'The Docker Image field is required.', 'image.required' => 'The Docker Image field is required.',
'image.string' => 'The Docker Image must be a string.', 'image.string' => 'The Docker Image must be a string.',
'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.integer' => 'The Public Port must be an integer.',
@ -127,9 +130,6 @@ public function syncData(bool $toModel = false)
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -142,8 +142,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} }
} }
@ -192,6 +190,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -200,9 +199,13 @@ public function instantSave()
} }
} }
public function databaseProxyStopped() public function databaseProxyStopped(): void
{ {
$this->syncData(); $this->database->refresh();
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->dispatch('databaseUpdated');
} }
public function submit() public function submit()
@ -218,6 +221,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {

View file

@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Project\Database\Clickhouse;
use App\Models\StandaloneClickhouse;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneClickhouse $database;
protected function databaseLabel(): string
{
return 'Clickhouse';
}
protected function supportsSsl(): bool
{
return false;
}
protected function showPublicUrlPlaceholder(): bool
{
return true;
}
}

View file

@ -2,8 +2,9 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use Auth; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\ItemNotFoundException;
use Livewire\Component; use Livewire\Component;
class Configuration extends Component class Configuration extends Component
@ -18,15 +19,6 @@ class Configuration extends Component
public $environment; public $environment;
public function getListeners()
{
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
];
}
public function mount() public function mount()
{ {
try { try {
@ -55,10 +47,10 @@ public function mount()
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { if ($e instanceof AuthorizationException) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }
if ($e instanceof \Illuminate\Support\ItemNotFoundException) { if ($e instanceof ItemNotFoundException) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }

View file

@ -2,7 +2,9 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -48,6 +50,20 @@ public function submit()
$this->validate(); $this->validate();
if ($this->saveToS3) {
$s3StorageExists = ! is_null($this->s3StorageId)
&& S3Storage::where('team_id', currentTeam()->id)
->where('is_usable', true)
->whereKey($this->s3StorageId)
->exists();
if (! $s3StorageExists) {
$this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.');
return;
}
}
$isValid = validate_cron_expression($this->frequency); $isValid = validate_cron_expression($this->frequency);
if (! $isValid) { if (! $isValid) {
$this->dispatch('error', 'Invalid Cron / Human expression.'); $this->dispatch('error', 'Invalid Cron / Human expression.');
@ -74,7 +90,7 @@ public function submit()
} }
$databaseBackup = ScheduledDatabaseBackup::create($payload); $databaseBackup = ScheduledDatabaseBackup::create($payload);
if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) { if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->dispatch('refreshScheduledBackups', $databaseBackup->id); $this->dispatch('refreshScheduledBackups', $databaseBackup->id);
} else { } else {
$this->dispatch('refreshScheduledBackups'); $this->dispatch('refreshScheduledBackups');

View file

@ -4,11 +4,9 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneDragonfly; use App\Models\StandaloneDragonfly;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -40,25 +38,21 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?Carbon $certificateValidUntil = null; public function getListeners(): array
public bool $enable_ssl = false;
public function getListeners()
{ {
$userId = Auth::id(); $user = Auth::user();
$teamId = Auth::user()->currentTeam()->id; if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [ return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
]; ];
} }
@ -73,12 +67,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -89,17 +77,16 @@ protected function rules(): array
return [ return [
'name' => ValidationPatterns::nameRules(), 'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'dragonflyPassword' => 'required|string', 'dragonflyPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password,
),
'image' => 'required|string', 'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(), 'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean', 'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string', 'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'enable_ssl' => 'nullable|boolean',
]; ];
} }
@ -109,8 +96,7 @@ protected function messages(): array
ValidationPatterns::combinedMessages(), ValidationPatterns::combinedMessages(),
ValidationPatterns::portMappingMessages(), ValidationPatterns::portMappingMessages(),
[ [
'dragonflyPassword.required' => 'The Dragonfly Password field is required.', ...ValidationPatterns::databasePasswordMessages('dragonflyPassword', 'Dragonfly Password'),
'dragonflyPassword.string' => 'The Dragonfly Password must be a string.',
'image.required' => 'The Docker Image field is required.', 'image.required' => 'The Docker Image field is required.',
'image.string' => 'The Docker Image must be a string.', 'image.string' => 'The Docker Image must be a string.',
'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.integer' => 'The Public Port must be an integer.',
@ -136,11 +122,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -152,9 +134,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} }
} }
@ -203,6 +182,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -211,9 +191,13 @@ public function instantSave()
} }
} }
public function databaseProxyStopped() public function databaseProxyStopped(): void
{ {
$this->syncData(); $this->database->refresh();
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->dispatch('databaseUpdated');
} }
public function submit() public function submit()
@ -229,6 +213,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -240,67 +225,6 @@ public function submit()
} }
} }
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()
->where('is_ca_certificate', true)
->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,26 @@
<?php
namespace App\Livewire\Project\Database\Dragonfly;
use App\Models\StandaloneDragonfly;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneDragonfly $database;
protected function databaseLabel(): string
{
return 'Dragonfly';
}
protected function showPublicUrlPlaceholder(): bool
{
return true;
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace App\Livewire\Project\Database;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Health extends Component
{
use AuthorizesRequests;
public $database;
#[Validate(['boolean'])]
public bool $healthCheckEnabled = true;
#[Validate(['integer', 'min:1'])]
public int $healthCheckInterval = 15;
#[Validate(['integer', 'min:1'])]
public int $healthCheckTimeout = 5;
#[Validate(['integer', 'min:1'])]
public int $healthCheckRetries = 5;
#[Validate(['integer', 'min:0'])]
public int $healthCheckStartPeriod = 5;
public function mount(): void
{
$this->authorize('view', $this->database);
$this->syncData();
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
$this->database->health_check_enabled = $this->healthCheckEnabled;
$this->database->health_check_interval = $this->healthCheckInterval;
$this->database->health_check_timeout = $this->healthCheckTimeout;
$this->database->health_check_retries = $this->healthCheckRetries;
$this->database->health_check_start_period = $this->healthCheckStartPeriod;
$this->database->save();
} else {
$this->healthCheckEnabled = $this->database->health_check_enabled;
$this->healthCheckInterval = $this->database->health_check_interval;
$this->healthCheckTimeout = $this->database->health_check_timeout;
$this->healthCheckRetries = $this->database->health_check_retries;
$this->healthCheckStartPeriod = $this->database->health_check_start_period;
}
}
public function instantSave(): void
{
$this->submit();
}
public function submit(): void
{
$updateSuccessful = false;
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$updateSuccessful = true;
$this->dispatch('success', 'Health check updated. Restart the database to apply the changes.');
} catch (\Throwable $e) {
handleError($e, $this);
}
if (! $updateSuccessful) {
return;
}
$this->markConfigurationChanged();
}
public function toggleHealthcheck(): void
{
$updateSuccessful = false;
try {
$this->authorize('update', $this->database);
$this->healthCheckEnabled = ! $this->healthCheckEnabled;
$this->syncData(true);
$updateSuccessful = true;
$this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'. Restart the database to apply the changes.');
} catch (\Throwable $e) {
handleError($e, $this);
}
if (! $updateSuccessful) {
return;
}
$this->markConfigurationChanged();
}
private function markConfigurationChanged(): void
{
if (is_null($this->database->config_hash)) {
$this->database->isConfigurationChanged(true);
return;
}
$this->dispatch('configurationChanged');
}
public function render(): View
{
return view('livewire.project.database.health');
}
}

View file

@ -2,14 +2,14 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use App\Models\S3Storage; use App\Models\ServiceDatabase;
use App\Models\Server; use App\Models\StandaloneClickhouse;
use App\Models\Service; use App\Models\StandaloneDragonfly;
use App\Support\ValidationPatterns; use App\Models\StandaloneKeydb;
use App\Models\StandaloneRedis;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
@ -17,797 +17,134 @@ class Import extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
/**
* Validate that a string is safe for use as an S3 bucket name.
* Allows alphanumerics, dots, dashes, and underscores.
*/
private function validateBucketName(string $bucket): bool
{
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
}
/**
* Validate that a string is safe for use as an S3 path.
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
*/
private function validateS3Path(string $path): bool
{
// Must not be empty
if (empty($path)) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
}
/**
* Validate that a string is safe for use as a file path on the server.
*/
private function validateServerPath(string $path): bool
{
// Must be an absolute path
if (! str_starts_with($path, '/')) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
}
public bool $unsupported = false;
// Store IDs instead of models for proper Livewire serialization
#[Locked] #[Locked]
public ?int $resourceId = null; public ?int $resourceId = null;
#[Locked] #[Locked]
public ?string $resourceType = null; public ?string $resourceType = null;
#[Locked]
public ?int $serverId = null;
// View-friendly properties to avoid computed property access in Blade
#[Locked]
public string $resourceUuid = '';
public string $resourceStatus = ''; public string $resourceStatus = '';
#[Locked] public string $resourceUuid = '';
public string $resourceDbType = '';
public array $parameters = []; public bool $unsupported = false;
public array $containers = []; public function getListeners(): array
public bool $scpInProgress = false;
public bool $importRunning = false;
public ?string $filename = null;
public ?string $filesize = null;
public bool $isUploading = false;
public int $progress = 0;
public bool $error = false;
#[Locked]
public string $container;
public array $importCommands = [];
public bool $dumpAll = false;
public string $restoreCommandText = '';
public string $customLocation = '';
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
// S3 Restore properties
public array $availableS3Storages = [];
public ?int $s3StorageId = null;
public string $s3Path = '';
public ?int $s3FileSize = null;
#[Computed]
public function resource()
{ {
if ($this->resourceId === null || $this->resourceType === null) { $listeners = ['databaseUpdated' => 'refreshStatus'];
return null;
$user = Auth::user();
if (! $user) {
return $listeners;
} }
return $this->resourceType::find($this->resourceId); $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus';
}
#[Computed] $team = $user->currentTeam();
public function server() if ($team) {
{ $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus';
if ($this->serverId === null) {
return null;
} }
return Server::ownedByCurrentTeam()->find($this->serverId); return $listeners;
} }
public function getListeners() public function mount(): void
{ {
$userId = Auth::id(); $resource = $this->resolveResourceFromRoute();
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'slideOverClosed' => 'resetActivityId',
];
}
public function resetActivityId()
{
$this->activityId = null;
}
public function mount()
{
$this->parameters = get_route_parameters();
$this->getContainers();
$this->loadAvailableS3Storages();
}
public function updatedDumpAll($value)
{
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
}
}
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case 'mariadb':
if ($value === true) {
$this->mariadbRestoreCommand = <<<'EOD'
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
} else {
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
}
break;
case \App\Models\StandaloneMysql::class:
case 'mysql':
if ($value === true) {
$this->mysqlRestoreCommand = <<<'EOD'
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
} else {
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
}
break;
case \App\Models\StandalonePostgresql::class:
case 'postgresql':
if ($value === true) {
$this->postgresqlRestoreCommand = <<<'EOD'
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
EOD;
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
} else {
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
}
break;
}
}
public function getContainers()
{
$this->containers = [];
$teamId = data_get(auth()->user()->currentTeam(), 'id');
// Try to find resource by route parameter
$databaseUuid = data_get($this->parameters, 'database_uuid');
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
$resource = null;
if ($databaseUuid) {
// Standalone database route
$resource = getResourceByUuid($databaseUuid, $teamId);
if (is_null($resource)) {
abort(404);
}
} elseif ($stackServiceUuid) {
// ServiceDatabase route - look up the service database
$serviceUuid = data_get($this->parameters, 'service_uuid');
$service = Service::whereUuid($serviceUuid)->first();
if (! $service) {
abort(404);
}
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if (is_null($resource)) {
abort(404);
}
} else {
abort(404);
}
$this->authorize('view', $resource); $this->authorize('view', $resource);
// Store IDs for Livewire serialization
$this->resourceId = $resource->id; $this->resourceId = $resource->id;
$this->resourceType = get_class($resource); $this->resourceType = get_class($resource);
// Store view-friendly properties $this->refreshStatus();
}
public function refreshStatus(): void
{
$resource = $this->resolveStoredResource();
$this->authorize('view', $resource);
$resource->refresh();
$this->resourceUuid = $resource->uuid;
$this->resourceStatus = $resource->status ?? ''; $this->resourceStatus = $resource->status ?? '';
$this->unsupported = $this->isUnsupportedResource($resource);
}
// Handle ServiceDatabase server access differently public function render(): View
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { {
$server = $resource->service?->server; return view('livewire.project.database.import');
if (! $server) { }
abort(404, 'Server not found for this service database.');
}
$this->serverId = $server->id;
$this->container = $resource->name.'-'.$resource->service->uuid;
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
// Determine database type for ServiceDatabase private function resolveResourceFromRoute(): object
$dbType = $resource->databaseType(); {
if (str_contains($dbType, 'postgres')) { $parameters = get_route_parameters();
$this->resourceDbType = 'standalone-postgresql'; $teamId = data_get(Auth::user()?->currentTeam(), 'id');
} elseif (str_contains($dbType, 'mysql')) { $databaseUuid = data_get($parameters, 'database_uuid');
$this->resourceDbType = 'standalone-mysql'; $stackServiceUuid = data_get($parameters, 'stack_service_uuid');
} elseif (str_contains($dbType, 'mariadb')) {
$this->resourceDbType = 'standalone-mariadb'; if ($databaseUuid) {
} elseif (str_contains($dbType, 'mongo')) { $resource = getResourceByUuid($databaseUuid, $teamId);
$this->resourceDbType = 'standalone-mongodb'; if ($resource) {
} else { return $resource;
$this->resourceDbType = $dbType;
} }
} else {
$server = $resource->destination?->server; abort(404);
if (! $server) {
abort(404, 'Server not found for this database.');
}
$this->serverId = $server->id;
$this->container = $resource->uuid;
$this->resourceUuid = $resource->uuid;
$this->resourceDbType = $resource->type();
} }
if (str($resource->status)->startsWith('running')) { if ($stackServiceUuid) {
$this->containers[] = $this->container; $project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', data_get($parameters, 'project_uuid'))
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', data_get($parameters, 'environment_uuid'))
->firstOrFail();
$service = $environment->services()->whereUuid(data_get($parameters, 'service_uuid'))->firstOrFail();
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if ($resource) {
return $resource;
}
} }
abort(404);
}
private function resolveStoredResource(): object
{
if ($this->resourceId === null || $this->resourceType === null) {
return $this->resolveResourceFromRoute();
}
$resource = $this->resourceType::find($this->resourceId);
if ($resource) {
return $resource;
}
abort(404);
}
private function isUnsupportedResource(object $resource): bool
{
if ( if (
$resource->getMorphClass() === \App\Models\StandaloneRedis::class || $resource instanceof StandaloneRedis ||
$resource->getMorphClass() === \App\Models\StandaloneKeydb::class || $resource instanceof StandaloneKeydb ||
$resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || $resource instanceof StandaloneDragonfly ||
$resource->getMorphClass() === \App\Models\StandaloneClickhouse::class $resource instanceof StandaloneClickhouse
) { ) {
$this->unsupported = true; return true;
} }
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) if ($resource instanceof ServiceDatabase) {
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$dbType = $resource->databaseType(); $dbType = $resource->databaseType();
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
$this->unsupported = true;
}
}
}
public function checkFile() return str_contains($dbType, 'redis') ||
{ str_contains($dbType, 'keydb') ||
if (filled($this->customLocation)) { str_contains($dbType, 'dragonfly') ||
// Validate the custom location to prevent command injection str_contains($dbType, 'clickhouse');
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
if (blank($result)) {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return;
}
$this->filename = $this->customLocation;
$this->dispatch('success', 'The file exists.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
}
public function runImport(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
} }
$this->authorize('update', $this->resource); return false;
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if ($this->filename === '') {
$this->dispatch('error', 'Please select a file to import.');
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return true;
}
try {
$this->importRunning = true;
$this->importCommands = [];
$backupFileName = "upload/{$this->resourceUuid}/restore";
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return true;
}
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return true;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$restoreCommand = $this->buildRestoreCommand($tmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$this->importCommands[] = "chmod +x {$scriptPath}";
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
if (! empty($this->importCommands)) {
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
'scriptPath' => $scriptPath,
'tmpPath' => $tmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
}
} catch (\Throwable $e) {
handleError($e, $this);
return true;
} finally {
$this->filename = null;
$this->importCommands = [];
}
return true;
}
public function loadAvailableS3Storages()
{
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
->where('is_usable', true)
->get()
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
->toArray();
} catch (\Throwable $e) {
$this->availableS3Storages = [];
}
}
public function updatedS3Path($value)
{
// Reset validation state when path changes
$this->s3FileSize = null;
// Ensure path starts with a slash
if ($value !== null && $value !== '') {
$this->s3Path = str($value)->trim()->start('/')->value();
}
}
public function updatedS3StorageId()
{
// Reset validation state when storage changes
$this->s3FileSize = null;
}
public function checkS3File()
{
if (! $this->s3StorageId) {
$this->dispatch('error', 'Please select an S3 storage.');
return;
}
if (blank($this->s3Path)) {
$this->dispatch('error', 'Please provide an S3 path.');
return;
}
// Clean the path (remove leading slash if present)
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path early to prevent command injection in subsequent operations
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
try {
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
}
// Test connection
$s3Storage->testConnection();
// Build S3 disk configuration
$disk = Storage::build([
'driver' => 's3',
'region' => $s3Storage->region,
'key' => $s3Storage->key,
'secret' => $s3Storage->secret,
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
]);
// Check if file exists
if (! $disk->exists($cleanPath)) {
$this->dispatch('error', 'File not found in S3. Please check the path.');
return;
}
// Get file size
$this->s3FileSize = $disk->size($cleanPath);
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
} catch (\Throwable $e) {
$this->s3FileSize = null;
return handleError($e, $this);
}
}
public function restoreFromS3(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource);
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if (! $this->s3StorageId || blank($this->s3Path)) {
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
return true;
}
if (is_null($this->s3FileSize)) {
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return true;
}
try {
$this->importRunning = true;
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
$key = $s3Storage->key;
$secret = $s3Storage->secret;
$bucket = $s3Storage->bucket;
$endpoint = $s3Storage->endpoint;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return true;
}
// Clean the S3 path
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path to prevent command injection
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return true;
}
// Get helper image
$helperImage = config('constants.coolify.helper_image');
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
} else {
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
}
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resourceUuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
// Prepare all commands in sequence
$commands = [];
// 1. Clean up any existing helper container and temp files from previous runs
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
$commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
// 2. Start helper container on the database network
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
// 3. Configure S3 access in helper container
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
// 4. Check file exists in S3 (bucket and path already validated above)
$escapedBucket = escapeshellarg($bucket);
$escapedCleanPath = escapeshellarg($cleanPath);
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
// 5. Download from S3 to helper container (progress shown by default)
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
$commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
// 8. Build and execute restore command inside database container
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$commands[] = "chmod +x {$scriptPath}";
$commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
// 9. Execute restore and cleanup temp files immediately after completion
$commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
// Execute all commands with cleanup event (as safety net for edge cases)
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
'containerName' => $containerName,
'serverTmpPath' => $serverTmpPath,
'scriptPath' => $scriptPath,
'containerTmpPath' => $containerTmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) {
$this->importRunning = false;
handleError($e, $this);
return true;
}
return true;
}
public function buildRestoreCommand(string $tmpPath): string
{
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
} elseif (str_contains($dbType, 'mongo')) {
$morphClass = 'mongodb';
}
}
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
} else {
$restoreCommand .= " {$tmpPath}";
}
break;
case \App\Models\StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand;
if ($this->dumpAll === false) {
$restoreCommand .= "{$tmpPath}";
}
break;
default:
$restoreCommand = '';
}
return $restoreCommand;
} }
} }

View file

@ -0,0 +1,825 @@
<?php
namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ImportForm extends Component
{
use AuthorizesRequests;
/**
* Validate that a string is safe for use as an S3 bucket name.
* Allows alphanumerics, dots, dashes, and underscores.
*/
private function validateBucketName(string $bucket): bool
{
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
}
/**
* Validate that a string is safe for use as an S3 path.
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
*/
private function validateS3Path(string $path): bool
{
// Must not be empty
if (empty($path)) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
}
/**
* Validate that a string is safe for use as a file path on the server.
*/
private function validateServerPath(string $path): bool
{
// Must be an absolute path
if (! str_starts_with($path, '/')) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
}
public bool $unsupported = false;
// Store IDs instead of models for proper Livewire serialization
#[Locked]
public ?int $resourceId = null;
#[Locked]
public ?string $resourceType = null;
#[Locked]
public ?int $serverId = null;
// View-friendly properties to avoid computed property access in Blade
#[Locked]
public string $resourceUuid = '';
public string $resourceStatus = '';
#[Locked]
public string $resourceDbType = '';
public array $parameters = [];
public array $containers = [];
public bool $scpInProgress = false;
public bool $importRunning = false;
public ?string $filename = null;
public ?string $filesize = null;
public bool $isUploading = false;
public int $progress = 0;
public bool $error = false;
#[Locked]
public string $container;
public array $importCommands = [];
public bool $dumpAll = false;
public string $restoreCommandText = '';
public string $customLocation = '';
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
// S3 Restore properties
public array $availableS3Storages = [];
public ?int $s3StorageId = null;
public string $s3Path = '';
public ?int $s3FileSize = null;
#[Computed]
public function resource()
{
if ($this->resourceId === null || $this->resourceType === null) {
return null;
}
return $this->resourceType::find($this->resourceId);
}
#[Computed]
public function server()
{
if ($this->serverId === null) {
return null;
}
return Server::ownedByCurrentTeam()->find($this->serverId);
}
protected $listeners = [
'slideOverClosed' => 'resetActivityId',
];
public function resetActivityId()
{
$this->activityId = null;
}
public function mount()
{
$this->parameters = get_route_parameters();
$this->getContainers();
$this->loadAvailableS3Storages();
}
public function updatedDumpAll($value)
{
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
}
}
switch ($morphClass) {
case StandaloneMariadb::class:
case 'mariadb':
if ($value === true) {
$this->mariadbRestoreCommand = <<<'EOD'
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
} else {
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
}
break;
case StandaloneMysql::class:
case 'mysql':
if ($value === true) {
$this->mysqlRestoreCommand = <<<'EOD'
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
} else {
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
}
break;
case StandalonePostgresql::class:
case 'postgresql':
if ($value === true) {
$this->postgresqlRestoreCommand = <<<'EOD'
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
EOD;
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
} else {
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
}
break;
}
}
public function getContainers()
{
$this->containers = [];
$teamId = data_get(auth()->user()->currentTeam(), 'id');
// Try to find resource by route parameter
$databaseUuid = data_get($this->parameters, 'database_uuid');
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
$resource = null;
if ($databaseUuid) {
// Standalone database route
$resource = getResourceByUuid($databaseUuid, $teamId);
if (is_null($resource)) {
abort(404);
}
} elseif ($stackServiceUuid) {
// ServiceDatabase route - look up the service database
$serviceUuid = data_get($this->parameters, 'service_uuid');
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', data_get($this->parameters, 'project_uuid'))
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', data_get($this->parameters, 'environment_uuid'))
->firstOrFail();
$service = $environment->services()->whereUuid($serviceUuid)->firstOrFail();
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if (is_null($resource)) {
abort(404);
}
} else {
abort(404);
}
$this->authorize('view', $resource);
// Store IDs for Livewire serialization
$this->resourceId = $resource->id;
$this->resourceType = get_class($resource);
// Store view-friendly properties
$this->resourceStatus = $resource->status ?? '';
// Handle ServiceDatabase server access differently
if ($resource->getMorphClass() === ServiceDatabase::class) {
$server = $resource->service?->server;
if (! $server) {
abort(404, 'Server not found for this service database.');
}
$this->serverId = $server->id;
$this->container = $resource->name.'-'.$resource->service->uuid;
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
// Determine database type for ServiceDatabase
$dbType = $resource->databaseType();
if (str_contains($dbType, 'postgres')) {
$this->resourceDbType = 'standalone-postgresql';
} elseif (str_contains($dbType, 'mysql')) {
$this->resourceDbType = 'standalone-mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$this->resourceDbType = 'standalone-mariadb';
} elseif (str_contains($dbType, 'mongo')) {
$this->resourceDbType = 'standalone-mongodb';
} else {
$this->resourceDbType = $dbType;
}
} else {
$server = $resource->destination?->server;
if (! $server) {
abort(404, 'Server not found for this database.');
}
$this->serverId = $server->id;
$this->container = $resource->uuid;
$this->resourceUuid = $resource->uuid;
$this->resourceDbType = $resource->type();
}
if (str($resource->status)->startsWith('running')) {
$this->containers[] = $this->container;
}
if (
$resource->getMorphClass() === StandaloneRedis::class ||
$resource->getMorphClass() === StandaloneKeydb::class ||
$resource->getMorphClass() === StandaloneDragonfly::class ||
$resource->getMorphClass() === StandaloneClickhouse::class
) {
$this->unsupported = true;
}
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
if ($resource->getMorphClass() === ServiceDatabase::class) {
$dbType = $resource->databaseType();
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
$this->unsupported = true;
}
}
}
public function checkFile()
{
if (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
if (blank($result)) {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return;
}
$this->filename = $this->customLocation;
$this->dispatch('success', 'The file exists.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
}
public function runImport(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource);
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if ($this->filename === '') {
$this->dispatch('error', 'Please select a file to import.');
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return true;
}
try {
$this->importRunning = true;
$this->importCommands = [];
$backupFileName = "upload/{$this->resourceUuid}/restore";
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return true;
}
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return true;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$restoreCommand = $this->buildRestoreCommand($tmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$this->importCommands[] = "chmod +x {$scriptPath}";
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
if (! empty($this->importCommands)) {
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
'scriptPath' => $scriptPath,
'tmpPath' => $tmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
}
} catch (\Throwable $e) {
handleError($e, $this);
return true;
} finally {
$this->filename = null;
$this->importCommands = [];
}
return true;
}
public function loadAvailableS3Storages()
{
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
->where('is_usable', true)
->get()
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
->toArray();
} catch (\Throwable $e) {
$this->availableS3Storages = [];
}
}
public function updatedS3Path($value)
{
// Reset validation state when path changes
$this->s3FileSize = null;
// Ensure path starts with a slash
if ($value !== null && $value !== '') {
$this->s3Path = str($value)->trim()->start('/')->value();
}
}
public function updatedS3StorageId()
{
// Reset validation state when storage changes
$this->s3FileSize = null;
}
public function checkS3File()
{
if (! $this->s3StorageId) {
$this->dispatch('error', 'Please select an S3 storage.');
return;
}
if (blank($this->s3Path)) {
$this->dispatch('error', 'Please provide an S3 path.');
return;
}
// Clean the path (remove leading slash if present)
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path early to prevent command injection in subsequent operations
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
try {
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
}
// Test connection
$s3Storage->testConnection();
// Build S3 disk configuration
$disk = Storage::build([
'driver' => 's3',
'region' => $s3Storage->region,
'key' => $s3Storage->key,
'secret' => $s3Storage->secret,
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
]);
// Check if file exists
if (! $disk->exists($cleanPath)) {
$this->dispatch('error', 'File not found in S3. Please check the path.');
return;
}
// Get file size
$this->s3FileSize = $disk->size($cleanPath);
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
} catch (\Throwable $e) {
$this->s3FileSize = null;
return handleError($e, $this);
}
}
public function restoreFromS3(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource);
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if (! $this->s3StorageId || blank($this->s3Path)) {
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
return true;
}
if (is_null($this->s3FileSize)) {
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return true;
}
try {
$this->importRunning = true;
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
$key = $s3Storage->key;
$secret = $s3Storage->secret;
$bucket = $s3Storage->bucket;
$endpoint = $s3Storage->endpoint;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return true;
}
// Clean the S3 path
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path to prevent command injection
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return true;
}
// Get helper image
$helperImage = config('constants.coolify.helper_image');
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
if ($this->resource->getMorphClass() === ServiceDatabase::class) {
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
} else {
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
}
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resourceUuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$escapedServerTmpPath = escapeshellarg($serverTmpPath);
$escapedContainerTmpPath = escapeshellarg($containerTmpPath);
$escapedScriptPath = escapeshellarg($scriptPath);
$escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
$escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
$escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
$restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
// Prepare all commands in sequence
$commands = [];
// 1. Clean up any existing helper container and temp files from previous runs
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
$commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
// 2. Start helper container on the database network
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
// 3. Configure S3 access in helper container
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
// 4. Check file exists in S3 (bucket and path already validated above)
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
// 5. Download from S3 to helper container (progress shown by default)
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
// 8. Build and execute restore command inside database container
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
$commands[] = "chmod +x {$escapedScriptPath}";
$commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
// 9. Execute restore and cleanup temp files immediately after completion
$commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
// Execute all commands with cleanup event (as safety net for edge cases)
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
'containerName' => $containerName,
'serverTmpPath' => $serverTmpPath,
'scriptPath' => $scriptPath,
'containerTmpPath' => $containerTmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) {
$this->importRunning = false;
handleError($e, $this);
return true;
}
return true;
}
public function buildRestoreCommand(string $tmpPath): string
{
$escapedTmpPath = escapeshellarg($tmpPath);
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
} elseif (str_contains($dbType, 'mongo')) {
$morphClass = 'mongodb';
}
}
switch ($morphClass) {
case StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
} else {
$restoreCommand .= " < {$escapedTmpPath}";
}
break;
case StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
} else {
$restoreCommand .= " < {$escapedTmpPath}";
}
break;
case StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
} else {
$restoreCommand .= " {$escapedTmpPath}";
}
break;
case StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
break;
default:
$restoreCommand = '';
}
return $restoreCommand;
}
}

View file

@ -4,11 +4,9 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneKeydb; use App\Models\StandaloneKeydb;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -42,25 +40,21 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?Carbon $certificateValidUntil = null; public function getListeners(): array
public bool $enable_ssl = false;
public function getListeners()
{ {
$userId = Auth::id(); $user = Auth::user();
$teamId = Auth::user()->currentTeam()->id; if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [ return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
]; ];
} }
@ -75,12 +69,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -88,24 +76,21 @@ public function mount()
protected function rules(): array protected function rules(): array
{ {
$baseRules = [ return [
'name' => ValidationPatterns::nameRules(), 'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'keydbConf' => 'nullable|string', 'keydbConf' => 'nullable|string',
'keydbPassword' => 'required|string', 'keydbPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->keydbPassword !== $this->database->keydb_password,
),
'image' => 'required|string', 'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(), 'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean', 'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string', 'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'enable_ssl' => 'boolean',
]; ];
return $baseRules;
} }
protected function messages(): array protected function messages(): array
@ -114,8 +99,7 @@ protected function messages(): array
ValidationPatterns::combinedMessages(), ValidationPatterns::combinedMessages(),
ValidationPatterns::portMappingMessages(), ValidationPatterns::portMappingMessages(),
[ [
'keydbPassword.required' => 'The KeyDB Password field is required.', ...ValidationPatterns::databasePasswordMessages('keydbPassword', 'KeyDB Password'),
'keydbPassword.string' => 'The KeyDB Password must be a string.',
'image.required' => 'The Docker Image field is required.', 'image.required' => 'The Docker Image field is required.',
'image.string' => 'The Docker Image must be a string.', 'image.string' => 'The Docker Image must be a string.',
'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.integer' => 'The Public Port must be an integer.',
@ -142,11 +126,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -159,9 +139,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} }
} }
@ -210,6 +187,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -218,9 +196,13 @@ public function instantSave()
} }
} }
public function databaseProxyStopped() public function databaseProxyStopped(): void
{ {
$this->syncData(); $this->database->refresh();
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->dispatch('databaseUpdated');
} }
public function submit() public function submit()
@ -236,6 +218,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -247,65 +230,6 @@ public function submit()
} }
} }
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

Some files were not shown because too many files have changed in this diff Show more