Merge remote-tracking branch 'origin/next' into 7616-conditional-image-tags

This commit is contained in:
Andras Bacsai 2026-06-01 11:15:55 +02:00
commit 92d6b577fd
237 changed files with 11570 additions and 6226 deletions

View file

@ -15,6 +15,18 @@ DB_PASSWORD=password
DB_HOST=host.docker.internal
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
# Set to true to enable Ray
RAY_ENABLED=false

View file

@ -59,8 +59,9 @@ ### Huge Sponsors
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
* [Seibert Group](https://seibert.link/coolifysoftware?ref=coolify.io) - Boost productivity company-wide with AI agents like Claude Code
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers Infrastructure for people who care about privacy and control
* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control
### Big Sponsors
@ -70,13 +71,11 @@ ### Big Sponsors
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers
* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [COMIT](https://comit.international?ref=coolify.io) - New York Times awardwinning contractor
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design
* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany.
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform

View file

@ -50,13 +50,9 @@ public function handle(StandaloneClickhouse $database)
],
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => ['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',
],
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'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_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_base64 = base64_encode($docker_compose);
$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\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
class StartDatabase
{
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)
{
@ -25,28 +29,28 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
return 'Server is not functional';
}
switch ($database->getMorphClass()) {
case \App\Models\StandalonePostgresql::class:
case StandalonePostgresql::class:
$activity = StartPostgresql::run($database);
break;
case \App\Models\StandaloneRedis::class:
case StandaloneRedis::class:
$activity = StartRedis::run($database);
break;
case \App\Models\StandaloneMongodb::class:
case StandaloneMongodb::class:
$activity = StartMongodb::run($database);
break;
case \App\Models\StandaloneMysql::class:
case StandaloneMysql::class:
$activity = StartMysql::run($database);
break;
case \App\Models\StandaloneMariadb::class:
case StandaloneMariadb::class:
$activity = StartMariadb::run($database);
break;
case \App\Models\StandaloneKeydb::class:
case StandaloneKeydb::class:
$activity = StartKeydb::run($database);
break;
case \App\Models\StandaloneDragonfly::class:
case StandaloneDragonfly::class:
$activity = StartDragonfly::run($database);
break;
case \App\Models\StandaloneClickhouse::class:
case StandaloneClickhouse::class:
$activity = StartClickhouse::run($database);
break;
}

View file

@ -11,14 +11,19 @@
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Notifications\Container\ContainerRestarted;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml;
class StartDatabaseProxy
{
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)
{
@ -29,7 +34,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
$proxyContainerName = "{$database->uuid}-proxy";
$isSSLEnabled = $database->enable_ssl ?? false;
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($database->getMorphClass() === ServiceDatabase::class) {
$databaseType = $database->databaseType();
$network = $database->service->uuid;
$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');
$team?->notify(
new \App\Notifications\Container\ContainerRestarted(
new ContainerRestarted(
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
$server,
)

View file

@ -106,13 +106,9 @@ public function handle(StandaloneDragonfly $database)
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'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_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_base64 = base64_encode($docker_compose);
$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,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -197,6 +193,9 @@ public function handle(StandaloneKeydb $database)
// Add 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);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$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,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -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_base64 = base64_encode($docker_compose);
$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,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => [
'CMD',
'echo',
'ok',
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD',
'echo',
'ok',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -253,6 +247,9 @@ public function handle(StandaloneMongodb $database)
$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_base64 = base64_encode($docker_compose);
$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(StandaloneMysql $database)
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -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_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -110,13 +110,9 @@ public function handle(StandalonePostgresql $database)
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -213,6 +209,9 @@ public function handle(StandalonePostgresql $database)
$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_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -105,17 +105,11 @@ public function handle(StandaloneRedis $database)
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => [
'CMD-SHELL',
'redis-cli',
'ping',
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD-SHELL',
'redis-cli',
'ping',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -194,6 +188,9 @@ public function handle(StandaloneRedis $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$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_base64 = base64_encode($docker_compose);
$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;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
@ -44,7 +45,10 @@ public function create(array $input): User
'password' => Hash::make($input['password']),
]);
$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
$settings = instanceSettings();

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

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

View file

@ -4,18 +4,22 @@
use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml;
class StartService
{
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)
{
$service->parse();
if ($stopBeforeStart) {
if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) {
StopService::run(service: $service, dockerCleanup: false);
}
$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');
}
private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool
{
return $stopBeforeStart && ! $pullLatestImages;
}
}

View file

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

View file

@ -16,7 +16,7 @@ class SyncBunny extends Command
*
* @var string
*/
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}';
protected $signature = 'sync:bunny {--templates} {--release} {--nightly}';
/**
* The console command description.
@ -25,650 +25,6 @@ class SyncBunny extends Command
*/
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.
*/
@ -677,8 +33,6 @@ public function handle()
$that = $this;
$only_template = $this->option('templates');
$only_version = $this->option('release');
$only_github_releases = $this->option('github-releases');
$only_github_versions = $this->option('github-versions');
$nightly = $this->option('nightly');
$bunny_cdn = 'https://cdn.coollabs.io';
$bunny_cdn_path = 'coolify';
@ -736,30 +90,11 @@ public function handle()
$install_script_location = "$parent_dir/other/nightly/$install_script";
$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';
$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();
// 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)
$bunnyFileMapping = [
$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));
if (! $hasChanges) {
@ -881,9 +178,9 @@ public function handle()
return;
} elseif ($only_version) {
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 {
$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);
$json = json_decode($file, true);
@ -891,8 +188,7 @@ public function handle()
$this->info("Version: {$actual_version}");
$this->info('This will:');
$this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)');
$this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
$this->info(' 1. Sync versions.json to BunnyCDN');
$this->newLine();
$confirmed = confirm('Are you sure you want to proceed?');
@ -900,8 +196,7 @@ public function handle()
return;
}
// 1. Sync versions.json to BunnyCDN (deprecated but still needed)
$this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
$this->info('Syncing versions.json to BunnyCDN...');
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
@ -909,46 +204,8 @@ public function handle()
$this->info('✓ versions.json uploaded & purged to BunnyCDN');
$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('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;
}
@ -970,31 +227,8 @@ public function handle()
$this->info('All files uploaded & purged to BunnyCDN.');
$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('BunnyCDN sync: Complete');
$this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed'));
} catch (\Throwable $e) {
$this->error('Error: '.$e->getMessage());
}

View file

@ -40,7 +40,6 @@ protected function schedule(Schedule $schedule): void
$this->instanceTimezone = config('app.timezone');
}
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$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();
@ -78,7 +77,7 @@ protected function schedule(Schedule $schedule): void
// Scheduled Jobs (Backups & Tasks)
$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();

View file

