mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-14 03:19:51 +00:00
Merge remote-tracking branch 'origin/next' into s3-backup-validation
This commit is contained in:
@@ -50,13 +50,9 @@ class StartClickhouse
|
||||
],
|
||||
],
|
||||
'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 @@ class StartClickhouse
|
||||
$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";
|
||||
|
||||
@@ -106,13 +106,9 @@ class StartDragonfly
|
||||
$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 @@ class StartDragonfly
|
||||
$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";
|
||||
|
||||
@@ -108,13 +108,9 @@ class StartKeydb
|
||||
$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 @@ class StartKeydb
|
||||
// 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";
|
||||
|
||||
@@ -103,13 +103,9 @@ class StartMariadb
|
||||
$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 @@ class StartMariadb
|
||||
];
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
@@ -109,17 +109,11 @@ class StartMongodb
|
||||
$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 @@ class StartMongodb
|
||||
$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";
|
||||
|
||||
@@ -103,13 +103,9 @@ class StartMysql
|
||||
$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 @@ class StartMysql
|
||||
];
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
@@ -110,13 +110,9 @@ class StartPostgresql
|
||||
$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 @@ class StartPostgresql
|
||||
$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";
|
||||
|
||||
@@ -105,17 +105,11 @@ class StartRedis
|
||||
$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 @@ class StartRedis
|
||||
$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";
|
||||
|
||||
@@ -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 @@ class CreateNewUser implements CreatesNewUsers
|
||||
'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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class StartService
|
||||
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();
|
||||
@@ -53,4 +53,9 @@ class StartService
|
||||
|
||||
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
|
||||
}
|
||||
|
||||
private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool
|
||||
{
|
||||
return $stopBeforeStart && ! $pullLatestImages;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,13 @@ class CleanupUnreachableServers extends Command
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@ class SyncBunny extends Command
|
||||
$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 @@ class SyncBunny extends Command
|
||||
$install_script_location = "$parent_dir/other/nightly/$install_script";
|
||||
$versions_location = "$parent_dir/other/nightly/$versions";
|
||||
}
|
||||
if (! $only_template && ! $only_version && ! $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 @@ class SyncBunny extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// 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 @@ class SyncBunny extends Command
|
||||
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 @@ class SyncBunny extends Command
|
||||
|
||||
$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 @@ class SyncBunny extends Command
|
||||
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 @@ class SyncBunny extends Command
|
||||
$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 @@ class SyncBunny extends Command
|
||||
$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());
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\CheckTraefikVersionJob;
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\CleanupOrphanedPreviewContainersJob;
|
||||
use App\Jobs\CleanupStaleMultiplexedConnections;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Jobs\PullTemplatesFromCDN;
|
||||
use App\Jobs\RegenerateSslCertJob;
|
||||
@@ -41,7 +40,6 @@ class Kernel extends ConsoleKernel
|
||||
$this->instanceTimezone = config('app.timezone');
|
||||
}
|
||||
|
||||
$this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly()->onOneServer();
|
||||
$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();
|
||||
|
||||
@@ -63,10 +63,10 @@ class SshMultiplexingHelper
|
||||
$scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
|
||||
|
||||
if ($server->isIpv6()) {
|
||||
return $scpCommand."{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
|
||||
return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
|
||||
}
|
||||
|
||||
return $scpCommand."{$source} ".self::escapedUserAtHost($server).":{$dest}";
|
||||
return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
|
||||
}
|
||||
|
||||
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false): string
|
||||
|
||||
@@ -299,6 +299,11 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
}
|
||||
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.');
|
||||
|
||||
@@ -6,8 +6,10 @@ 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
|
||||
{
|
||||
@@ -77,6 +79,17 @@ class SentinelController extends Controller
|
||||
|
||||
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.
|
||||
@@ -105,29 +118,38 @@ class SentinelController extends Controller
|
||||
$hash = $this->containerStateHash($data);
|
||||
$hashKey = "sentinel:push-hash:{$server->id}";
|
||||
$forceKey = "sentinel:push-force:{$server->id}";
|
||||
$lockKey = "sentinel:push-lock:{$server->id}";
|
||||
|
||||
$cachedHash = Cache::get($hashKey);
|
||||
$forceActive = Cache::has($forceKey);
|
||||
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;
|
||||
$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));
|
||||
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;
|
||||
}
|
||||
|
||||
return $shouldDispatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable hash of container state.
|
||||
*
|
||||
* Covers [name, state, health_status] only — metrics and
|
||||
* filesystem_usage_root are excluded on purpose (disk % churns constantly
|
||||
* and would defeat the hash; the storage check is separately cache-gated
|
||||
* inside PushServerUpdateJob). Sorted by name so container ordering from
|
||||
* Sentinel does not affect the hash.
|
||||
* 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
|
||||
{
|
||||
@@ -135,7 +157,6 @@ class SentinelController extends Controller
|
||||
->map(fn ($c) => [
|
||||
'name' => data_get($c, 'name'),
|
||||
'state' => data_get($c, 'state'),
|
||||
'health_status' => data_get($c, 'health_status'),
|
||||
])
|
||||
->sortBy('name')
|
||||
->values()
|
||||
|
||||
@@ -81,6 +81,10 @@ trait MatchesManualWebhookApplications
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ class Github extends Controller
|
||||
$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.");
|
||||
@@ -222,6 +223,7 @@ class Github extends Controller
|
||||
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
|
||||
authorAssociation: $author_association,
|
||||
fullName: $full_name,
|
||||
isForkPullRequest: $is_fork_pull_request ?? false,
|
||||
);
|
||||
|
||||
$return_payloads->push([
|
||||
@@ -303,6 +305,7 @@ class Github extends Controller
|
||||
$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.");
|
||||
@@ -434,6 +437,7 @@ class Github extends Controller
|
||||
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
|
||||
authorAssociation: $author_association,
|
||||
fullName: $full_name,
|
||||
isForkPullRequest: $is_fork_pull_request ?? false,
|
||||
);
|
||||
|
||||
$return_payloads->push([
|
||||
@@ -451,6 +455,40 @@ class Github extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
$code = (string) $request->query('code', '');
|
||||
|
||||
@@ -1,295 +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\Log;
|
||||
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();
|
||||
$this->cleanupDuplicateSshProcesses();
|
||||
$this->cleanupOrphanedSshProcesses();
|
||||
$this->cleanupOrphanedCloudflaredProcesses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Once two background ssh masters share the same ControlPath, OpenSSH's
|
||||
* control socket state is no longer trustworthy: `ssh -O check` may report
|
||||
* one PID while the socket lifecycle is tied to another. Reset the whole
|
||||
* duplicate group rather than trying to choose an owner.
|
||||
*/
|
||||
private function cleanupDuplicateSshProcesses(): void
|
||||
{
|
||||
$muxDir = storage_path('app/ssh/mux');
|
||||
$groups = [];
|
||||
|
||||
foreach ($this->listProcesses() as $process) {
|
||||
$controlPath = $this->extractControlPath($process['args']);
|
||||
if (! is_string($controlPath) || ! str_starts_with($controlPath, $muxDir.'/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groups[$controlPath][] = $process;
|
||||
}
|
||||
|
||||
foreach ($groups as $controlPath => $processes) {
|
||||
if (count($processes) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->resetDuplicateGroup($controlPath, $processes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill backgrounded ssh master processes that lost the ControlPath socket
|
||||
* race. Such processes are not masters, so ControlPersist never reaps them
|
||||
* and they leak memory until the container restarts. A legitimate master
|
||||
* always owns its socket file; an orphan has none.
|
||||
*
|
||||
* Processes younger than the minimum age are skipped: a freshly forked
|
||||
* master creates its socket a few milliseconds after starting, so a young
|
||||
* process with no socket may simply be mid-establish rather than orphaned.
|
||||
*/
|
||||
private function cleanupOrphanedSshProcesses(): void
|
||||
{
|
||||
$muxDir = storage_path('app/ssh/mux');
|
||||
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
|
||||
|
||||
foreach ($this->listProcesses() as $process) {
|
||||
// Only ever touch ssh processes pointing at Coolify's mux directory.
|
||||
$controlPath = $this->extractControlPath($process['args']);
|
||||
if (! is_string($controlPath) || ! str_starts_with($controlPath, $muxDir.'/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($process['etimes'] >= $minAge && ! file_exists($controlPath)) {
|
||||
$this->reapOrphan('ssh', $process);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill orphaned `cloudflared access ssh` proxy processes. Each is spawned
|
||||
* as the SSH ProxyCommand transport for a Cloudflare Tunnel server and must
|
||||
* die with its parent ssh. When that ssh is killed or orphaned (e.g. a lost
|
||||
* mux master), the cloudflared process can leak and accumulate. A legitimate
|
||||
* proxy always has a live ssh parent; one without is safe to reap.
|
||||
*
|
||||
* Processes younger than the minimum age are skipped so a proxy whose parent
|
||||
* ssh is still starting up, or a transient `ssh -O check` proxy mid-exit, is
|
||||
* never mistaken for an orphan.
|
||||
*/
|
||||
private function cleanupOrphanedCloudflaredProcesses(): void
|
||||
{
|
||||
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
|
||||
$processes = $this->listProcesses();
|
||||
|
||||
$sshPids = [];
|
||||
foreach ($processes as $process) {
|
||||
// The ssh binary itself, not `cloudflared access ssh` (space before ssh).
|
||||
if (preg_match('#(^|/)ssh\s#', $process['args'])) {
|
||||
$sshPids[$process['pid']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($processes as $process) {
|
||||
// `cloudflared access ssh`, never the `cloudflared tunnel` daemon.
|
||||
if (! str_contains($process['args'], 'cloudflared access ssh')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Orphaned when no live ssh process is its parent.
|
||||
if ($process['etimes'] >= $minAge && ! isset($sshPids[$process['ppid']])) {
|
||||
$this->reapOrphan('cloudflared', $process);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap a detected orphan process. When orphan reaping is disabled (the
|
||||
* default), the orphan is only logged — a dry-run mode that lets operators
|
||||
* verify what would be killed before enabling it for real.
|
||||
*
|
||||
* @param array{pid: string, ppid: string, etimes: int, args: string} $process
|
||||
*/
|
||||
private function reapOrphan(string $kind, array $process): void
|
||||
{
|
||||
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
|
||||
Log::info("Orphaned {$kind} process detected (dry-run, not killed)", [
|
||||
'pid' => $process['pid'],
|
||||
'etimes' => $process['etimes'],
|
||||
'command' => $process['args'],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Process::run('kill '.escapeshellarg($process['pid']));
|
||||
Log::info("Killed orphaned {$kind} process", [
|
||||
'pid' => $process['pid'],
|
||||
'etimes' => $process['etimes'],
|
||||
'command' => $process['args'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of running processes.
|
||||
*
|
||||
* @return list<array{pid: string, ppid: string, etimes: int, args: string}>
|
||||
*/
|
||||
private function listProcesses(): array
|
||||
{
|
||||
$ps = Process::run('ps -ww -eo pid=,ppid=,etimes=,args=');
|
||||
if ($ps->exitCode() !== 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$processes = [];
|
||||
foreach (explode("\n", trim($ps->output())) as $line) {
|
||||
if (! preg_match('/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/', $line, $matches)) {
|
||||
continue;
|
||||
}
|
||||
$processes[] = [
|
||||
'pid' => $matches[1],
|
||||
'ppid' => $matches[2],
|
||||
'etimes' => (int) $matches[3],
|
||||
'args' => $matches[4],
|
||||
];
|
||||
}
|
||||
|
||||
return $processes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{pid: string, ppid: string, etimes: int, args: string}> $processes
|
||||
*/
|
||||
private function resetDuplicateGroup(string $controlPath, array $processes): void
|
||||
{
|
||||
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
|
||||
Log::info('Duplicate ssh mux processes detected (dry-run, not killed)', [
|
||||
'control_path' => $controlPath,
|
||||
'pids' => array_column($processes, 'pid'),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($processes as $process) {
|
||||
Process::run('kill '.escapeshellarg($process['pid']));
|
||||
}
|
||||
|
||||
if (file_exists($controlPath)) {
|
||||
@unlink($controlPath);
|
||||
}
|
||||
|
||||
Log::info('Reset duplicate ssh mux processes', [
|
||||
'control_path' => $controlPath,
|
||||
'pids' => array_column($processes, 'pid'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function extractControlPath(string $args): ?string
|
||||
{
|
||||
if (! preg_match('/(?:^|\s)-o\s+ControlPath=(?:"([^"]+)"|\'([^\']+)\'|(\S+))/', $args, $matches)) {
|
||||
if (preg_match('/^ssh:\s+(\S+)\s+\[mux\]$/', $args, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $matches[1] ?: ($matches[2] ?: $matches[3]);
|
||||
}
|
||||
|
||||
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, 'server_not_found');
|
||||
|
||||
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, 'connection_check_failed');
|
||||
} 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, 'expired');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, 'server_does_not_exist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function extractServerUuidFromMuxFile($muxFile)
|
||||
{
|
||||
return substr($muxFile, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and delete a stale mux socket file. When orphan reaping is disabled
|
||||
* (the default), the file is only logged — a dry-run mode that lets operators
|
||||
* verify what would be removed before enabling it for real.
|
||||
*/
|
||||
private function removeMultiplexFile(string $muxFile, string $reason): void
|
||||
{
|
||||
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
|
||||
Log::info('Stale mux file detected (dry-run, not removed)', [
|
||||
'file' => $muxFile,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
Log::info('Removed stale mux file', [
|
||||
'file' => $muxFile,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
|
||||
public string $commitSha,
|
||||
public ?string $authorAssociation,
|
||||
public string $fullName,
|
||||
public bool $isForkPullRequest = false,
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
@@ -92,7 +93,17 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ use App\Models\ApplicationPreview;
|
||||
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\Middleware\WithoutOverlapping;
|
||||
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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
$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 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
$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) {
|
||||
@@ -143,19 +184,24 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
&& (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;
|
||||
@@ -168,9 +214,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
});
|
||||
$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');
|
||||
@@ -284,6 +329,151 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
$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()) {
|
||||
@@ -291,7 +481,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
}
|
||||
|
||||
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
$application = $this->applicationsById->get((string) $applicationId);
|
||||
if (! $application) {
|
||||
continue;
|
||||
}
|
||||
@@ -312,8 +502,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
$application->status = $aggregatedStatus;
|
||||
$application->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -328,8 +516,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
$application->status = $aggregatedStatus;
|
||||
$application->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,7 +534,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
continue;
|
||||
}
|
||||
|
||||
$service = $this->services->where('id', $serviceId)->first();
|
||||
$service = $this->servicesById->get((string) $serviceId);
|
||||
if (! $service) {
|
||||
continue;
|
||||
}
|
||||
@@ -356,9 +542,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
// 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) {
|
||||
@@ -380,8 +566,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||
$subResource->status = $aggregatedStatus;
|
||||
$subResource->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$subResource->update(['last_online_at' => now()]);
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -397,39 +581,31 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,9 +653,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
$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);
|
||||
@@ -518,15 +692,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
|
||||
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) {
|
||||
@@ -561,7 +733,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
}
|
||||
|
||||
$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([
|
||||
|
||||
+291
-146
@@ -6,14 +6,15 @@ use App\Models\ScheduledDatabaseBackup;
|
||||
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.
|
||||
@@ -96,21 +99,11 @@ class ScheduledJobManager implements ShouldQueue
|
||||
'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(),
|
||||
]);
|
||||
@@ -141,125 +134,211 @@ class ScheduledJobManager implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -327,71 +406,70 @@ class ScheduledJobManager implements ShouldQueue
|
||||
|
||||
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
|
||||
@@ -418,4 +496,71 @@ class ScheduledJobManager implements ShouldQueue
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(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 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$startTime = Carbon::now();
|
||||
|
||||
try {
|
||||
$this->initializeExecutionContext();
|
||||
|
||||
$this->task_log = ScheduledTaskExecution::create([
|
||||
'scheduled_task_id' => $this->task->id,
|
||||
'started_at' => $startTime,
|
||||
@@ -107,8 +118,6 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// 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 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// 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 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Profile;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Appearance extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.appearance');
|
||||
}
|
||||
}
|
||||
@@ -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 @@ class Configuration extends Component
|
||||
$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]);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 @@ class General extends Component
|
||||
'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 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
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 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ class General extends Component
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 @@ class Configuration extends Component
|
||||
$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');
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@ namespace App\Livewire\Project\Database\Dragonfly;
|
||||
|
||||
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 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -98,10 +86,7 @@ class General extends Component
|
||||
'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 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
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 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ class General extends Component
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -241,67 +225,6 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,14 @@
|
||||
|
||||
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\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;
|
||||
|
||||
@@ -26,803 +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 === 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);
|
||||
}
|
||||
|
||||
$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() === 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() === StandaloneRedis::class ||
|
||||
$resource->getMorphClass() === StandaloneKeydb::class ||
|
||||
$resource->getMorphClass() === StandaloneDragonfly::class ||
|
||||
$resource->getMorphClass() === 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() === 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() === 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 === 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 {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case 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 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 StandaloneMongodb::class:
|
||||
case 'mongodb':
|
||||
$restoreCommand = $this->mongodbRestoreCommand;
|
||||
if ($this->dumpAll === false) {
|
||||
$restoreCommand .= "{$tmpPath}";
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$restoreCommand = '';
|
||||
}
|
||||
|
||||
return $restoreCommand;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,9 @@ namespace App\Livewire\Project\Database\Keydb;
|
||||
|
||||
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 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -88,7 +76,7 @@ class General extends Component
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
$baseRules = [
|
||||
return [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'keydbConf' => 'nullable|string',
|
||||
@@ -101,13 +89,8 @@ class General extends Component
|
||||
'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 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
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 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ class General extends Component
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -248,65 +230,6 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mariadb;
|
||||
|
||||
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 @@ class General extends Component
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'enableSsl' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -133,7 +110,6 @@ class General extends Component
|
||||
'publicPort' => 'Public Port',
|
||||
'publicPortTimeout' => 'Public Port Timeout',
|
||||
'customDockerRunOptions' => 'Custom Docker Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
@@ -147,12 +123,6 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -176,11 +146,7 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -270,6 +234,7 @@ class General extends Component
|
||||
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 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mongodb;
|
||||
|
||||
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 @@ class General extends Component
|
||||
'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 @@ class General extends Component
|
||||
'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 @@ class General extends Component
|
||||
'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 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -173,12 +138,7 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -271,6 +228,7 @@ class General extends Component
|
||||
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 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mysql;
|
||||
|
||||
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 @@ class General extends Component
|
||||
'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 @@ class General extends Component
|
||||
'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 @@ class General extends Component
|
||||
'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 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -181,12 +146,7 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -277,6 +234,7 @@ class General extends Component
|
||||
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 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Postgresql;
|
||||
|
||||
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 @@ class General extends Component
|
||||
'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 @@ class General extends Component
|
||||
'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 @@ class General extends Component
|
||||
'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 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -194,12 +162,7 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ class General extends Component
|
||||
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 @@ class General extends Component
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Redis;
|
||||
|
||||
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 @@ class General extends Component
|
||||
'redisPassword' => ValidationPatterns::databasePasswordRules(
|
||||
enforcePattern: $this->redisPassword !== $this->database->redis_password,
|
||||
),
|
||||
'enableSsl' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -122,7 +102,6 @@ class General extends Component
|
||||
'customDockerRunOptions' => 'Custom Docker Options',
|
||||
'redisUsername' => 'Redis Username',
|
||||
'redisPassword' => 'Redis Password',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
@@ -136,12 +115,6 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -161,11 +134,7 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
$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 @@ class General extends Component
|
||||
);
|
||||
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -259,6 +226,7 @@ class General extends Component
|
||||
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 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ class GithubPrivateRepository extends Component
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->query = request()->query();
|
||||
$this->repositories = $this->branches = collect();
|
||||
$this->github_apps = GithubApp::where('team_id', currentTeam()->id)
|
||||
$this->github_apps = GithubApp::ownedByCurrentTeam()
|
||||
->where('is_public', false)
|
||||
->whereNotNull('app_id')
|
||||
->get();
|
||||
@@ -106,7 +106,7 @@ class GithubPrivateRepository extends Component
|
||||
$this->total_branches_count = 0;
|
||||
$this->page = 1;
|
||||
$this->selected_github_app_id = $github_app_id;
|
||||
$this->github_app = GithubApp::where('team_id', currentTeam()->id)
|
||||
$this->github_app = GithubApp::ownedByCurrentTeam()
|
||||
->where('is_public', false)
|
||||
->whereNotNull('app_id')
|
||||
->findOrFail($github_app_id);
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Livewire\Project\Service;
|
||||
|
||||
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 @@ class Configuration extends Component
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -112,18 +112,22 @@ class Destination extends Component
|
||||
{
|
||||
try {
|
||||
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
|
||||
$network = StandaloneDocker::ownedByCurrentTeam()->findOrFail($network_id);
|
||||
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$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->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);
|
||||
}
|
||||
@@ -140,7 +144,7 @@ class Destination extends Component
|
||||
{
|
||||
try {
|
||||
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
|
||||
$network = StandaloneDocker::ownedByCurrentTeam()->findOrFail($network_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]);
|
||||
@@ -164,7 +168,9 @@ class Destination extends Component
|
||||
}
|
||||
$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'));
|
||||
|
||||
@@ -93,7 +93,9 @@ class Sentinel extends Component
|
||||
{
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,9 @@ class Show extends Component
|
||||
// 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 @@ class Show extends Component
|
||||
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();
|
||||
|
||||
@@ -11,6 +11,8 @@ class SettingsDropdown extends Component
|
||||
{
|
||||
public $showWhatsNewModal = false;
|
||||
|
||||
public string $trigger = 'preferences';
|
||||
|
||||
public function getUnreadCountProperty()
|
||||
{
|
||||
return Auth::user()->getUnreadChangelogCount();
|
||||
|
||||
@@ -21,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;
|
||||
@@ -76,6 +80,8 @@ class Change extends Component
|
||||
|
||||
public string $manifestState = '';
|
||||
|
||||
public string $activeTab = 'general';
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
@@ -95,6 +101,9 @@ class Change extends Component
|
||||
'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'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -263,10 +272,18 @@ class Change extends Component
|
||||
}
|
||||
}
|
||||
$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) {
|
||||
|
||||
@@ -1188,17 +1188,20 @@ class Application extends BaseModel
|
||||
$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
|
||||
|
||||
@@ -14,6 +14,10 @@ class S3Storage extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute;
|
||||
|
||||
private const CONNECTION_TIMEOUT_SECONDS = 15;
|
||||
|
||||
private const REQUEST_TIMEOUT_SECONDS = 15;
|
||||
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'name',
|
||||
@@ -158,6 +162,10 @@ class S3Storage extends BaseModel
|
||||
'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();
|
||||
@@ -165,11 +173,12 @@ class S3Storage extends BaseModel
|
||||
$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) {
|
||||
@@ -184,11 +193,25 @@ class S3Storage extends BaseModel
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
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 @@ class StandaloneClickhouse extends BaseModel
|
||||
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');
|
||||
|
||||
@@ -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 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
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 @@ class StandaloneDragonfly extends BaseModel
|
||||
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');
|
||||
|
||||
@@ -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 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
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 @@ class StandaloneKeydb extends BaseModel
|
||||
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');
|
||||
|
||||
@@ -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 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
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 @@ class StandaloneMariadb extends BaseModel
|
||||
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');
|
||||
|
||||
@@ -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 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
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 @@ class StandaloneMongodb extends BaseModel
|
||||
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');
|
||||
|
||||
@@ -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 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
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 @@ class StandaloneMysql extends BaseModel
|
||||
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');
|
||||
|
||||
@@ -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 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
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 @@ class StandalonePostgresql extends BaseModel
|
||||
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');
|
||||
|
||||
@@ -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 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
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 @@ class StandaloneRedis extends BaseModel
|
||||
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');
|
||||
|
||||
@@ -98,8 +98,24 @@ class User extends Authenticatable implements SendsEmail
|
||||
$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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ class ApplicationConfigurationSnapshot
|
||||
private function displayValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'Not set';
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
@@ -323,7 +323,7 @@ class ApplicationConfigurationSnapshot
|
||||
private function summarizeText(?string $value): string
|
||||
{
|
||||
if (blank($value)) {
|
||||
return 'Not set';
|
||||
return '-';
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
@@ -37,8 +37,8 @@ class ConfigurationDiffer
|
||||
'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', '-'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Generated
+19
-19
@@ -9667,16 +9667,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-idn",
|
||||
"version": "v1.37.0",
|
||||
"version": "v1.38.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-intl-idn.git",
|
||||
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
|
||||
"reference": "dc21118016c039a66235cf93d96b435ffb282412"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
|
||||
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412",
|
||||
"reference": "dc21118016c039a66235cf93d96b435ffb282412",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9730,7 +9730,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0"
|
||||
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -9750,20 +9750,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-10T14:38:51+00:00"
|
||||
"time": "2026-05-25T15:22:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-normalizer",
|
||||
"version": "v1.37.0",
|
||||
"version": "v1.38.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
|
||||
"reference": "3833d7255cc303546435cb650316bff708a1c75c"
|
||||
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
|
||||
"reference": "3833d7255cc303546435cb650316bff708a1c75c",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
|
||||
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9815,7 +9815,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
|
||||
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -9835,20 +9835,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-09T11:45:10+00:00"
|
||||
"time": "2026-05-25T13:48:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-mbstring",
|
||||
"version": "v1.37.0",
|
||||
"version": "v1.38.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||
"reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
|
||||
"reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
|
||||
"reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92",
|
||||
"reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9900,7 +9900,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -9920,7 +9920,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-10T17:25:58+00:00"
|
||||
"time": "2026-05-26T12:51:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php80",
|
||||
@@ -18072,5 +18072,5 @@
|
||||
"php": "^8.4"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
+6
-11
@@ -2,9 +2,9 @@
|
||||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.1.1',
|
||||
'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'),
|
||||
@@ -67,13 +67,6 @@ return [
|
||||
'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
|
||||
'mux_lock_ttl' => env('SSH_MUX_LOCK_TTL', 30), // lock auto-release, seconds
|
||||
'mux_lock_timeout' => env('SSH_MUX_LOCK_TIMEOUT', 10), // max wait for lock, seconds
|
||||
'mux_orphan_min_age' => env('SSH_MUX_ORPHAN_MIN_AGE', 600), // min process age before reaping orphans, seconds
|
||||
'mux_orphan_reap_enabled' => env('SSH_MUX_ORPHAN_REAP_ENABLED', false), // false = dry-run, only log orphans
|
||||
'connection_timeout' => 10,
|
||||
'server_interval' => 20,
|
||||
'command_timeout' => 3600,
|
||||
@@ -100,9 +93,11 @@ return [
|
||||
|
||||
'sentinel' => [
|
||||
// How often (seconds) PushServerUpdateJob is force-dispatched even when
|
||||
// the container state hash is unchanged. Keeps last_online_at,
|
||||
// exited-detection and storage checks from going stale.
|
||||
// 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' => [
|
||||
|
||||
+20
-2
@@ -3,6 +3,24 @@
|
||||
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'),
|
||||
@@ -28,13 +46,13 @@ $pgsql = [
|
||||
*/
|
||||
if (env('DB_READ_HOST')) {
|
||||
$pgsql['read'] = [
|
||||
'host' => array_map('trim', explode(',', (string) env('DB_READ_HOST'))),
|
||||
'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' => array_map('trim', explode(',', (string) env('DB_WRITE_HOST', env('DB_HOST', 'coolify-db')))),
|
||||
'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', '')),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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)');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private array $tables = [
|
||||
'standalone_postgresqls',
|
||||
'standalone_mysqls',
|
||||
'standalone_mariadbs',
|
||||
'standalone_redis',
|
||||
'standalone_clickhouses',
|
||||
'standalone_dragonflies',
|
||||
'standalone_keydbs',
|
||||
'standalone_mongodbs',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
foreach ($this->tables as $table) {
|
||||
Schema::table($table, function (Blueprint $table) {
|
||||
$table->boolean('health_check_enabled')->default(true);
|
||||
$table->integer('health_check_interval')->default(15);
|
||||
$table->integer('health_check_timeout')->default(5);
|
||||
$table->integer('health_check_retries')->default(5);
|
||||
$table->integer('health_check_start_period')->default(5);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
foreach ($this->tables as $table) {
|
||||
Schema::table($table, function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'health_check_enabled',
|
||||
'health_check_interval',
|
||||
'health_check_timeout',
|
||||
'health_check_retries',
|
||||
'health_check_start_period',
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -32,6 +32,16 @@ class ProductionSeeder extends Seeder
|
||||
echo " Running in self-hosted mode.\n";
|
||||
}
|
||||
|
||||
if (Team::find(0) === null) {
|
||||
(new Team)->forceFill([
|
||||
'id' => 0,
|
||||
'name' => 'Root Team',
|
||||
'description' => 'The root team',
|
||||
'personal_team' => true,
|
||||
'show_boarding' => true,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if (User::find(0) !== null && Team::find(0) !== null) {
|
||||
if (DB::table('team_user')->where('user_id', 0)->first() === null) {
|
||||
DB::table('team_user')->insert([
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -52,6 +53,12 @@ class RootUserSeeder extends Seeder
|
||||
'password' => Hash::make(env('ROOT_USER_PASSWORD')),
|
||||
]);
|
||||
$user->save();
|
||||
|
||||
$team = Team::find(0);
|
||||
if ($team !== null && ! $user->teams()->where('team_id', 0)->exists()) {
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
}
|
||||
|
||||
echo "\n SUCCESS Root user created successfully.\n\n";
|
||||
} catch (\Exception $e) {
|
||||
echo "\n ERROR Failed to create root user: {$e->getMessage()}\n\n";
|
||||
|
||||
@@ -129,7 +129,7 @@ services:
|
||||
networks:
|
||||
- coolify
|
||||
minio:
|
||||
image: ghcr.io/coollabsio/maxio:latest
|
||||
image: coollabsio/maxio:latest
|
||||
pull_policy: always
|
||||
container_name: coolify-minio
|
||||
ports:
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@
|
||||
"cookie": "1.1.1",
|
||||
"dotenv": "17.3.1",
|
||||
"node-pty": "1.1.0",
|
||||
"ws": "8.19.0"
|
||||
"ws": "8.20.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
@@ -70,9 +70,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"cookie": "1.1.1",
|
||||
"dotenv": "17.3.1",
|
||||
"node-pty": "1.1.0",
|
||||
"ws": "8.19.0"
|
||||
"ws": "8.20.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,91 @@
|
||||
#!/bin/sh
|
||||
# Function to timestamp logs
|
||||
|
||||
# Check if the first argument is 'watch'
|
||||
if [ "$1" = "watch" ]; then
|
||||
WATCH_MODE="--watch"
|
||||
else
|
||||
WATCH_MODE=""
|
||||
fi
|
||||
|
||||
timestamp() {
|
||||
date "+%Y-%m-%d %H:%M:%S"
|
||||
log() {
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') [ENTRYPOINT] $*"
|
||||
}
|
||||
|
||||
# Start the terminal server in the background with logging
|
||||
node $WATCH_MODE /terminal/terminal-server.js > >(while read line; do echo "$(timestamp) [TERMINAL] $line"; done) 2>&1 &
|
||||
start_logger() {
|
||||
prefix="$1"
|
||||
fifo_path="$2"
|
||||
|
||||
while read -r line; do
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') [$prefix] $line"
|
||||
done < "$fifo_path" &
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
rm -f "$TERMINAL_LOG_FIFO" "$SOKETI_LOG_FIFO"
|
||||
}
|
||||
|
||||
TERMINAL_LOG_FIFO="/tmp/coolify-terminal-log.$$"
|
||||
SOKETI_LOG_FIFO="/tmp/coolify-soketi-log.$$"
|
||||
|
||||
rm -f "$TERMINAL_LOG_FIFO" "$SOKETI_LOG_FIFO"
|
||||
mkfifo "$TERMINAL_LOG_FIFO" "$SOKETI_LOG_FIFO"
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
log "Starting realtime container"
|
||||
log "WATCH_MODE=${WATCH_MODE:-off}"
|
||||
log "SOKETI_DEBUG=${SOKETI_DEBUG:-unset}"
|
||||
log "NODE_OPTIONS=${NODE_OPTIONS:-unset}"
|
||||
|
||||
start_logger "TERMINAL" "$TERMINAL_LOG_FIFO"
|
||||
TERMINAL_LOGGER_PID=$!
|
||||
|
||||
start_logger "SOKETI" "$SOKETI_LOG_FIFO"
|
||||
SOKETI_LOGGER_PID=$!
|
||||
|
||||
node $WATCH_MODE /terminal/terminal-server.js > "$TERMINAL_LOG_FIFO" 2>&1 &
|
||||
TERMINAL_PID=$!
|
||||
|
||||
# Start the Soketi process in the background with logging
|
||||
node /app/bin/server.js start > >(while read line; do echo "$(timestamp) [SOKETI] $line"; done) 2>&1 &
|
||||
log "Terminal server started pid=$TERMINAL_PID logger_pid=$TERMINAL_LOGGER_PID"
|
||||
|
||||
node /app/bin/server.js start > "$SOKETI_LOG_FIFO" 2>&1 &
|
||||
SOKETI_PID=$!
|
||||
|
||||
# Function to forward signals to child processes
|
||||
log "Soketi started pid=$SOKETI_PID logger_pid=$SOKETI_LOGGER_PID"
|
||||
|
||||
forward_signal() {
|
||||
kill -$1 $TERMINAL_PID $SOKETI_PID
|
||||
log "Forwarding signal $1 to terminal=$TERMINAL_PID soketi=$SOKETI_PID"
|
||||
|
||||
kill -"$1" "$TERMINAL_PID" 2>/dev/null || true
|
||||
kill -"$1" "$SOKETI_PID" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Forward SIGTERM to child processes
|
||||
trap 'forward_signal TERM' TERM
|
||||
trap 'forward_signal INT' INT
|
||||
|
||||
# Wait for any process to exit
|
||||
wait -n
|
||||
while true; do
|
||||
if ! kill -0 "$TERMINAL_PID" 2>/dev/null; then
|
||||
wait "$TERMINAL_PID"
|
||||
EXIT_CODE=$?
|
||||
|
||||
# Exit with status of process that exited first
|
||||
exit $?
|
||||
log "Terminal server exited code=$EXIT_CODE; stopping soketi pid=$SOKETI_PID"
|
||||
|
||||
kill "$SOKETI_PID" 2>/dev/null || true
|
||||
wait "$SOKETI_PID" 2>/dev/null || true
|
||||
|
||||
exit "$EXIT_CODE"
|
||||
fi
|
||||
|
||||
if ! kill -0 "$SOKETI_PID" 2>/dev/null; then
|
||||
wait "$SOKETI_PID"
|
||||
EXIT_CODE=$?
|
||||
|
||||
log "Soketi exited code=$EXIT_CODE; stopping terminal pid=$TERMINAL_PID"
|
||||
|
||||
kill "$TERMINAL_PID" 2>/dev/null || true
|
||||
wait "$TERMINAL_PID" 2>/dev/null || true
|
||||
|
||||
exit "$EXIT_CODE"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
@@ -4605,6 +4605,35 @@
|
||||
"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
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -2950,6 +2950,30 @@ paths:
|
||||
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
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.1.1"
|
||||
"version": "4.1.2"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.2.0"
|
||||
@@ -10,7 +10,7 @@
|
||||
"version": "1.0.14"
|
||||
},
|
||||
"realtime": {
|
||||
"version": "1.0.15"
|
||||
"version": "1.0.16"
|
||||
},
|
||||
"sentinel": {
|
||||
"version": "0.0.21"
|
||||
|
||||
Generated
+8
-8
@@ -15,7 +15,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"laravel-vite-plugin": "2.0.1",
|
||||
"postcss": "8.5.6",
|
||||
"postcss": "8.5.15",
|
||||
"tailwind-scrollbar": "4.0.2",
|
||||
"tailwindcss": "4.1.18",
|
||||
"vite": "7.3.2"
|
||||
@@ -1720,9 +1720,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1803,9 +1803,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1823,7 +1823,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"laravel-vite-plugin": "2.0.1",
|
||||
"postcss": "8.5.6",
|
||||
"postcss": "8.5.15",
|
||||
"tailwind-scrollbar": "4.0.2",
|
||||
"tailwindcss": "4.1.18",
|
||||
"vite": "7.3.2"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 116" preserveAspectRatio="xMidYMid meet">
|
||||
<path fill="#FFF" d="m202.357 49.394-5.311-2.124C172.085 103.434 72.786 69.289 66.81 85.997c-.996 11.286 54.227 2.146 93.706 4.059 12.039.583 18.076 9.671 12.964 24.484l10.069.031c11.615-36.209 48.683-17.73 50.232-29.68-2.545-7.857-42.601 0-31.425-35.497Z"/>
|
||||
<path fill="#F4811F" d="M176.332 108.348c1.593-5.31 1.062-10.622-1.593-13.809-2.656-3.187-6.374-5.31-11.154-5.842L71.17 87.634c-.531 0-1.062-.53-1.593-.53-.531-.532-.531-1.063 0-1.594.531-1.062 1.062-1.594 2.124-1.594l92.946-1.062c11.154-.53 22.839-9.56 27.087-20.182l5.312-13.809c0-.532.531-1.063 0-1.594C191.203 20.182 166.772 0 138.091 0 111.535 0 88.697 16.995 80.73 40.896c-5.311-3.718-11.684-5.843-19.12-5.31-12.747 1.061-22.838 11.683-24.432 24.43-.531 3.187 0 6.374.532 9.56C16.996 70.107 0 87.103 0 108.348c0 2.124 0 3.718.531 5.842 0 1.063 1.062 1.594 1.594 1.594h170.489c1.062 0 2.125-.53 2.125-1.594l1.593-5.842Z"/>
|
||||
<path fill="#FAAD3F" d="M205.544 48.863h-2.656c-.531 0-1.062.53-1.593 1.062l-3.718 12.747c-1.593 5.31-1.062 10.623 1.594 13.809 2.655 3.187 6.373 5.31 11.153 5.843l19.652 1.062c.53 0 1.062.53 1.593.53.53.532.53 1.063 0 1.594-.531 1.063-1.062 1.594-2.125 1.594l-20.182 1.062c-11.154.53-22.838 9.56-27.087 20.182l-1.063 4.78c-.531.532 0 1.594 1.063 1.594h70.108c1.062 0 1.593-.531 1.593-1.593 1.062-4.25 2.124-9.03 2.124-13.81 0-27.618-22.838-50.456-50.456-50.456"/>
|
||||
<text x="128" y="65" text-anchor="middle" dominant-baseline="middle" font-size="32" font-family="Arial, Helvetica, sans-serif" font-weight="bold" fill="#fff">
|
||||
DDNS
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,39 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0.91 0.87 497.09 497.26">
|
||||
<path d="M411.491 249.489C411.491 254.498 411.243 259.461 410.814 264.38H318.347L311.288 250.053L261.062 350.343L219.497 230.446L191.171 324.058L153.304 264.38H88.0809C87.6524 259.461 87.4043 254.498 87.4043 249.489C87.4043 244.48 87.6524 239.493 88.0809 234.575H169.745L181.563 253.211L217.693 133.991L266.069 273.879L311.513 183.065L336.773 234.575H410.746C411.197 239.493 411.491 244.547 411.491 249.489Z" fill="#444444"/>
|
||||
<path d="M277.276 40.9205C277.132 49.4665 274.393 57.7665 269.422 64.7181C264.451 71.6697 257.484 76.9441 249.446 79.8408C227.172 79.8408 205.116 84.2302 184.538 92.7583C163.96 101.286 145.263 113.786 129.514 129.544C113.765 145.302 101.273 164.009 92.751 184.597C84.2292 205.184 79.8446 227.25 79.8476 249.533C76.9636 241.367 71.6193 234.295 64.5508 229.294C57.4824 224.292 49.0376 221.607 40.3798 221.607C31.7221 221.607 23.2773 224.292 16.2089 229.294C9.14043 234.295 3.79611 241.367 0.912119 249.533C0.90323 216.878 7.32551 184.541 19.8121 154.369C32.2987 124.198 50.6048 96.7832 73.6848 73.6914C96.7648 50.5996 124.166 32.2832 154.324 19.7887C184.482 7.29408 216.805 0.866146 249.446 0.872074C257.664 3.83466 264.758 9.28105 269.745 16.4566C274.731 23.6322 277.363 32.1817 277.276 40.9205Z" fill="url(#paint0_linear_430_3947)"/>
|
||||
<path d="M497.934 249.488C495.05 257.655 489.706 264.726 482.637 269.728C475.569 274.729 467.124 277.415 458.466 277.415C449.809 277.415 441.364 274.729 434.295 269.728C427.227 264.726 421.883 257.655 418.999 249.488C418.987 204.503 401.12 161.362 369.326 129.551C337.532 97.7388 294.412 79.8588 249.445 79.8408C257.609 76.9556 264.677 71.609 269.676 64.5376C274.676 57.4662 277.36 49.0179 277.36 40.3564C277.36 31.695 274.676 23.2467 269.676 16.1753C264.677 9.10385 257.609 3.75728 249.445 0.87207C282.079 0.87207 314.394 7.30288 344.543 19.7973C374.693 32.2917 402.087 50.605 425.161 73.6914C448.236 96.7779 466.539 124.185 479.026 154.349C491.512 184.512 497.937 216.841 497.934 249.488Z" fill="url(#paint1_linear_430_3947)"/>
|
||||
<path d="M497.933 249.488C497.936 282.134 491.511 314.462 479.024 344.624C466.537 374.786 448.234 402.192 425.159 425.276C402.084 448.361 374.69 466.672 344.54 479.164C314.391 491.656 282.077 498.084 249.444 498.081C241.281 495.196 234.213 489.849 229.213 482.778C224.214 475.707 221.529 467.258 221.529 458.597C221.529 449.935 224.214 441.487 229.213 434.416C234.213 427.344 241.281 421.998 249.444 419.112C271.718 419.115 293.774 414.729 314.354 406.203C334.933 397.678 353.632 385.181 369.383 369.425C385.134 353.67 397.629 334.964 406.153 314.377C414.678 293.791 419.065 271.726 419.065 249.442C421.949 257.609 427.293 264.68 434.362 269.682C441.43 274.683 449.875 277.369 458.533 277.369C467.191 277.369 475.635 274.683 482.704 269.682C489.772 264.68 495.117 257.609 498.001 249.442L497.933 249.488Z" fill="url(#paint2_linear_430_3947)"/>
|
||||
<path d="M249.446 498.083C183.542 498.083 120.338 471.892 73.7377 425.271C27.137 378.651 0.957031 315.42 0.957031 249.489C3.84102 241.322 9.18534 234.251 16.2538 229.25C23.3222 224.248 31.767 221.562 40.4247 221.562C49.0825 221.562 57.5273 224.248 64.5957 229.25C71.6642 234.251 77.0085 241.322 79.8925 249.489C79.8925 294.488 97.7608 337.645 129.567 369.464C145.315 385.219 164.012 397.717 184.588 406.244C205.165 414.771 227.219 419.159 249.491 419.159C241.328 422.044 234.259 427.391 229.26 434.462C224.261 441.534 221.576 449.982 221.576 458.644C221.576 467.305 224.261 475.753 229.26 482.825C234.259 489.896 241.328 495.243 249.491 498.128L249.446 498.083Z" fill="url(#paint3_linear_430_3947)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_430_3947" x1="0.957225" y1="125.169" x2="277.276" y2="125.169" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D1C6E0"/>
|
||||
<stop offset="0.35" stop-color="#9274B2"/>
|
||||
<stop offset="0.71" stop-color="#652F8D"/>
|
||||
<stop offset="1" stop-color="#2B1143"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_430_3947" x1="249.445" y1="139.09" x2="497.934" y2="139.09" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.01" stop-color="#82C2DF"/>
|
||||
<stop offset="0.3" stop-color="#3CAFD9"/>
|
||||
<stop offset="0.59" stop-color="#0379B8"/>
|
||||
<stop offset="0.7" stop-color="#006DAC"/>
|
||||
<stop offset="0.73" stop-color="#006AA9"/>
|
||||
<stop offset="0.91" stop-color="#334C84"/>
|
||||
<stop offset="1" stop-color="#3A447A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_430_3947" x1="240.4" y1="466.797" x2="581.559" y2="212.975" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0C6237"/>
|
||||
<stop offset="0.406444" stop-color="#31BB77"/>
|
||||
<stop offset="0.752196" stop-color="#5BC792"/>
|
||||
<stop offset="0.98" stop-color="#B1E9CD"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_430_3947" x1="0.957031" y1="359.865" x2="249.446" y2="359.865" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#931B1E"/>
|
||||
<stop offset="0.32" stop-color="#ED4725"/>
|
||||
<stop offset="0.38" stop-color="#EE5F26"/>
|
||||
<stop offset="0.53" stop-color="#F08F27"/>
|
||||
<stop offset="0.74" stop-color="#F5C321"/>
|
||||
<stop offset="0.88" stop-color="#F8D718"/>
|
||||
<stop offset="1" stop-color="#F6DF65"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,94 @@
|
||||
@props([
|
||||
'database',
|
||||
'label',
|
||||
'dbUrl' => null,
|
||||
'dbUrlPublic' => null,
|
||||
'supportsSsl' => true,
|
||||
'enableSsl' => false,
|
||||
'sslMode' => null,
|
||||
'sslModeOptions' => null,
|
||||
'sslModeHelper' => null,
|
||||
'certificateValidUntil' => null,
|
||||
'isExited' => false,
|
||||
'showPublicUrlPlaceholder' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$urlHelper = 'If you change the user/password/port, this could be different. This is with the default values.';
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input :label="$label . ' URL (internal)'" :helper="$urlHelper" type="password" readonly
|
||||
wire:model="dbUrl" canGate="update" :canResource="$database" />
|
||||
@if ($dbUrlPublic)
|
||||
<x-forms.input :label="$label . ' URL (public)'" :helper="$urlHelper" type="password" readonly
|
||||
wire:model="dbUrlPublic" canGate="update" :canResource="$database" />
|
||||
@elseif ($showPublicUrlPlaceholder)
|
||||
<x-forms.input :label="$label . ' URL (public)'" :helper="$urlHelper" readonly
|
||||
value="Starting the database will generate this." canGate="update" :canResource="$database" />
|
||||
@endif
|
||||
|
||||
@if ($supportsSsl)
|
||||
<div class="flex flex-col gap-2 pt-4">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<h3>SSL Configuration</h3>
|
||||
@if ($enableSsl && $certificateValidUntil)
|
||||
<x-modal-confirmation title="Regenerate SSL Certificates"
|
||||
buttonTitle="Regenerate SSL Certificates" :actions="[
|
||||
'The SSL certificate of this database will be regenerated.',
|
||||
'You must restart the database after regenerating the certificate to start using the new certificate.',
|
||||
]"
|
||||
submitAction="regenerateSslCertificate" :confirmWithText="false" :confirmWithPassword="false" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if ($enableSsl && $certificateValidUntil)
|
||||
<span class="text-sm">Valid until:
|
||||
@if (now()->gt($certificateValidUntil))
|
||||
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
|
||||
@elseif(now()->addDays(30)->gt($certificateValidUntil))
|
||||
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
|
||||
soon</span>
|
||||
@else
|
||||
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="w-64" wire:key="enable_ssl">
|
||||
@if ($isExited)
|
||||
<x-forms.checkbox id="enableSsl" label="Enable SSL" wire:model.live="enableSsl"
|
||||
instantSave="instantSaveSSL" canGate="update" :canResource="$database" />
|
||||
@else
|
||||
<x-forms.checkbox id="enableSsl" label="Enable SSL" wire:model.live="enableSsl"
|
||||
instantSave="instantSaveSSL" disabled
|
||||
helper="Database should be stopped to change this setting." canGate="update"
|
||||
:canResource="$database" />
|
||||
@endif
|
||||
</div>
|
||||
@if ($sslModeOptions && $enableSsl)
|
||||
<div class="mx-2">
|
||||
@if ($isExited)
|
||||
<x-forms.select id="sslMode" label="SSL Mode" wire:model.live="sslMode"
|
||||
instantSave="instantSaveSSL" :helper="$sslModeHelper" canGate="update"
|
||||
:canResource="$database">
|
||||
@foreach ($sslModeOptions as $value => $option)
|
||||
<option value="{{ $value }}" title="{{ $option['title'] ?? '' }}">{{ $option['label'] }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
@else
|
||||
<x-forms.select id="sslMode" label="SSL Mode" instantSave="instantSaveSSL" disabled
|
||||
helper="Database should be stopped to change this setting." canGate="update"
|
||||
:canResource="$database">
|
||||
@foreach ($sslModeOptions as $value => $option)
|
||||
<option value="{{ $value }}" title="{{ $option['title'] ?? '' }}">{{ $option['label'] }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -4,9 +4,9 @@
|
||||
])
|
||||
|
||||
@php
|
||||
$changes = data_get($diff, 'changes', []);
|
||||
$count = data_get($diff, 'count', count($changes));
|
||||
$requiresBuild = data_get($diff, 'requires_build', false);
|
||||
$changes = collect(data_get($diff, 'changes', []))->filter(fn ($change) => data_get($change, 'key') !== 'domains.custom_labels')->values()->all();
|
||||
$count = count($changes);
|
||||
$requiresBuild = collect($changes)->contains(fn ($change) => data_get($change, 'impact') === 'build');
|
||||
@endphp
|
||||
|
||||
@if ($count > 0)
|
||||
@@ -21,45 +21,39 @@
|
||||
'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300' => $requiresBuild,
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => ! $requiresBuild,
|
||||
])>
|
||||
{{ $requiresBuild ? 'Rebuild' : 'Redeploy' }}
|
||||
{{ $requiresBuild ? 'Rebuild required' : 'Redeploy required' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@unless ($compact)
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-4">
|
||||
@foreach (collect($changes)->groupBy('section_label') as $sectionLabel => $sectionChanges)
|
||||
<div>
|
||||
<div class="mb-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-neutral-600 dark:text-neutral-400">
|
||||
{{ $sectionLabel }}
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-sm border border-neutral-300 dark:border-coolgray-200">
|
||||
<div class="min-w-[44rem]">
|
||||
<div class="grid grid-cols-[minmax(12rem,1.4fr)_7rem_minmax(8rem,1fr)_1.5rem_minmax(8rem,1fr)] items-center gap-2 bg-neutral-100 px-3 py-1.5 text-[0.65rem] font-semibold uppercase tracking-wide text-neutral-500 dark:bg-coolgray-200 dark:text-neutral-400">
|
||||
<div>Field</div>
|
||||
<div>Type</div>
|
||||
<div>From</div>
|
||||
<div></div>
|
||||
<div>To</div>
|
||||
</div>
|
||||
<div class="divide-y divide-neutral-300 dark:divide-coolgray-200">
|
||||
@foreach ($sectionChanges as $change)
|
||||
<div class="grid grid-cols-[minmax(12rem,1.4fr)_7rem_minmax(8rem,1fr)_1.5rem_minmax(8rem,1fr)] items-center gap-2 px-3 py-1.5 text-neutral-700 dark:text-neutral-300">
|
||||
<div class="truncate font-medium text-black dark:text-white" title="{{ data_get($change, 'label') }}">
|
||||
{{ data_get($change, 'label') }}
|
||||
</div>
|
||||
<div class="text-neutral-500 dark:text-neutral-400">
|
||||
{{ data_get($change, 'type') }}
|
||||
</div>
|
||||
<div class="truncate" title="{{ data_get($change, 'old_display_value') }}">
|
||||
{{ data_get($change, 'old_display_value') }}
|
||||
</div>
|
||||
<div class="text-center text-neutral-500 dark:text-neutral-400">→</div>
|
||||
<div class="truncate" title="{{ data_get($change, 'new_display_value') }}">
|
||||
{{ data_get($change, 'new_display_value') }}
|
||||
</div>
|
||||
<div class="rounded-sm border border-neutral-300 dark:border-coolgray-200">
|
||||
<div class="grid grid-cols-[12rem_1fr_1.5rem_1fr] items-center gap-2 bg-neutral-100 px-3 py-1.5 text-[0.65rem] font-semibold uppercase tracking-wide text-neutral-500 dark:bg-coolgray-200 dark:text-neutral-400">
|
||||
<div>Field</div>
|
||||
<div>From</div>
|
||||
<div></div>
|
||||
<div>To</div>
|
||||
</div>
|
||||
<div class="divide-y divide-neutral-300 dark:divide-coolgray-200">
|
||||
@foreach ($sectionChanges as $change)
|
||||
<div class="grid grid-cols-[12rem_1fr_1.5rem_1fr] items-start gap-2 px-3 py-1.5 text-neutral-700 dark:text-neutral-300">
|
||||
<div class="shrink-0 font-medium text-black dark:text-white">
|
||||
{{ data_get($change, 'label') }}
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="truncate text-red-700 dark:text-red-400/80" title="{{ data_get($change, 'old_display_value') }}">
|
||||
{{ data_get($change, 'old_display_value') }}
|
||||
</div>
|
||||
<div class="text-center text-neutral-500 dark:text-neutral-400">→</div>
|
||||
<div class="truncate text-green-700 dark:text-green-500" title="{{ data_get($change, 'new_display_value') }}">
|
||||
{{ data_get($change, 'new_display_value') }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,23 +49,32 @@
|
||||
localStorage.setItem('theme', type);
|
||||
this.queryTheme();
|
||||
},
|
||||
cycleTheme() {
|
||||
const themes = ['light', 'system', 'dark'];
|
||||
const currentIndex = themes.indexOf(this.theme || localStorage.getItem('theme') || 'dark');
|
||||
this.setTheme(themes[(currentIndex + 1) % themes.length]);
|
||||
},
|
||||
queryTheme() {
|
||||
const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const userSettings = localStorage.getItem('theme') || 'dark';
|
||||
localStorage.setItem('theme', userSettings);
|
||||
let isDark = false;
|
||||
if (userSettings === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
this.theme = 'dark';
|
||||
isDark = true;
|
||||
} else if (userSettings === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
this.theme = 'light';
|
||||
} else if (darkModePreference) {
|
||||
this.theme = 'system';
|
||||
document.documentElement.classList.add('dark');
|
||||
isDark = true;
|
||||
} else if (!darkModePreference) {
|
||||
this.theme = 'system';
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
|
||||
},
|
||||
checkZoom() {
|
||||
if (this.zoom === null) {
|
||||
@@ -92,9 +101,9 @@
|
||||
}
|
||||
}
|
||||
}">
|
||||
<div class="flex pt-4 pb-4 pl-2 items-start gap-2 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out motion-reduce:transition-none"
|
||||
:class="collapsed ? 'lg:flex-col lg:items-center lg:pl-0 lg:gap-3 lg:pt-7' : 'lg:pt-6'">
|
||||
<div class="flex flex-col w-full" :class="collapsed && 'lg:hidden'">
|
||||
<div class="flex pt-4 pb-4 pl-2 pr-3 items-start gap-3 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out motion-reduce:transition-none"
|
||||
:class="collapsed ? 'lg:flex-col lg:items-center lg:pl-0 lg:pr-0 lg:gap-3 lg:pt-7' : 'lg:pt-6'">
|
||||
<div class="flex min-w-0 flex-1 flex-col" :class="collapsed && 'lg:hidden'">
|
||||
<a href="/" {{ wireNavigate() }} class="text-2xl font-bold tracking-tight dark:text-white hover:opacity-80 transition-opacity">Coolify</a>
|
||||
<x-version />
|
||||
</div>
|
||||
@@ -107,10 +116,10 @@
|
||||
</a>
|
||||
<x-version class="text-[10px]" />
|
||||
</div>
|
||||
<div :class="collapsed && 'lg:hidden'">
|
||||
<div class="min-w-0 flex-1" :class="collapsed && 'lg:hidden'">
|
||||
<!-- Search button that triggers global search modal -->
|
||||
<button @click="$dispatch('open-global-search')" type="button" title="Search (Press / or ⌘K)"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 bg-neutral-100 dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-200 rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors">
|
||||
class="flex h-8 w-full items-center justify-between gap-1.5 px-2.5 py-1.5 bg-neutral-100 dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-200 rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-neutral-500 dark:text-neutral-400"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
@@ -120,9 +129,6 @@
|
||||
class="px-1 py-0.5 text-xs font-semibold text-neutral-500 dark:text-neutral-400 bg-neutral-200 dark:bg-coolgray-200 rounded">/</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="collapsed && 'lg:hidden'">
|
||||
<livewire:settings-dropdown />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 pt-2 pb-7 overflow-hidden motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out motion-reduce:transition-none" :class="collapsed && 'lg:px-0 lg:pt-0 lg:pb-4 lg:min-h-8 lg:flex lg:justify-center'">
|
||||
<livewire:switch-team />
|
||||
@@ -366,9 +372,73 @@
|
||||
@endif
|
||||
@endif
|
||||
<div class="flex-1"></div>
|
||||
<li>
|
||||
<livewire:settings-dropdown trigger="changelog-sidebar" />
|
||||
</li>
|
||||
<li>
|
||||
<div class="menu-item" title="Theme" aria-label="Theme switcher" :class="collapsed && 'lg:hidden'">
|
||||
<svg x-show="theme === 'dark'" class="menu-item-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
<svg x-show="theme === 'light'" class="menu-item-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<svg x-show="theme === 'system'" class="menu-item-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Theme</span>
|
||||
<div class="ml-auto flex items-center gap-0.5 rounded-sm bg-neutral-100 p-0.5 dark:bg-coolgray-200">
|
||||
<button type="button" @click.stop="setTheme('light')" title="Light" aria-label="Use light theme"
|
||||
class="grid size-6 place-items-center rounded-sm text-xs hover:bg-white hover:text-coollabs dark:hover:bg-base dark:hover:text-warning"
|
||||
:class="theme === 'light' && 'bg-white text-coollabs shadow-sm dark:bg-base dark:text-warning'">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" @click.stop="setTheme('system')" title="System default" aria-label="Use system theme"
|
||||
class="grid size-6 place-items-center rounded-sm text-xs hover:bg-white hover:text-coollabs dark:hover:bg-base dark:hover:text-warning"
|
||||
:class="theme === 'system' && 'bg-white text-coollabs shadow-sm dark:bg-base dark:text-warning'">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" @click.stop="setTheme('dark')" title="Dark" aria-label="Use dark theme"
|
||||
class="grid size-6 place-items-center rounded-sm text-xs hover:bg-white hover:text-coollabs dark:hover:bg-base dark:hover:text-warning"
|
||||
:class="theme === 'dark' && 'bg-white text-coollabs shadow-sm dark:bg-base dark:text-warning'">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" @click.stop="cycleTheme()"
|
||||
:title="`Theme: ${theme === 'system' ? 'System default' : theme}. Click to change.`"
|
||||
:aria-label="`Theme: ${theme === 'system' ? 'System default' : theme}. Click to change theme.`"
|
||||
class="menu-item hidden"
|
||||
:class="collapsed && 'lg:flex'">
|
||||
<svg x-show="theme === 'dark'" class="menu-item-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
<svg x-show="theme === 'light'" class="menu-item-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<svg x-show="theme === 'system'" class="menu-item-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
@if (isInstanceAdmin() && !isCloud())
|
||||
@persist('upgrade')
|
||||
<li :class="collapsed && 'lg:hidden'">
|
||||
<li>
|
||||
<livewire:upgrade />
|
||||
</li>
|
||||
@endpersist
|
||||
@@ -420,7 +490,7 @@
|
||||
<li>
|
||||
<form action="/logout" method="POST">
|
||||
@csrf
|
||||
<button title="Logout" type="submit" class="gap-2 mb-6 menu-item">
|
||||
<button title="Logout" type="submit" class="mb-6 menu-item">
|
||||
<svg class="menu-item-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor"
|
||||
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2a9.985 9.985 0 0 1 8 4h-2.71a8 8 0 1 0 .001 12h2.71A9.985 9.985 0 0 1 12 22m7-6v-3h-8v-2h8V8l5 4z" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<h1>Notifications</h1>
|
||||
<div class="subtitle">Get notified about your infrastructure.</div>
|
||||
<div class="navbar-main">
|
||||
<nav class="flex items-center gap-3.5 min-h-10">
|
||||
<nav class="flex items-center gap-6 min-h-10 whitespace-nowrap">
|
||||
<a class="{{ request()->routeIs('notifications.email') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('notifications.email') }}">
|
||||
<button>Email</button>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="pb-6">
|
||||
<h1>Profile</h1>
|
||||
<div class="subtitle">Your user profile settings.</div>
|
||||
<div class="navbar-main">
|
||||
<nav class="flex items-center gap-6 min-h-10">
|
||||
<a class="{{ request()->routeIs('profile') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('profile') }}">
|
||||
General
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('profile.appearance') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('profile.appearance') }}">
|
||||
Appearance
|
||||
</a>
|
||||
<div class="flex-1"></div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@
|
||||
{{ $destination->name }}
|
||||
<x-deprecated-badge />
|
||||
</div>
|
||||
<div class="box-description">server: {{ $destination->server->name }}</div>
|
||||
<div class="box-description">Server: {{ $destination->server->name }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
Appearance | Coolify
|
||||
</x-slot>
|
||||
<x-profile.navbar />
|
||||
|
||||
<div x-data="{
|
||||
theme: localStorage.getItem('theme') || 'dark',
|
||||
pageWidth: localStorage.getItem('pageWidth') || 'full',
|
||||
zoom: localStorage.getItem('zoom') || '100',
|
||||
init() {
|
||||
localStorage.setItem('theme', this.theme);
|
||||
localStorage.setItem('pageWidth', this.pageWidth);
|
||||
localStorage.setItem('zoom', this.zoom);
|
||||
this.applyTheme();
|
||||
},
|
||||
setTheme(type) {
|
||||
this.theme = type;
|
||||
localStorage.setItem('theme', type);
|
||||
this.applyTheme();
|
||||
},
|
||||
applyTheme() {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDark = this.theme === 'dark' || (this.theme === 'system' && prefersDark);
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
|
||||
},
|
||||
setWidth(width) {
|
||||
this.pageWidth = width;
|
||||
localStorage.setItem('pageWidth', width);
|
||||
window.location.reload();
|
||||
},
|
||||
setZoom(value) {
|
||||
this.zoom = value;
|
||||
localStorage.setItem('zoom', value);
|
||||
window.location.reload();
|
||||
},
|
||||
}" class="flex max-w-2xl flex-col">
|
||||
<section class="space-y-1.5">
|
||||
<h2>Appearance</h2>
|
||||
<div>Choose how Coolify looks in this browser.</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button type="button" @click="setTheme('light')" aria-label="Use light theme"
|
||||
class="flex items-center gap-2 rounded-sm border border-neutral-300 bg-white px-2 py-1 text-left text-sm hover:bg-neutral-100 focus-visible:ring-2 focus-visible:ring-coollabs dark:border-coolgray-300 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 dark:focus-visible:ring-warning"
|
||||
:class="theme === 'light' && 'border-coollabs text-coollabs dark:border-warning dark:text-warning'">
|
||||
<svg class="size-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<span>Light</span>
|
||||
</button>
|
||||
<button type="button" @click="setTheme('system')" aria-label="Use system theme"
|
||||
class="flex items-center gap-2 rounded-sm border border-neutral-300 bg-white px-2 py-1 text-left text-sm hover:bg-neutral-100 focus-visible:ring-2 focus-visible:ring-coollabs dark:border-coolgray-300 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 dark:focus-visible:ring-warning"
|
||||
:class="theme === 'system' && 'border-coollabs text-coollabs dark:border-warning dark:text-warning'">
|
||||
<svg class="size-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>System</span>
|
||||
</button>
|
||||
<button type="button" @click="setTheme('dark')" aria-label="Use dark theme"
|
||||
class="flex items-center gap-2 rounded-sm border border-neutral-300 bg-white px-2 py-1 text-left text-sm hover:bg-neutral-100 focus-visible:ring-2 focus-visible:ring-coollabs dark:border-coolgray-300 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 dark:focus-visible:ring-warning"
|
||||
:class="theme === 'dark' && 'border-coollabs text-coollabs dark:border-warning dark:text-warning'">
|
||||
<svg class="size-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
<span>Dark</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1.5">
|
||||
<h2>Width</h2>
|
||||
<div>Choose the maximum page width for this browser.</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button type="button" @click="setWidth('center')" aria-label="Use centered width"
|
||||
class="flex items-center gap-2 rounded-sm border border-neutral-300 bg-white px-2 py-1 text-sm hover:bg-neutral-100 focus-visible:ring-2 focus-visible:ring-coollabs dark:border-coolgray-300 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 dark:focus-visible:ring-warning"
|
||||
:class="pageWidth === 'center' && 'border-coollabs text-coollabs dark:border-warning dark:text-warning'">
|
||||
<svg class="size-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
Center
|
||||
</button>
|
||||
<button type="button" @click="setWidth('full')" aria-label="Use full width"
|
||||
class="flex items-center gap-2 rounded-sm border border-neutral-300 bg-white px-2 py-1 text-sm hover:bg-neutral-100 focus-visible:ring-2 focus-visible:ring-coollabs dark:border-coolgray-300 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 dark:focus-visible:ring-warning"
|
||||
:class="pageWidth === 'full' && 'border-coollabs text-coollabs dark:border-warning dark:text-warning'">
|
||||
<svg class="size-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
Full
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1.5">
|
||||
<h2>Zoom</h2>
|
||||
<div>Choose interface density for this browser.</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button type="button" @click="setZoom('100')" aria-label="Use 100 percent zoom"
|
||||
class="flex items-center gap-2 rounded-sm border border-neutral-300 bg-white px-2 py-1 text-sm hover:bg-neutral-100 focus-visible:ring-2 focus-visible:ring-coollabs dark:border-coolgray-300 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 dark:focus-visible:ring-warning"
|
||||
:class="zoom === '100' && 'border-coollabs text-coollabs dark:border-warning dark:text-warning'">
|
||||
<svg class="size-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
100%
|
||||
</button>
|
||||
<button type="button" @click="setZoom('90')" aria-label="Use 90 percent zoom"
|
||||
class="flex items-center gap-2 rounded-sm border border-neutral-300 bg-white px-2 py-1 text-sm hover:bg-neutral-100 focus-visible:ring-2 focus-visible:ring-coollabs dark:border-coolgray-300 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 dark:focus-visible:ring-warning"
|
||||
:class="zoom === '90' && 'border-coollabs text-coollabs dark:border-warning dark:text-warning'">
|
||||
<svg class="size-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 10h4v4h-4v-4z" />
|
||||
</svg>
|
||||
90%
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user