@ -4,7 +4,6 @@
use App\Models\PrivateKey;
use App\Models\Server;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
@ -12,145 +11,65 @@
class SshMultiplexingHelper
{
public static function serverSshConfiguration(Server $server)
public static function serverSshConfiguration(Server $server): array
{
$privateKey = PrivateKey::findOrFail($server->private_key_id);
$sshKeyLocation = $privateKey->getKeyLocation();
$muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
return [
'sshKeyLocation' => $sshKeyLocation,
'muxFilename' => $muxFilename,
'sshKeyLocation' => $privateKey->getKeyLocation(),
'muxFilename' => self::muxSocket($server),
];
}
public static function ensureMultiplexedConnection(Server $server): bool
{
if (! 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;
return self::isMultiplexingEnabled();
}
public static function establishNewMultiplexedConnection(Server $server): bool
public static function removeMuxFile(Server $server): void
{
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
$connectionTimeout = self::getConnectionTimeout($server);
$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);
$closeCommand = self::muxControlCommand($server, 'exit');
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);
$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()) {
$scp_command .= '-6 ';
$scpCommand .= '-6 ';
}
if (self::isMultiplexingEnabled()) {
try {
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
}
$scpCommand .= self::multiplexingOptions($server);
}
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, self::getConnectionTimeout($server), 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()) {
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
} else {
$scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}";
return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($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) {
throw new \RuntimeException('Server is disabled.');
@ -161,40 +80,37 @@ public static function generateSshCommand(Server $server, string $command, bool
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()) {
try {
$multiplexingSuccessful = self::ensureMultiplexedConnection($server);
if ($multiplexingSuccessful) {
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
}
} catch (\Exception $e) {
// Continue without multiplexing
}
$sshCommand .= self::multiplexingOptions($server);
}
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, self::getConnectionTimeout($server), 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($delimiter);
$delimiter = base64_encode(Hash::make($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
.$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
@ -231,7 +147,6 @@ private static function validateSshKey(PrivateKey $privateKey): void
$privateKey->storeInFileSystem();
}
// Ensure correct permissions (SSH requires 0600)
if (file_exists($keyLocation)) {
$currentPerms = fileperms($keyLocation) & 0777;
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
@ -262,90 +177,10 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati
.'-o RequestTTY=no '
.'-o LogLevel=ERROR ';
// Bruh
if ($isScp) {
$options .= '-P '.escapeshellarg((string) $server->port).' ';
} else {
$options .= '-p '.escapeshellarg((string) $server->port).' ';
return $options.'-P '.escapeshellarg((string) $server->port).' ';
}
return $options;
}
/**
* 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);
return $options.'-p '.escapeshellarg((string) $server->port).' ';
}
}

View file

@ -299,6 +299,11 @@ public function database_by_uuid(Request $request)
'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'],
'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'],
'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],
],
),
)
@ -565,9 +570,17 @@ public function update_by_uuid(Request $request)
}
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);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) {
$errors = $validator->errors()->merge($healthCheckValidator->errors());
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');

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

@ -5,6 +5,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
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\ApplicationPreview;
use Exception;
@ -14,6 +15,7 @@
class Bitbucket extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request)
{
@ -62,8 +64,14 @@ public function manual(Request $request)
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$commit = data_get($payload, 'pullrequest.source.commit.hash');
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
$applications = $applications->where('git_branch', $branch)->get();
$full_name = $this->manualWebhookRepositoryFullName($full_name);
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()) {
return response([
'status' => 'failed',
@ -79,11 +87,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@ -97,11 +101,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@ -114,11 +114,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}

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

@ -5,6 +5,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
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\ApplicationPreview;
use Exception;
@ -15,6 +16,7 @@
class Gitea extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request)
{
@ -58,15 +60,19 @@ public function manual(Request $request)
if (! $branch) {
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') {
$applications = $applications->where('git_branch', $branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
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.");
}
}
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()) {
return response("Nothing to do. No applications found with branch '$base_branch'.");
}
@ -80,11 +86,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null,
'event' => $x_gitea_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@ -96,11 +98,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null,
'event' => $x_gitea_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}

View file

@ -4,6 +4,7 @@
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\ProcessGithubPullRequestWebhook;
use App\Models\Application;
@ -11,6 +12,7 @@
use App\Models\PrivateKey;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
@ -18,6 +20,7 @@
class Github extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request)
{
@ -59,6 +62,7 @@ public function manual(Request $request)
$before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$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'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
@ -66,15 +70,19 @@ public function manual(Request $request)
if (! $branch) {
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') {
$applications = $applications->where('git_branch', $branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
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.");
}
}
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()) {
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
}
@ -93,11 +101,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@ -109,11 +113,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@ -223,6 +223,7 @@ public function manual(Request $request)
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
isForkPullRequest: $is_fork_pull_request ?? false,
);
$return_payloads->push([
@ -304,6 +305,7 @@ public function normal(Request $request)
$before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$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'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
@ -435,6 +437,7 @@ public function normal(Request $request)
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
isForkPullRequest: $is_fork_pull_request ?? false,
);
$return_payloads->push([
@ -452,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)
{
try {
$code = $request->get('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();
$code = (string) $request->query('code', '');
abort_if(blank($code), 422, 'Missing GitHub App manifest code.');
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (Exception $e) {
return handleError($e);
}
$github_app = $this->consumeGithubAppSetupState(
request: $request,
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)
{
try {
$installation_id = $request->get('installation_id');
$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();
}
$source = (string) $request->query('source', '');
abort_if(blank($source), 404);
$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]);
} 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

@ -5,6 +5,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
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\ApplicationPreview;
use Exception;
@ -15,6 +16,7 @@
class Gitlab extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request)
{
@ -85,9 +87,18 @@ public function manual(Request $request)
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') {
$applications = $applications->where('git_branch', $branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
$return_payloads->push([
'status' => 'failed',
@ -98,7 +109,7 @@ public function manual(Request $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()) {
$return_payloads->push([
'status' => 'failed',
@ -117,11 +128,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null,
'event' => $x_gitlab_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@ -132,11 +139,7 @@ public function manual(Request $request)
'repository' => $full_name ?? null,
'event' => $x_gitlab_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}

View file

@ -197,7 +197,7 @@ public function tags()
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->nixpacks_plan_json = collect([]);
@ -1302,12 +1302,8 @@ private function generate_runtime_environment_variables()
$sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id');
}
if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($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->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
});
$sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
$sorted_environment_variables_preview = $sorted_environment_variables_preview->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
$ports = $this->application->main_port();
$coolify_envs = $this->generate_coolify_env_variables();
@ -1460,6 +1456,15 @@ private function generate_runtime_environment_variables()
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()
{
// This method saves the .env file with ALL runtime variables
@ -1675,11 +1680,9 @@ private function generate_buildtime_environment_variables()
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->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') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
$sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
foreach ($sorted_environment_variables as $env) {
@ -1728,11 +1731,9 @@ private function generate_buildtime_environment_variables()
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->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') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
$sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
foreach ($sorted_environment_variables as $env) {
@ -3028,6 +3029,10 @@ private function generate_env_variables()
->where('is_buildtime', true)
->get();
if ($this->build_pack === 'dockercompose') {
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
@ -3040,6 +3045,10 @@ private function generate_env_variables()
->where('is_buildtime', true)
->get();
if ($this->build_pack === 'dockercompose') {
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {

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

@ -77,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public ScheduledDatabaseBackup $backup)
{
$this->onQueue('high');
$this->onQueue(crons_queue());
$this->timeout = $backup->timeout ?? 3600;
}
@ -668,12 +668,14 @@ private function calculate_size()
private function upload_to_s3(): void
{
if (is_null($this->s3)) {
$previousS3StorageId = $this->backup->s3_storage_id;
$this->backup->update([
'save_s3' => false,
'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 {

View file

@ -39,6 +39,7 @@ public function __construct(
public string $commitSha,
public ?string $authorAssociation,
public string $fullName,
public bool $isForkPullRequest = false,
) {
$this->onQueue('high');
}
@ -92,7 +93,17 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
// Check if PR deployments from public contributors are restricted
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)) {
return;
}

View file

@ -13,6 +13,16 @@
use App\Models\Server;
use App\Models\ServiceApplication;
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\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus;
@ -25,6 +35,7 @@
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Laravel\Horizon\Contracts\Silenced;
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
@ -46,6 +57,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
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 $allDatabaseUuids;
@ -78,6 +101,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public bool $foundLogDrainContainer = false;
private ?array $cachedDestinationIds = null;
public function middleware(): array
{
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->allServiceApplicationIds = 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()
@ -120,6 +151,16 @@ public function handle()
$this->allTcpProxyUuids ??= collect();
$this->allServiceApplicationIds ??= 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
if (! $this->data) {
@ -127,30 +168,40 @@ public function handle()
}
$data = collect($this->data);
$this->server->sentinelHeartbeat();
// Heartbeat is updated by SentinelController on every push, before dispatch.
$this->containers = collect(data_get($data, 'containers'));
$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;
$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);
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
} elseif ($filesystemUsageRoot !== null && $filesystemUsageRoot < $diskThreshold) {
Cache::forget($storageCacheKey);
}
if ($this->containers->isEmpty()) {
return;
}
$this->applications = $this->server->applications();
$this->databases = $this->server->databases();
$this->previews = $this->server->previews();
// Eager load service applications and databases to avoid N+1 queries
$this->services = $this->server->services()
->with(['applications:id,service_id', 'databases:id,service_id'])
->get();
$this->applications = $this->loadApplications();
$this->databases = $this->loadDatabases();
$this->previews = $this->loadPreviews();
$this->services = $this->loadServices();
$this->applicationsById = $this->applications->keyBy(fn ($application) => (string) $application->id);
$this->previewsByKey = $this->previews->keyBy(fn ($preview) => $preview->application_id.':'.$preview->pull_request_id);
$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) {
return $application->additional_servers_count === 0;
@ -163,9 +214,8 @@ public function handle()
});
$this->allDatabaseUuids = $this->databases->pluck('uuid');
$this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid');
// Use eager-loaded relationships instead of querying in loop
$this->allServiceApplicationIds = $this->services->flatMap(fn ($service) => $service->applications->pluck('id'));
$this->allServiceDatabaseIds = $this->services->flatMap(fn ($service) => $service->databases->pluck('id'));
$this->allServiceApplicationIds = $this->serviceApplicationsById->keys();
$this->allServiceDatabaseIds = $this->serviceDatabasesById->keys();
foreach ($this->containers as $container) {
$containerStatus = data_get($container, 'state', 'exited');
@ -279,6 +329,151 @@ public function handle()
$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()
{
if ($this->applicationContainerStatuses->isEmpty()) {
@ -286,7 +481,7 @@ private function aggregateMultiContainerStatuses()
}
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
$application = $this->applications->where('id', $applicationId)->first();
$application = $this->applicationsById->get((string) $applicationId);
if (! $application) {
continue;
}
@ -307,8 +502,6 @@ private function aggregateMultiContainerStatuses()
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
} elseif ($aggregatedStatus) {
$application->update(['last_online_at' => now()]);
}
continue;
@ -323,8 +516,6 @@ private function aggregateMultiContainerStatuses()
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
} elseif ($aggregatedStatus) {
$application->update(['last_online_at' => now()]);
}
}
}
@ -343,7 +534,7 @@ private function aggregateServiceContainerStatuses()
continue;
}
$service = $this->services->where('id', $serviceId)->first();
$service = $this->servicesById->get((string) $serviceId);
if (! $service) {
continue;
}
@ -351,9 +542,9 @@ private function aggregateServiceContainerStatuses()
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null;
if ($subType === 'application') {
$subResource = $service->applications->where('id', $subId)->first();
$subResource = $this->serviceApplicationsById->get((string) $subId);
} elseif ($subType === 'database') {
$subResource = $service->databases->where('id', $subId)->first();
$subResource = $this->serviceDatabasesById->get((string) $subId);
}
if (! $subResource) {
@ -375,8 +566,6 @@ private function aggregateServiceContainerStatuses()
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
} elseif ($aggregatedStatus) {
$subResource->update(['last_online_at' => now()]);
}
continue;
@ -392,39 +581,31 @@ private function aggregateServiceContainerStatuses()
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
} elseif ($aggregatedStatus) {
$subResource->update(['last_online_at' => now()]);
}
}
}
private function updateApplicationStatus(string $applicationId, string $containerStatus)
{
$application = $this->applications->where('id', $applicationId)->first();
$application = $this->applicationsById->get((string) $applicationId);
if (! $application) {
return;
}
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
} else {
$application->update(['last_online_at' => now()]);
}
}
private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus)
{
$application = $this->previews->where('application_id', $applicationId)
->where('pull_request_id', $pullRequestId)
->first();
$application = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
if (! $application) {
return;
}
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
} else {
$application->update(['last_online_at' => now()]);
}
}
@ -472,9 +653,7 @@ private function updateNotFoundApplicationPreviewStatus()
$applicationId = $parts[0];
$pullRequestId = $parts[1];
$applicationPreview = $this->previews->where('application_id', $applicationId)
->where('pull_request_id', $pullRequestId)
->first();
$applicationPreview = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) {
$previewIdsToUpdate->push($applicationPreview->id);
@ -500,11 +679,11 @@ private function updateProxyStatus()
} catch (\Throwable $e) {
}
} 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.
$proxyCacheKey = 'connect-proxy:'.$this->server->id;
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);
}
}
@ -513,15 +692,13 @@ private function updateProxyStatus()
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false)
{
$database = $this->databases->where('uuid', $databaseUuid)->first();
$database = $this->databasesByUuid->get($databaseUuid);
if (! $database) {
return;
}
if ($database->status !== $containerStatus) {
$database->status = $containerStatus;
$database->save();
} else {
$database->update(['last_online_at' => now()]);
}
if ($this->isRunning($containerStatus) && $tcpProxy) {
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
@ -556,7 +733,7 @@ private function updateNotFoundDatabaseStatus()
}
$notFoundDatabaseUuids->each(function ($databaseUuid) {
$database = $this->databases->where('uuid', $databaseUuid)->first();
$database = $this->databasesByUuid->get($databaseUuid);
if ($database) {
if (! str($database->status)->startsWith('exited')) {
$database->update([

View file

@ -6,14 +6,15 @@
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
@ -22,6 +23,8 @@ class ScheduledJobManager implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const CHUNK_SIZE = 100;
/**
* The time when this job execution started.
* 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()
{
$this->onQueue($this->determineQueue());
}
private function determineQueue(): string
{
$preferredQueue = 'crons';
$fallbackQueue = 'high';
$configuredQueues = explode(',', env('HORIZON_QUEUES', 'high,default'));
return in_array($preferredQueue, $configuredQueues) ? $preferredQueue : $fallbackQueue;
$this->onQueue(crons_queue());
}
/**
@ -106,21 +99,11 @@ public function handle(): void
'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 {
$this->processScheduledBackups();
$this->processScheduledBackupsAndTasks();
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process scheduled backups', [
'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', [
Log::channel('scheduled-errors')->error('Failed to process scheduled backups and tasks', [
'error' => $e->getMessage(),
'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)
->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) {
try {
$server = $backup->server();
$skipReason = $this->getBackupSkipReason($backup, $server);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logSkip('backup', $skipReason, [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
]);
if (blank(data_get($backup, 'database')) || blank($server)) {
$this->processScheduledBackup($backup, $server);
continue;
}
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($serverTimezone) === false) {
$serverTimezone = config('app.timezone');
}
$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,
]);
if ($this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
$dueBackups[] = [
'backup' => $backup,
'server' => $server,
];
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing backup', [
Log::channel('scheduled-errors')->error('Error prechecking backup', [
'backup_id' => $backup->id,
'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'])
->where('enabled', true)
->get();
$dueTasks = [];
foreach ($tasks as $task) {
try {
$server = $task->server();
// Phase 1: Critical checks (always — cheap, handles orphans and infra issues)
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
if ($criticalSkip !== null) {
$this->skippedCount++;
$this->logSkip('task', $criticalSkip, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server?->team_id,
]);
if (blank($server) || (! $task->service && ! $task->application)) {
$this->processScheduledTask($task, $server);
continue;
}
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($serverTimezone) === false) {
$serverTimezone = config('app.timezone');
if ($this->isDueCandidateBeforeExpensiveChecks($task->frequency, $server, "scheduled-task:{$task->id}")) {
$dueTasks[] = [
'task' => $task,
'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) {
Log::channel('scheduled-errors')->error('Error processing task', [
Log::channel('scheduled-errors')->error('Error prechecking task', [
'task_id' => $task->id,
'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
@ -337,71 +406,70 @@ private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
private function processDockerCleanups(): void
{
// Get all servers that need cleanup checks
$servers = $this->getServersForCleanup();
foreach ($servers as $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;
$this->getServersForCleanupQuery()
->chunkById(self::CHUNK_SIZE, function ($servers): void {
foreach ($servers as $server) {
$this->processDockerCleanup($server);
}
});
}
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($serverTimezone) === false) {
$serverTimezone = config('app.timezone');
}
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
if (isset(VALID_CRON_STRINGS[$frequency])) {
$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', [
private function processDockerCleanup(Server $server): void
{
try {
$skipReason = $this->getDockerCleanupSkipReason($server);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logSkip('docker_cleanup', $skipReason, [
'server_id' => $server->id,
'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')
->where('ip', '!=', '1.2.3.4');
if (isCloud()) {
$servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
$own = Team::find(0)->servers()->with('settings')->get();
return $servers->merge($own);
$query
->with('team.subscription')
->where(function (Builder $query): void {
$query
->where('team_id', 0)
->orWhereRelation('team.subscription', 'stripe_invoice_paid', true);
});
}
return $query->get();
return $query;
}
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(),
], $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 Team $team;
public ?Team $team = null;
public ?Server $server = null;
public ScheduledTask $task;
public Application|Service $resource;
public Application|Service|null $resource = null;
public ?ScheduledTaskExecution $task_log = null;
@ -61,25 +61,34 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
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;
if ($service = $task->service()->first()) {
$this->resource = $service;
} elseif ($application = $task->application()->first()) {
$this->resource = $application;
$this->timeout = $this->task->timeout ?? 300;
}
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 {
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->timeout = $this->task->timeout ?? 300;
$this->team = Team::findOrFail($this->task->team_id);
$this->server_timezone = $this->getServerTimezone();
$this->server = $this->resource->destination->server;
}
private function getServerTimezone(): string
@ -98,6 +107,8 @@ public function handle(): void
$startTime = Carbon::now();
try {
$this->initializeExecutionContext();
$this->task_log = ScheduledTaskExecution::create([
'scheduled_task_id' => $this->task->id,
'started_at' => $startTime,
@ -107,8 +118,6 @@ public function handle(): void
// Store execution ID for timeout handling
$this->executionId = $this->task_log->id;
$this->server = $this->resource->destination->server;
if ($this->resource->type() === 'application') {
$containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0);
if ($containers->count() > 0) {
@ -179,7 +188,10 @@ public function handle(): void
// Re-throw to trigger Laravel's retry mechanism with backoff
throw $e;
} finally {
ScheduledTaskDone::dispatch($this->team->id);
if ($this->team) {
ScheduledTaskDone::dispatch($this->team->id);
}
if ($this->task_log) {
$finishedAt = Carbon::now();
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
@ -205,6 +217,8 @@ public function backoff(): array
*/
public function failed(?\Throwable $exception): void
{
$this->team ??= Team::find($this->task->team_id);
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
'job' => 'ScheduledTaskJob',
'task_id' => $this->task->uuid,

View file

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

View file

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

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

@ -17,17 +17,10 @@ class Configuration extends Component
public $servers;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
"echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh',
'buildPackUpdated' => '$refresh',
'refresh' => '$refresh',
];
}
protected $listeners = [
'buildPackUpdated' => '$refresh',
'refresh' => '$refresh',
];
public function mount()
{
@ -51,8 +44,6 @@ public function mount()
$this->environment = $environment;
$this->application = $application;
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]);
}

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;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
@ -21,7 +23,7 @@ class Source extends Component
#[Validate(['nullable', 'string'])]
public ?string $privateKeyName = null;
#[Validate(['nullable', 'integer'])]
#[Locked]
public ?int $privateKeyId = null;
#[Validate(['required', 'string'])]
@ -103,7 +105,8 @@ public function setPrivateKey(int $privateKeyId)
{
try {
$this->authorize('update', $this->application);
$this->privateKeyId = $privateKeyId;
$key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId);
$this->privateKeyId = $key->id;
$this->syncData(true);
$this->getPrivateKeys();
$this->application->refresh();
@ -136,8 +139,11 @@ public function changeSource($sourceId, $sourceType)
try {
$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([
'source_id' => $sourceId,
'source_id' => $source->id,
'source_type' => $sourceType,
]);

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
@ -144,7 +145,7 @@ public function delete($password, $selectedActions = [])
try {
$server = null;
if ($this->backup->database instanceof \App\Models\ServiceDatabase) {
if ($this->backup->database instanceof ServiceDatabase) {
$server = $this->backup->database->service->destination->server;
} elseif ($this->backup->database->destination && $this->backup->database->destination->server) {
$server = $this->backup->database->destination->server;
@ -170,7 +171,7 @@ public function delete($password, $selectedActions = [])
$this->backup->delete();
if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $this->backup->database;
return redirect()->route('project.service.database.backups', [
@ -182,7 +183,7 @@ public function delete($password, $selectedActions = [])
} else {
return redirect()->route('project.database.backup.index', $this->parameters);
}
} catch (\Exception $e) {
} catch (Exception $e) {
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
return handleError($e, $this);
@ -207,6 +208,13 @@ private function customValidate()
$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
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
$this->backup->disable_local_backup = $this->disableLocalBackup = false;
@ -214,7 +222,7 @@ private function customValidate()
$isValid = validate_cron_expression($this->backup->frequency);
if (! $isValid) {
throw new \Exception('Invalid Cron / Human expression');
throw new Exception('Invalid Cron / Human expression');
}
$this->validate();
}

View file

@ -40,18 +40,21 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
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 [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
];
}
@ -88,8 +91,6 @@ protected function rules(): array
'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
];
}
@ -129,9 +130,6 @@ public function syncData(bool $toModel = false)
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@ -144,8 +142,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
}
}
@ -194,6 +190,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@ -202,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()
@ -220,6 +221,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} 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;
use Auth;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\ItemNotFoundException;
use Livewire\Component;
class Configuration extends Component
@ -18,15 +19,6 @@ class Configuration extends Component
public $environment;
public function getListeners()
{
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
];
}
public function mount()
{
try {
@ -55,10 +47,10 @@ public function mount()
$this->dispatch('configurationChanged');
}
} catch (\Throwable $e) {
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
if ($e instanceof AuthorizationException) {
return redirect()->route('dashboard');
}
if ($e instanceof \Illuminate\Support\ItemNotFoundException) {
if ($e instanceof ItemNotFoundException) {
return redirect()->route('dashboard');
}

View file

@ -2,7 +2,9 @@
namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
@ -48,6 +50,20 @@ public function submit()
$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);
if (! $isValid) {
$this->dispatch('error', 'Invalid Cron / Human expression.');
@ -74,7 +90,7 @@ public function submit()
}
$databaseBackup = ScheduledDatabaseBackup::create($payload);
if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->dispatch('refreshScheduledBackups', $databaseBackup->id);
} else {
$this->dispatch('refreshScheduledBackups');

View file

@ -4,11 +4,9 @@
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneDragonfly;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
@ -40,25 +38,21 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false;
public ?Carbon $certificateValidUntil = null;
public bool $enable_ssl = false;
public function getListeners()
public function getListeners(): array
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
$user = Auth::user();
if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
"echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
];
}
@ -73,12 +67,6 @@ public function mount()
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -98,10 +86,7 @@ protected function rules(): array
'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
'enable_ssl' => 'nullable|boolean',
];
}
@ -137,11 +122,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
$this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@ -153,9 +134,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$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;
}
}
@ -204,6 +182,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@ -212,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()
@ -230,6 +213,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@ -241,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
{
$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;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneRedis;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@ -17,797 +17,134 @@ class Import 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 string $resourceUuid = '';
public array $parameters = [];
public bool $unsupported = false;
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()
public function getListeners(): array
{
if ($this->resourceId === null || $this->resourceType === null) {
return null;
$listeners = ['databaseUpdated' => 'refreshStatus'];
$user = Auth::user();
if (! $user) {
return $listeners;
}
return $this->resourceType::find($this->resourceId);
}
$listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus';
#[Computed]
public function server()
{
if ($this->serverId === null) {
return null;
$team = $user->currentTeam();
if ($team) {
$listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus';
}
return Server::ownedByCurrentTeam()->find($this->serverId);
return $listeners;
}
public function getListeners()
public function mount(): void
{
$userId = Auth::id();
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);
}
$resource = $this->resolveResourceFromRoute();
$this->authorize('view', $resource);
// Store IDs for Livewire serialization
$this->resourceId = $resource->id;
$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->unsupported = $this->isUnsupportedResource($resource);
}
// Handle ServiceDatabase server access differently
if ($resource->getMorphClass() === \App\Models\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
public function render(): View
{
return view('livewire.project.database.import');
}
// 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;
private function resolveResourceFromRoute(): object
{
$parameters = get_route_parameters();
$teamId = data_get(Auth::user()?->currentTeam(), 'id');
$databaseUuid = data_get($parameters, 'database_uuid');
$stackServiceUuid = data_get($parameters, 'stack_service_uuid');
if ($databaseUuid) {
$resource = getResourceByUuid($databaseUuid, $teamId);
if ($resource) {
return $resource;
}
} 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();
abort(404);
}
if (str($resource->status)->startsWith('running')) {
$this->containers[] = $this->container;
if ($stackServiceUuid) {
$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 (
$resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
$resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
$resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
$resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
$resource instanceof StandaloneRedis ||
$resource instanceof StandaloneKeydb ||
$resource instanceof StandaloneDragonfly ||
$resource instanceof StandaloneClickhouse
) {
$this->unsupported = true;
return true;
}
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($resource instanceof ServiceDatabase) {
$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.';
return str_contains($dbType, 'redis') ||
str_contains($dbType, 'keydb') ||
str_contains($dbType, 'dragonfly') ||
str_contains($dbType, 'clickhouse');
}
$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() === \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;
return false;
}
}

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\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneKeydb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
@ -42,25 +40,21 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false;
public ?Carbon $certificateValidUntil = null;
public bool $enable_ssl = false;
public function getListeners()
public function getListeners(): array
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
$user = Auth::user();
if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
"echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
];
}
@ -75,12 +69,6 @@ public function mount()
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -88,7 +76,7 @@ public function mount()
protected function rules(): array
{
$baseRules = [
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'keydbConf' => 'nullable|string',
@ -101,13 +89,8 @@ protected function rules(): array
'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
'enable_ssl' => 'boolean',
];
return $baseRules;
}
protected function messages(): array
@ -143,11 +126,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
$this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@ -160,9 +139,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$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;
}
}
@ -211,6 +187,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@ -219,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()
@ -237,6 +218,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@ -248,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
{
$this->database->refresh();

View file

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

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneMariadb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class General extends Component
@ -50,25 +47,6 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
protected function rules(): array
{
return [
@ -94,7 +72,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
];
}
@ -133,7 +110,6 @@ protected function messages(): array
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Options',
'enableSsl' => 'Enable SSL',
];
public function mount()
@ -147,12 +123,6 @@ public function mount()
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
@ -176,11 +146,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@ -196,9 +162,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@ -234,6 +197,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@ -270,6 +234,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@ -278,63 +243,6 @@ public function instantSave()
}
}
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 have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refresh(): void
{
$this->database->refresh();

View file

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

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneMongodb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class General extends Component
@ -48,27 +45,6 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
protected function rules(): array
{
return [
@ -91,8 +67,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full',
];
}
@ -112,7 +86,6 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
]
);
}
@ -130,8 +103,6 @@ protected function messages(): array
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
@ -145,12 +116,6 @@ public function mount()
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
@ -173,12 +138,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@ -193,10 +153,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@ -235,6 +191,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@ -271,6 +228,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@ -279,68 +237,6 @@ public function instantSave()
}
}
public function updatedSslMode()
{
$this->instantSaveSSL();
}
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 have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refresh(): void
{
$this->database->refresh();

View file

@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Project\Database\Mongodb;
use App\Models\StandaloneMongodb;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneMongodb $database;
protected function databaseLabel(): string
{
return 'Mongo';
}
protected function sslModeOptions(): array
{
return [
'allow' => ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
];
}
protected function sslModeHelper(): string
{
return 'Choose the SSL verification mode for MongoDB connections';
}
protected function afterRefresh(): void
{
$this->sslMode = $this->database->ssl_mode;
}
protected function applyExtraSslAttributes(): void
{
$this->database->ssl_mode = $this->sslMode;
}
public function updatedSslMode(): void
{
$this->instantSaveSSL();
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneMysql;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class General extends Component
@ -50,27 +47,6 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
protected function rules(): array
{
return [
@ -96,8 +72,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
];
}
@ -118,7 +92,6 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
]
);
}
@ -137,8 +110,6 @@ protected function messages(): array
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
@ -152,12 +123,6 @@ public function mount()
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
@ -181,12 +146,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@ -202,10 +162,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@ -241,6 +197,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@ -277,6 +234,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@ -285,68 +243,6 @@ public function instantSave()
}
}
public function updatedSslMode()
{
$this->instantSaveSSL();
}
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 have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refresh(): void
{
$this->database->refresh();

View file

@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Project\Database\Mysql;
use App\Models\StandaloneMysql;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneMysql $database;
protected function databaseLabel(): string
{
return 'MySQL';
}
protected function sslModeOptions(): array
{
return [
'PREFERRED' => ['title' => 'Prefer secure connections', 'label' => 'Prefer (secure)'],
'REQUIRED' => ['title' => 'Require secure connections', 'label' => 'Require (secure)'],
'VERIFY_CA' => ['title' => 'Verify CA certificate', 'label' => 'Verify CA (secure)'],
'VERIFY_IDENTITY' => ['title' => 'Verify full certificate', 'label' => 'Verify Full (secure)'],
];
}
protected function sslModeHelper(): string
{
return 'Choose the SSL verification mode for MySQL connections';
}
protected function afterRefresh(): void
{
$this->sslMode = $this->database->ssl_mode;
}
protected function applyExtraSslAttributes(): void
{
$this->database->ssl_mode = $this->sslMode;
}
public function updatedSslMode(): void
{
$this->instantSaveSSL();
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class General extends Component
@ -54,32 +51,14 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public string $new_filename;
public string $new_content;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
'save_init_script',
'delete_init_script',
];
}
protected $listeners = [
'save_init_script',
'delete_init_script',
];
protected function rules(): array
{
@ -106,8 +85,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
];
}
@ -127,7 +104,6 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
]
);
}
@ -148,8 +124,6 @@ protected function messages(): array
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
@ -163,12 +137,6 @@ public function mount()
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
@ -194,12 +162,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@ -217,10 +180,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@ -243,68 +202,6 @@ public function instantSaveAdvanced()
}
}
public function updatedSslMode()
{
$this->instantSaveSSL();
}
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 have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
@ -330,6 +227,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@ -493,6 +391,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {

View file

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Project\Database\Postgresql;
use App\Models\StandalonePostgresql;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandalonePostgresql $database;
protected function databaseLabel(): string
{
return 'Postgres';
}
protected function sslModeOptions(): array
{
return [
'allow' => ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
'verify-ca' => ['title' => 'Verify CA certificate', 'label' => 'verify-ca (secure)'],
'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
];
}
protected function sslModeHelper(): string
{
return 'Choose the SSL verification mode for PostgreSQL connections';
}
protected function afterRefresh(): void
{
$this->sslMode = $this->database->ssl_mode;
}
protected function applyExtraSslAttributes(): void
{
$this->database->ssl_mode = $this->sslMode;
}
public function updatedSslMode(): void
{
$this->instantSaveSSL();
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class General extends Component
@ -48,25 +45,9 @@ class General extends Component
public string $redisVersion;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $enableSsl = false;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
'envsUpdated' => 'refresh',
];
}
protected $listeners = [
'envsUpdated' => 'refresh',
];
protected function rules(): array
{
@ -87,7 +68,6 @@ protected function rules(): array
'redisPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->redisPassword !== $this->database->redis_password,
),
'enableSsl' => 'boolean',
];
}
@ -122,7 +102,6 @@ protected function messages(): array
'customDockerRunOptions' => 'Custom Docker Options',
'redisUsername' => 'Redis Username',
'redisPassword' => 'Redis Password',
'enableSsl' => 'Enable SSL',
];
public function mount()
@ -136,12 +115,6 @@ public function mount()
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -161,11 +134,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@ -177,9 +146,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
$this->redisVersion = $this->database->getRedisVersion();
$this->redisUsername = $this->database->redis_username;
$this->redisPassword = $this->database->redis_password;
@ -227,6 +193,7 @@ public function submit()
);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@ -259,6 +226,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@ -267,63 +235,6 @@ public function instantSave()
}
}
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
{
$this->database->refresh();

View file

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

View file

@ -4,12 +4,14 @@
use App\Models\Environment;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
class DeleteEnvironment extends Component
{
use AuthorizesRequests;
#[Locked]
public int $environment_id;
public bool $disabled = false;
@ -20,12 +22,8 @@ class DeleteEnvironment extends Component
public function mount()
{
try {
$this->environmentName = Environment::findOrFail($this->environment_id)->name;
$this->parameters = get_route_parameters();
} catch (\Exception $e) {
return handleError($e, $this);
}
$this->parameters = get_route_parameters();
$this->environmentName = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id)->name;
}
public function delete()
@ -33,7 +31,7 @@ public function delete()
$this->validate([
'environment_id' => 'required|int',
]);
$environment = Environment::findOrFail($this->environment_id);
$environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id);
$this->authorize('delete', $environment);
if ($environment->isEmpty()) {

View file

@ -9,6 +9,7 @@
use App\Support\ValidationPatterns;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Livewire\Attributes\Locked;
use Livewire\Component;
class GithubPrivateRepository extends Component
@ -29,6 +30,7 @@ class GithubPrivateRepository extends Component
public int $selected_repository_id;
#[Locked]
public int $selected_github_app_id;
public string $selected_repository_owner;
@ -37,8 +39,6 @@ class GithubPrivateRepository extends Component
public string $selected_branch_name = 'main';
public string $token;
public $repositories;
public int $total_repositories_count = 0;
@ -71,7 +71,10 @@ public function mount()
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->repositories = $this->branches = collect();
$this->github_apps = GithubApp::private();
$this->github_apps = GithubApp::ownedByCurrentTeam()
->where('is_public', false)
->whereNotNull('app_id')
->get();
}
public function updatedSelectedRepositoryId(): void
@ -96,22 +99,25 @@ public function updatedBuildPack()
}
}
public function loadRepositories($github_app_id)
public function loadRepositories(int $github_app_id): void
{
$this->repositories = collect();
$this->branches = collect();
$this->total_branches_count = 0;
$this->page = 1;
$this->selected_github_app_id = $github_app_id;
$this->github_app = GithubApp::where('id', $github_app_id)->first();
$this->token = generateGithubInstallationToken($this->github_app);
$repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page);
$this->github_app = GithubApp::ownedByCurrentTeam()
->where('is_public', false)
->whereNotNull('app_id')
->findOrFail($github_app_id);
$token = generateGithubInstallationToken($this->github_app);
$repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
if ($this->repositories->count() < $this->total_repositories_count) {
while ($this->repositories->count() < $this->total_repositories_count) {
$this->page++;
$repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page);
$repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
}
@ -142,7 +148,9 @@ public function loadBranches()
protected function loadBranchByPage()
{
$response = Http::GitHub($this->github_app->api_url, $this->token)
$token = generateGithubInstallationToken($this->github_app);
$response = Http::GitHub($this->github_app->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [

View file

@ -4,7 +4,6 @@
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class Configuration extends Component
@ -27,16 +26,10 @@ class Configuration extends Component
public array $parameters;
public function getListeners()
{
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
'refreshServices' => 'refreshServices',
'refresh' => 'refreshServices',
];
}
protected $listeners = [
'refreshServices' => 'refreshServices',
'refresh' => 'refreshServices',
];
public function render()
{
@ -105,18 +98,4 @@ public function restartDatabase($id)
return handleError($e, $this);
}
}
public function serviceChecked()
{
try {
$this->service->applications->each(function ($application) {
$application->refresh();
});
$this->service->databases->each(function ($database) {
$database->refresh();
});
} catch (\Exception $e) {
return handleError($e, $this);
}
}
}

View file

@ -28,10 +28,16 @@ public function mount()
try {
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) {
return redirect()->route('dashboard');
}
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', $this->parameters['project_uuid'])
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', $this->parameters['environment_uuid'])
->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();

View file

@ -7,12 +7,15 @@
use App\Actions\Service\StopService;
use App\Enums\ProcessStatus;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
class Heading extends Component
{
use AuthorizesRequests;
public Service $service;
public array $parameters;
@ -27,6 +30,8 @@ class Heading extends Component
public function mount()
{
$this->authorizeService('view');
if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) {
$this->service->isConfigurationChanged(true);
$this->dispatch('configurationChanged');
@ -47,6 +52,8 @@ public function getListeners()
public function checkStatus()
{
$this->authorizeService('view');
if ($this->service->server->isFunctional()) {
GetContainersStatus::dispatch($this->service->server);
} else {
@ -61,6 +68,8 @@ public function manualCheckStatus()
public function serviceChecked()
{
$this->authorizeService('view');
try {
$this->service->applications->each(function ($application) {
$application->refresh();
@ -82,6 +91,8 @@ public function serviceChecked()
public function checkDeployments()
{
$this->authorizeService('view');
try {
$activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first();
$status = data_get($activity, 'properties.status');
@ -99,12 +110,16 @@ public function checkDeployments()
public function start()
{
$this->authorizeService('deploy');
$activity = StartService::run($this->service, pullLatestImages: true);
$this->dispatch('activityMonitor', $activity->id);
}
public function forceDeploy()
{
$this->authorizeService('deploy');
try {
$activities = Activity::where('properties->type_uuid', $this->service->uuid)
->where(function ($q) {
@ -124,6 +139,8 @@ public function forceDeploy()
public function stop()
{
$this->authorizeService('stop');
try {
StopService::dispatch($this->service, false, $this->docker_cleanup);
} catch (\Exception $e) {
@ -133,6 +150,8 @@ public function stop()
public function restart()
{
$this->authorizeService('deploy');
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
@ -145,6 +164,8 @@ public function restart()
public function pullAndRestartEvent()
{
$this->authorizeService('deploy');
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
@ -155,6 +176,15 @@ public function pullAndRestartEvent()
$this->dispatch('activityMonitor', $activity->id);
}
private function authorizeService(string $ability): void
{
$this->service = Service::ownedByCurrentTeam()
->whereKey($this->service->getKey())
->firstOrFail();
$this->authorize($ability, $this->service);
}
public function render()
{
return view('livewire.project.service.heading', [

View file

@ -108,10 +108,16 @@ public function mount()
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->currentRoute = request()->route()->getName();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) {
return redirect()->route('dashboard');
}
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', $this->parameters['project_uuid'])
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', $this->parameters['environment_uuid'])
->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) {

View file

@ -0,0 +1,66 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class ResourceCard extends Component
{
use AuthorizesRequests;
public Service $service;
public ServiceApplication|ServiceDatabase $resource;
public array $parameters = [];
public function getListeners(): array
{
$user = Auth::user();
if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [
"echo-private:team.{$team->id},ServiceChecked" => 'refreshResource',
];
}
public function refreshResource(): void
{
$this->resource->refresh();
}
public function restart(): void
{
try {
$this->authorize('update', $this->service);
$this->resource->restart();
$message = $this->resource instanceof ServiceApplication
? 'Service application restarted successfully.'
: 'Service database restarted successfully.';
$this->dispatch('success', $message);
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render(): View
{
return view('livewire.project.service.resource-card', [
'isApplication' => $this->resource instanceof ServiceApplication,
'isDatabase' => $this->resource instanceof ServiceDatabase,
]);
}
}

View file

@ -110,15 +110,27 @@ public function redeploy(int $network_id, int $server_id)
public function promote(int $network_id, int $server_id)
{
$main_destination = $this->resource->destination;
$this->resource->update([
'destination_id' => $network_id,
'destination_type' => StandaloneDocker::class,
]);
$this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
$this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
$this->refreshServers();
$this->resource->refresh();
try {
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
$this->authorize('update', $this->resource);
$this->resource->getConnection()->transaction(function () use ($network, $server) {
$main_destination = $this->resource->destination;
$this->resource->update([
'destination_id' => $network->id,
'destination_type' => StandaloneDocker::class,
]);
$this->resource->additional_networks()
->wherePivot('server_id', $server->id)
->detach($network->id);
$this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
});
$this->resource->refresh();
$this->refreshServers();
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function refreshServers()
@ -130,8 +142,16 @@ public function refreshServers()
public function addServer(int $network_id, int $server_id)
{
$this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]);
$this->dispatch('refresh');
try {
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
$this->authorize('update', $this->resource);
$this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]);
$this->dispatch('refresh');
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function removeServer(int $network_id, int $server_id, $password, $selectedActions = [])
@ -148,7 +168,9 @@ public function removeServer(int $network_id, int $server_id, $password, $select
}
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
StopApplicationOneServer::run($this->resource, $server);
$this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
$this->resource->additional_networks()
->wherePivot('server_id', $server_id)
->detach($network_id);
$this->loadData();
$this->dispatch('refresh');
ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id'));

View file

@ -12,6 +12,8 @@ class Terminal extends Component
{
public bool $hasShell = true;
public bool $isTerminalConnected = false;
private function checkShellAvailability(Server $server, string $container): bool
{
$escapedContainer = escapeshellarg($container);
@ -65,12 +67,20 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
$dockerCommand = "sudo {$dockerCommand}";
}
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
$command = SshMultiplexingHelper::generateSshCommand(
$server,
$dockerCommand,
commandTimeout: (int) config('constants.terminal.command_timeout')
);
} else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
$command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand);
$command = SshMultiplexingHelper::generateSshCommand(
$server,
$shellCommand,
commandTimeout: (int) config('constants.terminal.command_timeout')
);
}
// ssh command is sent back to frontend then to websocket
// this is done because the websocket connection is not available here
@ -84,6 +94,23 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
$this->dispatch('send-back-command', $command);
}
#[On('terminalConnected')]
public function markTerminalConnected(): void
{
$this->isTerminalConnected = true;
}
#[On('terminalDisconnected')]
public function markTerminalDisconnected(): void
{
$this->isTerminalConnected = false;
}
public function keepTerminalPageAlive(): void
{
$this->isTerminalConnected = true;
}
public function render()
{
return view('livewire.project.shared.terminal');

View file

@ -5,6 +5,7 @@
use App\Models\InstanceSettings;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Laravel\Sanctum\PersonalAccessToken;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ApiTokens extends Component
@ -29,8 +30,10 @@ class ApiTokens extends Component
public $isApiEnabled;
#[Locked]
public bool $canUseRootPermissions = false;
#[Locked]
public bool $canUseWritePermissions = false;
public function render()
@ -54,7 +57,7 @@ private function getTokens()
public function updatedPermissions($permissionToUpdate)
{
// Check if user is trying to use restricted permissions
if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) {
if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use root permissions.');
// Remove root from permissions if it was somehow added
$this->permissions = array_diff($this->permissions, ['root']);
@ -62,7 +65,7 @@ public function updatedPermissions($permissionToUpdate)
return;
}
if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) {
if (in_array($permissionToUpdate, ['write', 'write:sensitive'], true) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use write permissions.');
// Remove write permissions if they were somehow added
$this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']);
@ -72,7 +75,7 @@ public function updatedPermissions($permissionToUpdate)
if ($permissionToUpdate == 'root') {
$this->permissions = ['root'];
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) {
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) {
$this->permissions[] = 'read';
} elseif ($permissionToUpdate == 'deploy') {
$this->permissions = ['deploy'];
@ -90,11 +93,11 @@ public function addNewToken()
$this->authorize('create', PersonalAccessToken::class);
// Validate permissions based on user role
if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) {
if (in_array('root', $this->permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with root permissions.');
}
if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! $this->canUseWritePermissions) {
if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with write permissions.');
}

View file

@ -45,7 +45,7 @@ public function add($name)
} else {
SwarmDocker::create([
'name' => $this->server->name.'-'.$name,
'network' => $this->name,
'network' => $name,
'server_id' => $this->server->id,
]);
}

View file

@ -93,7 +93,9 @@ public function handleSentinelRestarted($event)
{
if ($event['serverUuid'] === $this->server->uuid) {
$this->server->refresh();
$this->syncData();
// Only refresh display-only state; never re-sync text-input properties
// (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695).
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
$this->dispatch('success', 'Sentinel has been restarted successfully.');
}
}

View file

@ -277,7 +277,9 @@ public function handleSentinelRestarted($event)
// Only refresh if the event is for this server
if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) {
$this->server->refresh();
$this->syncData();
// Only refresh display-only state; never re-sync text-input properties
// (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695).
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
$this->dispatch('success', 'Sentinel has been restarted successfully.');
}
}
@ -457,12 +459,15 @@ public function handleServerValidated($event = null)
return;
}
// Refresh server data
// Refresh server data and only the display-only state that validation produces.
// Never re-sync text-input properties via syncData() — would clobber any
// unsaved typing (see coolify#6062 / #6354 / #9695).
$this->server->refresh();
$this->syncData();
// Update validation state
$this->server->settings->refresh();
$this->isValidating = $this->server->is_validating ?? false;
$this->validationLogs = $this->server->validation_logs;
$this->isReachable = $this->server->settings->is_reachable;
$this->isUsable = $this->server->settings->is_usable;
// Reload Hetzner tokens in case the linking section should now be shown
$this->loadHetznerTokens();

View file

@ -11,6 +11,8 @@ class SettingsDropdown extends Component
{
public $showWhatsNewModal = false;
public string $trigger = 'preferences';
public function getUnreadCountProperty()
{
return Auth::user()->getUnreadChangelogCount();

View file

@ -7,7 +7,9 @@
use App\Models\PrivateKey;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
@ -19,6 +21,10 @@ class Change extends Component
public string $webhook_endpoint = '';
public string $custom_webhook_endpoint = '';
public bool $use_custom_webhook_endpoint = false;
public ?string $ipv4 = null;
public ?string $ipv6 = null;
@ -72,6 +78,10 @@ class Change extends Component
public $privateKeys;
public string $manifestState = '';
public string $activeTab = 'general';
protected function rules(): array
{
return [
@ -91,6 +101,9 @@ protected function rules(): array
'metadata' => 'nullable|string',
'pullRequests' => 'nullable|string',
'privateKeyId' => 'nullable|int',
'webhook_endpoint' => ['required', 'string', 'url'],
'custom_webhook_endpoint' => ['nullable', 'string', 'url'],
'use_custom_webhook_endpoint' => ['required', 'bool'],
];
}
@ -147,6 +160,24 @@ private function syncData(bool $toModel = false): void
}
}
private function githubAppSetupStateCacheKey(string $state): string
{
return 'github-app-setup-state:'.hash('sha256', $state);
}
private function createGithubAppSetupState(string $action): string
{
$state = Str::random(64);
Cache::put($this->githubAppSetupStateCacheKey($state), [
'action' => $action,
'github_app_id' => $this->github_app->id,
'team_id' => $this->github_app->team_id,
], now()->addMinutes(60));
return $state;
}
public function checkPermissions()
{
try {
@ -211,6 +242,7 @@ public function mount()
// Override name with kebab case for display
$this->name = str($this->github_app->name)->kebab();
$this->fqdn = $settings->fqdn;
$this->manifestState = $this->createGithubAppSetupState('manifest');
if ($settings->public_ipv4) {
$this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port');
@ -240,10 +272,18 @@ public function mount()
}
}
$this->parameters = get_route_parameters();
$routeName = request()->route()?->getName();
if ($routeName === 'source.github.permissions') {
$this->activeTab = 'permissions';
} elseif ($routeName === 'source.github.resources') {
$this->activeTab = 'resources';
} else {
$this->activeTab = 'general';
}
if (isCloud() && ! isDev()) {
$this->webhook_endpoint = config('app.url');
} else {
$this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? '';
$this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? $this->ipv6 ?? config('app.url') ?? '';
$this->is_system_wide = $this->github_app->is_system_wide;
}
} catch (\Throwable $e) {

View file

@ -1188,17 +1188,20 @@ public function pendingDeploymentConfigurationDiff(): ConfigurationDiff
$currentSnapshot = $this->deploymentConfigurationSnapshot();
$lastDeployment = $this->get_last_successful_deployment();
if ($lastDeployment?->configuration_snapshot) {
return app(ConfigurationDiffer::class)->diff($lastDeployment->configuration_snapshot, $currentSnapshot);
$previousSnapshot = $lastDeployment?->configuration_snapshot;
if (! $previousSnapshot) {
$oldConfigHash = data_get($this, 'config_hash');
$hasLegacyChange = $oldConfigHash === null || $oldConfigHash !== $this->legacyConfigurationHash();
if (! $hasLegacyChange) {
return ConfigurationDiff::unchanged();
}
$previousSnapshot = [];
}
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {
return ConfigurationDiff::legacy(true);
}
return ConfigurationDiff::legacy($oldConfigHash !== $this->legacyConfigurationHash());
return app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot);
}
public function hasPendingDeploymentConfigurationChanges(): bool

View file

@ -73,26 +73,6 @@ public static function ownedByCurrentTeam()
});
}
public static function public()
{
return GithubApp::where(function ($query) {
$query->where(function ($q) {
$q->where('team_id', currentTeam()->id)
->orWhere('is_system_wide', true);
})->where('is_public', true);
})->whereNotNull('app_id')->get();
}
public static function private()
{
return GithubApp::where(function ($query) {
$query->where(function ($q) {
$q->where('team_id', currentTeam()->id)
->orWhere('is_system_wide', true);
})->where('is_public', false);
})->whereNotNull('app_id')->get();
}
public function team()
{
return $this->belongsTo(Team::class);

View file

@ -14,7 +14,12 @@ class S3Storage extends BaseModel
{
use HasFactory, HasSafeStringAttribute;
private const CONNECTION_TIMEOUT_SECONDS = 15;
private const REQUEST_TIMEOUT_SECONDS = 15;
protected $fillable = [
'team_id',
'name',
'description',
'region',
@ -157,6 +162,10 @@ public function testConnection(bool $shouldSave = false)
'bucket' => $this['bucket'],
'endpoint' => $this['endpoint'],
'use_path_style_endpoint' => true,
'http' => [
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
],
]);
// Test the connection by listing files with ListObjectsV2 (S3)
$disk->files();
@ -164,11 +173,12 @@ public function testConnection(bool $shouldSave = false)
$this->unusable_email_sent = false;
$this->is_usable = true;
} catch (\Throwable $e) {
$exception = $this->toUserFriendlyConnectionException($e);
$this->is_usable = false;
if ($this->unusable_email_sent === false && is_transactional_emails_enabled()) {
$mail = new MailMessage;
$mail->subject('Coolify: S3 Storage Connection Error');
$mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]);
$mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $exception->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]);
// Load the team with its members and their roles explicitly
$team = $this->team()->with(['members' => function ($query) {
@ -183,11 +193,25 @@ public function testConnection(bool $shouldSave = false)
$this->unusable_email_sent = true;
}
throw $e;
throw $exception;
} finally {
if ($shouldSave) {
$this->save();
}
}
}
private function toUserFriendlyConnectionException(\Throwable $exception): \Throwable
{
$message = str($exception->getMessage())->lower();
if ($message->contains(['timed out', 'timeout', 'connection refused', 'could not resolve', 'curl error 28'])) {
return new \RuntimeException(
'Could not connect to the S3 endpoint within 15 seconds. Please verify the endpoint, bucket, credentials, region, and network/firewall settings.',
previous: $exception,
);
}
return $exception;
}
}

View file

@ -23,6 +23,7 @@ class ScheduledDatabaseBackupExecution extends BaseModel
protected function casts(): array
{
return [
'size' => 'integer',
's3_uploaded' => 'boolean',
'local_storage_deleted' => 'boolean',
's3_storage_deleted' => 'boolean',

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandaloneClickhouse extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@ -44,11 +45,21 @@ class StandaloneClickhouse extends BaseModel
'destination_type',
'destination_id',
'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'clickhouse_admin_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
@ -111,6 +122,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandaloneDragonfly extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@ -43,11 +44,21 @@ class StandaloneDragonfly extends BaseModel
'destination_type',
'destination_id',
'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'dragonfly_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
@ -110,6 +121,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandaloneKeydb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@ -44,11 +45,21 @@ class StandaloneKeydb extends BaseModel
'destination_type',
'destination_id',
'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'server_status'];
protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'keydb_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
@ -111,6 +122,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -12,7 +13,7 @@
class StandaloneMariadb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@ -47,11 +48,21 @@ class StandaloneMariadb extends BaseModel
'destination_type',
'destination_id',
'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'mariadb_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
@ -114,6 +125,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandaloneMongodb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@ -47,11 +48,21 @@ class StandaloneMongodb extends BaseModel
'destination_type',
'destination_id',
'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
@ -120,6 +131,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandaloneMysql extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@ -48,11 +49,21 @@ class StandaloneMysql extends BaseModel
'destination_type',
'destination_id',
'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'mysql_password' => 'encrypted',
'mysql_root_password' => 'encrypted',
'public_port_timeout' => 'integer',
@ -116,6 +127,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandalonePostgresql extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@ -50,11 +51,21 @@ class StandalonePostgresql extends BaseModel
'destination_type',
'destination_id',
'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'init_scripts' => 'array',
'postgres_password' => 'encrypted',
'public_port_timeout' => 'integer',
@ -158,6 +169,7 @@ public function deleteVolumes()
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandaloneRedis extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@ -43,11 +44,21 @@ class StandaloneRedis extends BaseModel
'destination_type',
'destination_id',
'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
@ -115,6 +126,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');

View file

@ -98,8 +98,24 @@ protected static function boot()
$team['id'] = 0;
$team['name'] = 'Root Team';
}
$new_team = $user->id === 0 ? Team::find(0) : null;
if ($new_team !== null) {
$new_team->forceFill($team);
$new_team->save();
if (! $user->teams()->whereKey($new_team->id)->exists()) {
$user->teams()->attach($new_team, ['role' => 'owner']);
} else {
$user->teams()->updateExistingPivot($new_team->id, ['role' => 'owner']);
}
return;
}
$new_team = (new Team)->forceFill($team);
$new_team->save();
$user->teams()->attach($new_team, ['role' => 'owner']);
});

View file

@ -306,7 +306,7 @@ private function normalizeValue(mixed $value): mixed
private function displayValue(mixed $value): string
{
if ($value === null) {
return 'Not set';
return '-';
}
if (is_bool($value)) {
@ -323,7 +323,7 @@ private function displayValue(mixed $value): string
private function summarizeText(?string $value): string
{
if (blank($value)) {
return 'Not set';
return '-';
}
$value = trim((string) $value);

View file

@ -37,8 +37,8 @@ public function diff(array $previousSnapshot, array $currentSnapshot): Configura
'impact' => data_get($item, 'impact', 'redeploy'),
'sensitive' => $sensitive,
'display_summary' => $displaySummary,
'old_display_value' => $sensitive ? ($previous === null ? 'Not set' : 'Set') : data_get($previous, 'display_value', 'Not set'),
'new_display_value' => $sensitive ? ($current === null ? 'Removed' : 'Set') : data_get($current, 'display_value', 'Not set'),
'old_display_value' => $sensitive ? ($previous === null ? '-' : '••••••••') : data_get($previous, 'display_value', '-'),
'new_display_value' => $sensitive ? ($current === null ? '-' : '••••••••') : data_get($current, 'display_value', '-'),
];
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Traits;
/**
* Shared healthcheck behaviour for standalone database models.
*
* Standalone databases use a fixed, type-specific probe command (psql, redis-cli, ...),
* so only the timing fields and the enable/disable flag are configurable.
*/
trait HasDatabaseHealthCheck
{
public function isHealthcheckEnabled(): bool
{
return (bool) ($this->health_check_enabled ?? true);
}
/**
* Build the Docker Compose healthcheck block for the given probe command.
*
* @param array<int, string> $test The Docker `test` array (e.g. ['CMD', 'pg_isready']).
* @return array<string, mixed>
*/
public function healthCheckConfiguration(array $test): array
{
return [
'test' => $test,
'interval' => ($this->health_check_interval ?? 15).'s',
'timeout' => ($this->health_check_timeout ?? 5).'s',
'retries' => $this->health_check_retries ?? 5,
'start_period' => ($this->health_check_start_period ?? 5).'s',
];
}
protected function healthCheckConfigurationHash(): string
{
return implode('|', [
(int) ($this->health_check_enabled ?? true),
$this->health_check_interval ?? 15,
$this->health_check_timeout ?? 5,
$this->health_check_retries ?? 5,
$this->health_check_start_period ?? 5,
]);
}
}

View file

@ -0,0 +1,172 @@
<?php
namespace App\Traits;
use App\Helpers\SslHelper;
use Carbon\Carbon;
use Exception;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
/**
* Shared behavior for the per-database StatusInfo Livewire siblings.
*
* Lives on a child Livewire component so status broadcasts never trigger a
* roundtrip on the parent form preserving in-progress typing AND wire:dirty.
* See coolify#6062 / #6354 / #9695.
*
* Consumers must declare a typed `public Model $database` and implement
* databaseLabel(). All other hooks have sensible defaults.
*/
trait HasDatabaseStatusInfo
{
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?Carbon $certificateValidUntil = null;
abstract protected function databaseLabel(): string;
protected function supportsSsl(): bool
{
return true;
}
protected function sslModeOptions(): ?array
{
return null;
}
protected function sslModeHelper(): ?string
{
return null;
}
protected function showPublicUrlPlaceholder(): bool
{
return false;
}
public function getListeners(): array
{
$listeners = ['databaseUpdated' => 'refresh'];
$user = Auth::user();
if (! $user) {
return $listeners;
}
$listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refresh';
$team = $user->currentTeam();
if ($team) {
$listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refresh';
}
return $listeners;
}
public function mount(): void
{
$this->refresh();
}
public function refresh(): void
{
$this->database->refresh();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
if ($this->supportsSsl()) {
$this->enableSsl = (bool) $this->database->enable_ssl;
$this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until;
$this->afterRefresh();
}
}
/**
* Hook for subclasses with extra status-derived properties (e.g. sslMode).
*/
protected function afterRefresh(): void {}
public function instantSaveSSL(): void
{
try {
$this->authorize('update', $this->database);
$this->database->enable_ssl = $this->enableSsl;
$this->applyExtraSslAttributes();
$this->database->save();
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
handleError($e, $this);
}
}
/**
* Hook for subclasses with additional SSL columns to persist (e.g. ssl_mode).
*/
protected function applyExtraSslAttributes(): void {}
public function regenerateSslCertificate(): void
{
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->refresh();
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
public function render(): View
{
return view('livewire.project.database.status-info', [
'label' => $this->databaseLabel(),
'supportsSsl' => $this->supportsSsl(),
'sslModeOptions' => $this->sslModeOptions(),
'sslModeHelper' => $this->sslModeHelper(),
'showPublicUrlPlaceholder' => $this->showPublicUrlPlaceholder(),
'isExited' => str($this->database->status)->contains('exited'),
]);
}
}

View file

@ -353,14 +353,30 @@ function showBoarding(): bool
function refreshSession(?Team $team = null): void
{
if (! $team) {
if (Auth::user()->currentTeam()) {
$team = Team::find(Auth::user()->currentTeam()->id);
} else {
$team = User::find(Auth::id())->teams->first();
$currentTeam = Auth::user()->currentTeam();
if ($currentTeam) {
// currentTeam() can resolve a stale (just-deleted) team from the
// session/cache, so Team::find() may still return null here.
$team = Team::find($currentTeam->id);
}
if (! $team) {
// Fall back to any team the user still belongs to.
$team = User::query()->find(Auth::id())?->teams()->first();
}
}
// Clear old cache key format for backwards compatibility
Cache::forget('team:'.Auth::id());
if (! $team) {
// The user has no team left (e.g. just deleted their current team and
// belongs to no other): clear the stale session reference instead of
// dereferencing null.
session()->forget('currentTeam');
return;
}
// Use new cache key format that includes team ID
Cache::forget('user:'.Auth::id().':team:'.$team->id);
Cache::remember('user:'.Auth::id().':team:'.$team->id, 3600, function () use ($team) {
@ -592,6 +608,39 @@ function isCloud(): bool
return ! config('constants.coolify.self_hosted');
}
/**
* Resolve the queue used for application deployments, database starts and service starts.
*
* On cloud these jobs run on a dedicated `deployments` queue so they can be drained by an
* isolated Horizon worker pool; self-hosted keeps them on the shared `high` queue. Routing
* is decided by `isCloud()` (config-based) rather than `HORIZON_QUEUES`, so the dispatching
* process needs no special env only the worker must be configured to drain `deployments`.
*
* IMPORTANT: on cloud a worker MUST include `deployments` in its `HORIZON_QUEUES`, otherwise
* these jobs are never processed.
*/
function deployment_queue(): string
{
return isCloud() ? 'deployments' : 'high';
}
/**
* Resolve the queue used for scheduled jobs the scheduler dispatcher, scheduled tasks and
* scheduled database backups, whether triggered automatically or manually.
*
* On cloud these jobs run on a dedicated `crons` queue so they can be drained by an isolated
* Horizon worker pool; self-hosted keeps them on the shared `high` queue. Routing is decided
* by `isCloud()` (config-based), so the dispatching process needs no special env only the
* worker must be configured to drain `crons`.
*
* IMPORTANT: on cloud a worker MUST include `crons` in its `HORIZON_QUEUES`, otherwise these
* jobs are never processed.
*/
function crons_queue(): string
{
return isCloud() ? 'crons' : 'high';
}
function translate_cron_expression($expression_to_validate): string
{
if (isset(VALID_CRON_STRINGS[$expression_to_validate])) {

2539
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,9 +2,9 @@
return [
'coolify' => [
'version' => '4.1.0',
'version' => '4.1.2',
'helper_version' => '1.0.14',
'realtime_version' => '1.0.15',
'realtime_version' => '1.0.16',
'railpack_version' => '0.23.0',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
@ -16,7 +16,7 @@
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
'releases_url' => 'https://cdn.coolify.io/releases.json',
'releases_url' => env('RELEASES_URL', 'https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json'),
],
'urls' => [
@ -35,6 +35,7 @@
'protocol' => env('TERMINAL_PROTOCOL'),
'host' => env('TERMINAL_HOST'),
'port' => env('TERMINAL_PORT'),
'command_timeout' => 0,
],
'pusher' => [
@ -67,9 +68,6 @@
'ssh' => [
'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)),
'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600),
'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true),
'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5),
'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes
'connection_timeout' => 10,
'server_interval' => 20,
'command_timeout' => 3600,
@ -94,6 +92,23 @@
'sentry_dsn' => env('SENTRY_DSN'),
],
'sentinel' => [
// How often (seconds) PushServerUpdateJob is force-dispatched even when
// the container state hash is unchanged. Keeps exited-detection and
// storage checks from going stale without writing every resource row on
// every push.
'push_force_interval_seconds' => env('SENTINEL_PUSH_FORCE_INTERVAL_SECONDS', 300),
],
'proxy' => [
// How often (seconds) PushServerUpdateJob periodically re-connects the
// proxy to Docker networks as a safety net. Real network-layout changes
// already connect the proxy on-demand; this only covers gaps (Swarm
// networks added via UI, proxy crash recovery).
'connect_networks_interval_seconds' => env('PROXY_CONNECT_NETWORKS_INTERVAL_SECONDS', 3600),
],
'webhooks' => [
'feedback_discord_webhook' => env('FEEDBACK_DISCORD_WEBHOOK'),
'dev_webhook' => env('SERVEO_URL'),

View file

@ -1,6 +1,64 @@
<?php
use Illuminate\Support\Str;
use Pdo\Pgsql;
$parseDatabaseHosts = function (mixed $hosts, mixed $fallback = 'coolify-db'): array {
$parsedHosts = array_values(array_filter(
array_map('trim', explode(',', (string) $hosts)),
'strlen',
));
if ($parsedHosts !== []) {
return $parsedHosts;
}
$fallbackHosts = array_values(array_filter(
array_map('trim', explode(',', (string) $fallback)),
'strlen',
));
return $fallbackHosts === [] ? ['coolify-db'] : $fallbackHosts;
};
$pgsql = [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', 'coolify-db'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'coolify'),
'username' => env('DB_USERNAME', 'coolify'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
'options' => [
(defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? Pgsql::ATTR_DISABLE_PREPARES : PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false),
],
];
/*
* Opt-in read/write replica split. Activates only when DB_READ_HOST is set.
* When unset, the pgsql connection is identical to a single-primary setup.
* Hosts may be comma-separated; Laravel random-picks one per connection.
*/
if (env('DB_READ_HOST')) {
$pgsql['read'] = [
'host' => $parseDatabaseHosts(env('DB_READ_HOST'), env('DB_HOST', 'coolify-db')),
'port' => env('DB_READ_PORT', env('DB_PORT', '5432')),
'username' => env('DB_READ_USERNAME', env('DB_USERNAME', 'coolify')),
'password' => env('DB_READ_PASSWORD', env('DB_PASSWORD', '')),
];
$pgsql['write'] = [
'host' => $parseDatabaseHosts(env('DB_WRITE_HOST'), env('DB_HOST', 'coolify-db')),
'port' => env('DB_WRITE_PORT', env('DB_PORT', '5432')),
'username' => env('DB_WRITE_USERNAME', env('DB_USERNAME', 'coolify')),
'password' => env('DB_WRITE_PASSWORD', env('DB_PASSWORD', '')),
];
$pgsql['sticky'] = (bool) env('DB_STICKY', true);
}
return [
@ -35,23 +93,7 @@
'connections' => [
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', 'coolify-db'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'coolify'),
'username' => env('DB_USERNAME', 'coolify'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
'options' => [
(defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? \Pdo\Pgsql::ATTR_DISABLE_PREPARES : \PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false),
],
],
'pgsql' => $pgsql,
'testing' => [
'driver' => 'sqlite',

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement('CREATE INDEX IF NOT EXISTS swarm_dockers_server_id_index ON swarm_dockers (server_id)');
DB::statement('CREATE INDEX IF NOT EXISTS services_server_id_index ON services (server_id)');
DB::statement('CREATE INDEX IF NOT EXISTS application_previews_application_id_index ON application_previews (application_id)');
DB::statement('CREATE INDEX IF NOT EXISTS service_applications_service_id_index ON service_applications (service_id)');
DB::statement('CREATE INDEX IF NOT EXISTS service_databases_service_id_index ON service_databases (service_id)');
DB::statement('CREATE INDEX IF NOT EXISTS servers_sentinel_updated_at_index ON servers (sentinel_updated_at)');
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS swarm_dockers_server_id_index');
DB::statement('DROP INDEX IF EXISTS services_server_id_index');
DB::statement('DROP INDEX IF EXISTS application_previews_application_id_index');
DB::statement('DROP INDEX IF EXISTS service_applications_service_id_index');
DB::statement('DROP INDEX IF EXISTS service_databases_service_id_index');
DB::statement('DROP INDEX IF EXISTS servers_sentinel_updated_at_index');
}
};

View file

@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Fillfactor < 100 leaves free space per page so Postgres can do HOT
// (Heap-Only Tuple) in-place updates instead of allocating a new tuple
// elsewhere. Coolify's hot-update tables churn rows on every Sentinel
// push / status change; without page-local headroom, non-HOT updates
// accumulate dead tuples and bloat the heap (we've seen up to 50× on
// cloud). Lower fillfactor on hot-update tables, default on the rest.
DB::statement('ALTER TABLE applications SET (fillfactor = 70)');
DB::statement('ALTER TABLE servers SET (fillfactor = 85)');
DB::statement('ALTER TABLE services SET (fillfactor = 85)');
DB::statement('ALTER TABLE service_applications SET (fillfactor = 85)');
DB::statement('ALTER TABLE service_databases SET (fillfactor = 85)');
DB::statement('ALTER TABLE standalone_postgresqls SET (fillfactor = 85)');
DB::statement('ALTER TABLE standalone_redis SET (fillfactor = 85)');
DB::statement('ALTER TABLE standalone_mongodbs SET (fillfactor = 85)');
DB::statement('ALTER TABLE standalone_mysqls SET (fillfactor = 85)');
DB::statement('ALTER TABLE standalone_mariadbs SET (fillfactor = 85)');
DB::statement('ALTER TABLE standalone_keydbs SET (fillfactor = 85)');
DB::statement('ALTER TABLE standalone_dragonflies SET (fillfactor = 85)');
DB::statement('ALTER TABLE standalone_clickhouses SET (fillfactor = 85)');
DB::statement('ALTER TABLE application_deployment_queues SET (fillfactor = 90)');
// Autovacuum default kicks in at 20% dead tuples — too lazy for our
// churn rate. Trigger at 5% on the highest-write tables to keep heap
// pages tidy and prevent visibility-map gaps that hurt scan plans.
DB::statement('ALTER TABLE applications SET (autovacuum_vacuum_scale_factor = 0.05)');
DB::statement('ALTER TABLE servers SET (autovacuum_vacuum_scale_factor = 0.05)');
DB::statement('ALTER TABLE service_applications SET (autovacuum_vacuum_scale_factor = 0.05)');
DB::statement('ALTER TABLE service_databases SET (autovacuum_vacuum_scale_factor = 0.05)');
DB::statement('ALTER TABLE standalone_postgresqls SET (autovacuum_vacuum_scale_factor = 0.05)');
}
public function down(): void
{
DB::statement('ALTER TABLE applications RESET (fillfactor, autovacuum_vacuum_scale_factor)');
DB::statement('ALTER TABLE servers RESET (fillfactor, autovacuum_vacuum_scale_factor)');
DB::statement('ALTER TABLE services RESET (fillfactor)');
DB::statement('ALTER TABLE service_applications RESET (fillfactor, autovacuum_vacuum_scale_factor)');
DB::statement('ALTER TABLE service_databases RESET (fillfactor, autovacuum_vacuum_scale_factor)');
DB::statement('ALTER TABLE standalone_postgresqls RESET (fillfactor, autovacuum_vacuum_scale_factor)');
DB::statement('ALTER TABLE standalone_redis RESET (fillfactor)');
DB::statement('ALTER TABLE standalone_mongodbs RESET (fillfactor)');
DB::statement('ALTER TABLE standalone_mysqls RESET (fillfactor)');
DB::statement('ALTER TABLE standalone_mariadbs RESET (fillfactor)');
DB::statement('ALTER TABLE standalone_keydbs RESET (fillfactor)');
DB::statement('ALTER TABLE standalone_dragonflies RESET (fillfactor)');
DB::statement('ALTER TABLE standalone_clickhouses RESET (fillfactor)');
DB::statement('ALTER TABLE application_deployment_queues RESET (fillfactor)');
}
};

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