Merge remote-tracking branch 'origin/next' into fix/form-state

This commit is contained in:
Andras Bacsai committed 2026-05-28 17:34:52 +02:00
commit 4401bee941
68 files changed
+2407 -1890

No files matched your search

+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
@@ -44,7 +45,10 @@ 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();
-41
View File
@@ -1,41 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Application;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
class ResourcesCheck
{
use AsAction;
public function handle()
{
$seconds = 60;
try {
Application::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
ServiceApplication::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
ServiceDatabase::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandalonePostgresql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneRedis::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneMongodb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneMysql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneMariadb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneKeydb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneDragonfly::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneClickhouse::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
} catch (\Throwable $e) {
return handleError($e);
}
}
}
+7 -773
View File
@@ -16,7 +16,7 @@ class SyncBunny extends Command
*
* @var string
*/
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}';
protected $signature = 'sync:bunny {--templates} {--release} {--nightly}';
/**
* The console command description.
@@ -25,650 +25,6 @@ class SyncBunny extends Command
*/
protected $description = 'Sync files to BunnyCDN';
/**
* Fetch GitHub releases and sync to GitHub repository
*/
private function syncReleasesToGitHubRepo(): bool
{
$this->info('Fetching releases from GitHub...');
try {
$response = Http::timeout(30)
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
'per_page' => 30, // Fetch more releases for better changelog
]);
if (! $response->successful()) {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
$releases = $response->json();
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp;
$branchName = 'update-releases-'.$timestamp;
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
$releasesDir = dirname($releasesPath);
// Ensure directory exists
if (! is_dir($releasesDir)) {
$this->info("Creating directory: $releasesDir");
if (! mkdir($releasesDir, 0755, true)) {
$this->error("Failed to create directory: $releasesDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write releases.json to: $releasesPath");
$this->error('Possible reasons: permission denied or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Stage and commit
$this->info('Committing changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('Releases are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
$commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR Output: '.implode("\n", $output));
}
$this->info('Total releases synced: '.count($releases));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing releases: '.$e->getMessage());
return false;
}
}
/**
* Sync both releases.json and versions.json to GitHub repository in one PR
*/
private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
{
$this->info('Syncing releases.json and versions.json to GitHub repository...');
try {
// 1. Fetch releases from GitHub API
$this->info('Fetching releases from GitHub API...');
$response = Http::timeout(30)
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
'per_page' => 30,
]);
if (! $response->successful()) {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
$releases = $response->json();
// 2. Read versions.json
if (! file_exists($versionsLocation)) {
$this->error("versions.json not found at: $versionsLocation");
return false;
}
$file = file_get_contents($versionsLocation);
$versionsJson = json_decode($file, true);
$actualVersion = data_get($versionsJson, 'coolify.v4.version');
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
$branchName = 'update-releases-and-versions-'.$timestamp;
$versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
// 3. Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// 4. Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 5. Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
$releasesDir = dirname($releasesPath);
if (! is_dir($releasesDir)) {
if (! mkdir($releasesDir, 0755, true)) {
$this->error("Failed to create directory: $releasesDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
$this->error("Failed to write releases.json to: $releasesPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 6. Write versions.json
$this->info('Writing versions.json...');
$versionsPath = "$tmpDir/$versionsTargetPath";
$versionsDir = dirname($versionsPath);
if (! is_dir($versionsDir)) {
if (! mkdir($versionsDir, 0755, true)) {
$this->error("Failed to create directory: $versionsDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
$this->error("Failed to write versions.json to: $versionsPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 7. Stage both files
$this->info('Staging changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 8. Check for changes
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('Both files are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
// 9. Commit changes
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 10. Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 11. Create pull request
$this->info('Creating pull request...');
$prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// 12. Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info("Version synced: $actualVersion");
$this->info('Total releases synced: '.count($releases));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing to GitHub: '.$e->getMessage());
return false;
}
}
/**
* Sync install.sh, docker-compose, and env files to GitHub repository via PR
*/
private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
{
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$this->info("Syncing $envLabel files to GitHub repository...");
try {
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp;
$branchName = 'update-files-'.$timestamp;
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Copy each file to its target path in the CDN repo
$copiedFiles = [];
foreach ($files as $sourceFile => $targetPath) {
if (! file_exists($sourceFile)) {
$this->warn("Source file not found, skipping: $sourceFile");
continue;
}
$destPath = "$tmpDir/$targetPath";
$destDir = dirname($destPath);
if (! is_dir($destDir)) {
if (! mkdir($destDir, 0755, true)) {
$this->error("Failed to create directory: $destDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
if (copy($sourceFile, $destPath) === false) {
$this->error("Failed to copy $sourceFile to $destPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$copiedFiles[] = $targetPath;
$this->info("Copied: $targetPath");
}
if (empty($copiedFiles)) {
$this->warn('No files were copied. Nothing to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
// Stage all copied files
$this->info('Staging changes...');
$output = [];
$stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
exec($stageCmd, $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Check for changes
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('All files are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
// Commit changes
$commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
$fileList = implode("\n- ", $copiedFiles);
$prBody = "Automated update of $envLabel files:\n- $fileList";
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info('Files synced: '.count($copiedFiles));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing files to GitHub: '.$e->getMessage());
return false;
}
}
/**
* Sync versions.json to GitHub repository via PR
*/
private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
{
$this->info('Syncing versions.json to GitHub repository...');
try {
if (! file_exists($versionsLocation)) {
$this->error("versions.json not found at: $versionsLocation");
return false;
}
$file = file_get_contents($versionsLocation);
$json = json_decode($file, true);
$actualVersion = data_get($json, 'coolify.v4.version');
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
$branchName = 'update-versions-'.$timestamp;
$targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Write versions.json
$this->info('Writing versions.json...');
$versionsPath = "$tmpDir/$targetPath";
$versionsDir = dirname($versionsPath);
// Ensure directory exists
if (! is_dir($versionsDir)) {
$this->info("Creating directory: $versionsDir");
if (! mkdir($versionsDir, 0755, true)) {
$this->error("Failed to create directory: $versionsDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($versionsPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write versions.json to: $versionsPath");
$this->error('Possible reasons: permission denied or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Stage and commit
$this->info('Committing changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('versions.json is already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$prBody = "Automated update of $envLabel versions.json to version $actualVersion";
$output = [];
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info("Version synced: $actualVersion");
return true;
} catch (\Throwable $e) {
$this->error('Error syncing versions.json: '.$e->getMessage());
return false;
}
}
/**
* Execute the console command.
*/
@@ -677,8 +33,6 @@ 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());
}
-2
View File
@@ -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();
+2 -2
View File
@@ -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
+36 -15
View File
@@ -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()
@@ -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,
]);
}
}
+209 -37
View File
@@ -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
View File
@@ -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,
]);
}
}
+29 -15
View File
@@ -40,13 +40,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
*/
public $timeout = 300;
public Team $team;
public ?Team $team = null;
public ?Server $server = null;
public ScheduledTask $task;
public Application|Service $resource;
public Application|Service|null $resource = null;
public ?ScheduledTaskExecution $task_log = null;
@@ -61,25 +61,34 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
public array $containers = [];
public string $server_timezone;
public string $server_timezone = 'UTC';
public function __construct($task)
public function __construct(ScheduledTask $task)
{
$this->onQueue(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,
@@ -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);
+17 -11
View File
@@ -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'));
+18 -1
View File
@@ -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) {
+12 -9
View File
@@ -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
+25 -2
View File
@@ -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 = [
'name',
'description',
@@ -157,6 +161,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();
@@ -164,11 +172,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) {
@@ -183,11 +192,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;
}
}
+16
View File
@@ -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', '-'),
];
}
+4 -9
View File
@@ -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
View File
@@ -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)');
}
};
+10
View File
@@ -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([
+7
View File
@@ -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";
+71 -15
View File
@@ -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
+8
View File
@@ -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

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512">
<path d="M0 0 C1.70939841 1.00633939 3.41880759 2.012661 5.12884521 3.01791382 C6.88771134 4.05366605 8.64373532 5.09410976 10.39941406 6.13525391 C15.86254051 9.36890211 21.37144447 12.52335208 26.875 15.6875 C29.04187281 16.93714262 31.20853906 18.18714347 33.375 19.4375 C34.4475 20.05625 35.52 20.675 36.625 21.3125 C41.5 24.125 41.5 24.125 46.375 26.9375 C47.44766113 27.55633057 48.52032227 28.17516113 49.62548828 28.81274414 C51.79057071 30.06185518 53.95560982 31.31104129 56.12060547 32.56030273 C61.56115706 35.69952309 67.00247216 38.83740563 72.4453125 41.97265625 C85.09977209 49.26386061 97.74027623 56.57797052 110.34375 63.95703125 C113.85537664 66.01257309 117.37523466 68.05217832 120.90625 70.07421875 C121.67767334 70.51918701 122.44909668 70.96415527 123.24389648 71.42260742 C124.63850133 72.22677522 126.03613705 73.02572665 127.43774414 73.81762695 C133.54267807 77.35523584 139.213694 81.53188654 143.125 87.5 C143.58390625 88.17675781 144.0428125 88.85351562 144.515625 89.55078125 C149.43255775 99.08793529 149.30972422 108.11592721 149.27905273 118.55810547 C149.28476189 120.31496615 149.29172825 122.07182313 149.29985046 123.82867432 C149.31773767 128.58084836 149.31662175 133.3328373 149.31090808 138.08503485 C149.30780951 142.06098821 149.31391617 146.03690636 149.31994683 150.0128547 C149.33397615 159.39743444 149.33244429 168.78192313 149.32104492 178.16650391 C149.30956769 187.82832287 149.3236364 197.48986412 149.3504414 207.15164649 C149.37263752 215.46532668 149.37922689 223.77891995 149.37336498 232.09262764 C149.37000327 237.04991631 149.37235799 242.00702704 149.38961601 246.96429062 C149.40514533 251.62823425 149.40106352 256.29176831 149.38231087 260.95569611 C149.37864138 262.66039327 149.38168524 264.36512118 149.39219666 266.06978989 C149.46437189 278.74968217 148.06844884 288.75429938 140.046875 299.01953125 C133.60934589 305.44548207 125.7356734 309.74132493 117.875 314.1875 C116.1311678 315.18325595 114.38734263 316.17902463 112.64407349 317.17576599 C111.45731817 317.85409678 110.27020309 318.53179855 109.08273315 319.20887756 C103.36327206 322.47146062 97.6805969 325.79539038 92 329.125 C82.73051969 334.54758443 73.41667313 339.88955448 64.08654785 345.20672607 C55.949299 349.84525289 47.8353364 354.52137234 39.75 359.25 C27.15494428 366.61481635 14.53438926 373.93375481 1.875 381.1875 C0.9576709 381.7148877 0.0403418 382.24227539 -0.90478516 382.78564453 C-3.36927887 384.1989891 -5.83950038 385.60160553 -8.3125 387 C-9.0122998 387.40250977 -9.71209961 387.80501953 -10.43310547 388.21972656 C-17.09519935 391.95312136 -23.18349938 393.48649703 -30.875 393.5625 C-32.16921875 393.59085937 -33.4634375 393.61921875 -34.796875 393.6484375 C-45.24624098 393.01072874 -54.39730898 387.04248993 -63.1875 381.875 C-64.41666274 381.16134233 -65.64632822 380.44854993 -66.87646484 379.73657227 C-69.39927697 378.27481152 -71.91928989 376.80840094 -74.43701172 375.33789062 C-78.96329577 372.69564735 -83.5060633 370.08222296 -88.04858398 367.46801758 C-91.26592438 365.61583252 -94.48097663 363.75973747 -97.6953125 361.90234375 C-104.17010022 358.16098344 -110.64736343 354.42392517 -117.125 350.6875 C-119.2916775 349.43751878 -121.45834417 348.18751879 -123.625 346.9375 C-124.6975 346.31875 -125.77 345.7 -126.875 345.0625 C-131.75 342.25 -131.75 342.25 -136.625 339.4375 C-138.2349585 338.50877075 -138.2349585 338.50877075 -139.87744141 337.5612793 C-142.03618703 336.31572429 -144.19471701 335.06979546 -146.35302734 333.82348633 C-151.88914426 330.62732156 -157.42927343 327.43845894 -162.9765625 324.26171875 C-169.37399954 320.59524689 -175.75649931 316.90399913 -182.125 313.1875 C-182.97465332 312.6934668 -183.82430664 312.19943359 -184.69970703 311.69042969 C-196.87661887 304.56064306 -206.13343948 297.4624996 -210.67488384 283.49647617 C-211.29941167 280.29281451 -211.26151795 277.2111391 -211.27217102 273.94700623 C-211.27584771 273.21631033 -211.2795244 272.48561442 -211.2833125 271.73277622 C-211.29432881 269.28335084 -211.29818973 266.83396791 -211.30200195 264.38452148 C-211.30826828 262.62840135 -211.31491523 260.87228253 -211.32191467 259.11616516 C-211.34294169 253.34698406 -211.35331747 247.57780274 -211.36328125 241.80859375 C-211.36732966 239.82024687 -211.3714466 237.83190014 -211.37563133 235.84355354 C-211.39468195 226.49913362 -211.40891188 217.15472159 -211.4172433 207.81028599 C-211.4270082 197.0367967 -211.45330879 186.26351329 -211.49374419 175.49009651 C-211.52394903 167.15530395 -211.53871642 158.82057024 -211.54202431 150.48572403 C-211.54437028 145.5115683 -211.55327137 140.53763253 -211.57848549 135.56353569 C-211.60184697 130.87917398 -211.60596709 126.19517577 -211.59572411 121.5107708 C-211.59523055 119.79800199 -211.60149732 118.0852148 -211.61528015 116.37250137 C-211.71345984 103.44141503 -210.34632335 92.68280134 -201.5859375 82.5625 C-196.68881338 77.83638657 -190.9834993 74.55916415 -185.125 71.1875 C-183.66126953 70.34058594 -183.66126953 70.34058594 -182Line truncated
<path d="M0 0 C1.58748859 -0.00378736 3.17497386 -0.00929083 4.76245117 -0.01637268 C9.05577347 -0.030288 13.34869326 -0.01918631 17.64199305 -0.00266147 C22.14218081 0.01122755 26.64234612 0.00472476 31.14254761 0.00120544 C38.69633443 -0.00149308 46.24996201 0.01195002 53.80371094 0.03515625 C62.53116221 0.06178301 71.25834766 0.06350564 79.98582566 0.05176789 C88.39214416 0.04098274 96.7983667 0.04718172 105.20467758 0.06132317 C108.77791493 0.06707463 112.35108186 0.06700694 115.92432022 0.06225777 C120.13185711 0.05747951 124.33910344 0.06762467 128.54658508 0.08921242 C130.08889155 0.0947682 131.63122601 0.09525668 133.17353439 0.09025955 C135.28203587 0.08428547 137.38965525 0.09685089 139.49807739 0.11402893 C140.67679979 0.11645883 141.85552219 0.11888872 143.06996346 0.12139225 C148.22129744 0.7249554 151.41529186 2.64111071 155.12841797 6.18920898 C157.71557242 10.10690001 158.61287492 12.83773807 158.31591797 17.50170898 C157.40142317 21.91374529 156.05843158 24.77775822 152.81591797 27.93920898 C148.67879051 30.52491365 145.52376178 30.63190145 140.73149109 30.64888 C139.72331706 30.65439504 139.72331706 30.65439504 138.69477588 30.66002148 C136.43786697 30.67105313 134.1810041 30.67490125 131.92407227 30.67871094 C130.30776829 30.68497592 128.69146575 30.69162275 127.07516479 30.69862366 C121.76349897 30.71966006 116.45183286 30.73002476 111.14013672 30.73999023 C109.31171974 30.74403776 107.48330291 30.74815469 105.65488625 30.75234032 C97.06599285 30.77139004 88.47710806 30.78562226 79.8881976 30.79395229 C69.97210814 30.80372823 60.0562417 30.83005794 50.14023083 30.87045318 C42.4764438 30.90060283 34.81272073 30.91542002 27.14887553 30.9187333 C22.57112632 30.92108486 17.99361542 30.93005923 13.41592979 30.95519447 C9.10914424 30.97846051 4.80275418 30.98271445 0.49592209 30.97243309 C-1.0834923 30.97193728 -2.66292607 30.97827113 -4.24228096 30.99198914 C-6.40156219 31.00969284 -8.55942167 31.00261124 -10.71870422 30.98945618 C-11.92604375 30.99184453 -13.13338327 30.99423288 -14.37730885 30.99669361 C-19.18442961 30.27712479 -22.31846731 28.05568435 -25.49658203 24.43920898 C-27.9340093 20.46235396 -28.05892602 17.07480565 -27.68408203 12.50170898 C-26.27959947 7.70678763 -24.78370423 5.23650778 -20.56082153 2.41465759 C-14.11682817 -0.68912856 -6.98561178 -0.04752778 0 0 Z " fill="#5D4DFF" transform="translate(206.68408203125,303.498291015625)"/>
<path d="M0 0 C0.66989018 -0.00538554 1.33978035 -0.01077108 2.02997023 -0.01631981 C4.26158969 -0.03056074 6.49261273 -0.02350563 8.72425842 -0.01637268 C10.32953364 -0.02207829 11.9348048 -0.0290433 13.54006958 -0.03717041 C17.89134382 -0.05509584 22.24241633 -0.05393461 26.59371638 -0.04822803 C30.2294076 -0.04513529 33.86506012 -0.05122957 37.50074583 -0.05726677 C46.07918909 -0.07130836 54.65753276 -0.06975375 63.23597717 -0.05836487 C72.07928112 -0.04688363 80.9222818 -0.0609602 89.76554579 -0.08776134 C97.36488571 -0.10992832 104.96413051 -0.11655348 112.56350046 -0.11068493 C117.09929166 -0.10731658 121.63488845 -0.10972233 126.17065239 -0.12693596 C130.43648119 -0.14241898 134.70186119 -0.1384347 138.96767235 -0.11963081 C140.53052943 -0.11594868 142.09341986 -0.1190425 143.65624619 -0.1295166 C158.34815862 -0.22044232 158.34815862 -0.22044232 164.09950256 4.38768005 C167.26418319 8.1852968 168.25781388 12.4893416 168.09950256 17.38768005 C166.39195407 22.77993844 164.18465359 26.42641601 159.38684559 29.47643471 C156.31877911 30.69870932 153.74187618 30.64585575 150.43951416 30.65510559 C149.43724217 30.66190226 149.43724217 30.66190226 148.41472226 30.66883624 C146.17701025 30.68149074 143.93954131 30.67985922 141.70179749 30.6782074 C140.09681892 30.68419205 138.49184323 30.69099745 136.88687134 30.69856262 C132.52725772 30.71623752 128.1677226 30.72091866 123.80807662 30.72219419 C121.08361415 30.72361565 118.35917164 30.72788517 115.63471413 30.73318863 C106.12776548 30.75169471 96.62087293 30.75985863 87.11390686 30.75828552 C78.25588827 30.75708436 69.39811345 30.77818016 60.54015654 30.80977285 C52.93237449 30.83593847 45.32467203 30.84665268 37.71684533 30.8453747 C33.17430417 30.8448651 28.63197956 30.85054722 24.08948326 30.8717308 C19.81549271 30.89111773 15.54192515 30.89123469 11.26791954 30.87669754 C9.70183188 30.87460881 8.13571772 30.87931425 6.56967545 30.89141846 C-8.13634494 30.99769494 -8.13634494 30.99769494 -13.90049744 26.38768005 C-16.71412746 22.96760346 -17.76766298 19.21420558 -18.46299744 14.88768005 C-17.72727811 10.30987088 -16.08014633 6.78916492 -12.90049744 3.38768005 C-8.43892792 0.73767921 -5.16288121 0.01050015 0 0 Z " fill="#5D4DFF" transform="translate(196.90049743652344,165.61231994628906)"/>
<path d="M0 0 C1.74753067 -0.00856522 1.74753067 -0.00856522 3.53036499 -0.01730347 C5.44967583 -0.01898026 5.44967583 -0.01898026 7.40776062 -0.02069092 C8.7679101 -0.02531989 10.12805828 -0.03034466 11.48820496 -0.03573608 C15.18459279 -0.04885484 18.88096246 -0.05530406 22.57736993 -0.05974674 C24.88722684 -0.06267842 27.1970788 -0.06678342 29.50693321 -0.07125092 C36.73536033 -0.08492471 43.96377611 -0.09459184 51.19221526 -0.09845281 C59.53370129 -0.10293287 67.87502339 -0.12047879 76.21645957 -0.1494534 C82.66582077 -0.17107734 89.11513587 -0.18115747 95.56453294 -0.18249393 C99.41565801 -0.18354148 103.26662896 -0.18943864 107.11771584 -0.20731354 C110.74158144 -0.22380655 114.36517729 -0.22594391 117.98906708 -0.21717453 C119.94883689 -0.21603848 121.90859781 -0.22974652 123.86831665 -0.243927 C134.70719021 -0.1924874 134.70719021 -0.1924874 139.40263367 3.14044189 C143.99937831 8.2341319 144.86495901 11.54455925 144.82060242 18.30450439 C144.16155149 22.77619575 141.89203211 25.39534174 138.40263367 28.14044189 C135.07074972 30.17807958 132.4940131 30.39521069 128.61581421 30.40786743 C127.43403595 30.4163974 126.25225769 30.42492737 125.03466797 30.43371582 C123.74159225 30.43280945 122.44851654 30.43190308 121.11625671 30.43096924 C119.73787573 30.43695501 118.3594981 30.44376067 116.98112488 30.45132446 C113.2392576 30.4689885 109.49748179 30.47368005 105.75557685 30.47495604 C103.41638537 30.47637816 101.0772171 30.48064895 98.73803139 30.48595047 C90.57323863 30.50445326 82.40851115 30.51262092 74.24369812 30.51104736 C66.63988891 30.50984643 59.03636377 30.53093854 51.43262643 30.56253469 C44.89914356 30.58871342 38.36575336 30.59941397 31.83221847 30.59813654 C27.93241345 30.5976273 24.0328608 30.60328898 20.13310814 30.62449265 C16.46318634 30.64390176 12.79375692 30.64398172 9.12381744 30.62945938 C7.14158231 30.62638171 5.159347 30.64302973 3.17718506 30.66033936 C-3.62071578 30.61242671 -8.12234595 30.32274115 -13.59736633 26.14044189 C-16.76841194 22.33518716 -17.73748189 18.04448633 -17.59736633 13.14044189 C-15.90303982 7.26013224 -13.43700439 4.56851885 -8.59736633 1.14044189 C-5.46872314 0.09756083 -3.28303509 0.00962657 0 0 Z " fill="#5D4DFF" transform="translate(164.5973663330078,234.85955810546875)"/>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+39
View File
@@ -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

@@ -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>
+2 -2
View File
@@ -368,7 +368,7 @@
<div class="flex-1"></div>
@if (isInstanceAdmin() && !isCloud())
@persist('upgrade')
<li :class="collapsed && 'lg:hidden'">
<li>
<livewire:upgrade />
</li>
@endpersist
@@ -420,7 +420,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>
@@ -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
@@ -13,6 +13,7 @@
scrollDebounce: null,
isScrolling: false,
destroyed: false,
morphUpdatedCleanup: null,
deploymentFinishedCleanup: null,
lastTouchY: 0,
showTimestamps: true,
@@ -40,6 +41,10 @@
clearTimeout(this.scrollTimeout);
this.scrollTimeout = null;
}
if (this.scrollDebounce) {
clearTimeout(this.scrollDebounce);
this.scrollDebounce = null;
}
},
disableFollow() {
if (!this.alwaysScroll) return;
@@ -208,10 +213,8 @@
});
// Apply search after Livewire updates.
// Livewire.hook() has no deregister API, so this callback survives
// wire:navigate. It is made harmless after teardown by the
// `destroyed` guard and by only reacting to DOM inside this root.
Livewire.hook('morph.updated', ({ el }) => {
// Livewire.hook() returns an unregister fn; keep it for destroy().
this.morphUpdatedCleanup = Livewire.hook('morph.updated', ({ el }) => {
if (this.destroyed) return;
if (el.id !== 'logs' || !this.$root.contains(el)) return;
this.$nextTick(() => {
@@ -247,6 +250,10 @@
clearTimeout(this.scrollDebounce);
this.scrollDebounce = null;
}
if (typeof this.morphUpdatedCleanup === 'function') {
this.morphUpdatedCleanup();
this.morphUpdatedCleanup = null;
}
if (typeof this.deploymentFinishedCleanup === 'function') {
this.deploymentFinishedCleanup();
this.deploymentFinishedCleanup = null;
@@ -5,7 +5,8 @@
<h1>GitHub App</h1>
<div class="flex gap-2">
@if (data_get($github_app, 'installation_id'))
<x-forms.button canGate="update" :canResource="$github_app" type="submit">Save</x-forms.button>
<x-forms.button canGate="update" :canResource="$github_app" type="submit"
:disabled="$activeTab !== 'general'">Save</x-forms.button>
@endif
@can('delete', $github_app)
@if ($applications->count() > 0)
@@ -39,170 +40,187 @@
Install Repositories on GitHub
</a>
@else
<div class="flex flex-col gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2 w-full">
<x-forms.input canGate="update" :canResource="$github_app" id="name" label="App Name" />
<x-forms.button canGate="update" :canResource="$github_app" wire:click.prevent="updateGithubAppName">
Sync Name
</x-forms.button>
@can('update', $github_app)
<a href="{{ $this->getGithubAppNameUpdatePath() }}">
<x-forms.button
class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline">
Rename
<x-external-link />
</x-forms.button>
</a>
<a href="{{ getInstallationPath($github_app) }}" class="w-fit">
<x-forms.button
class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline whitespace-nowrap">
Update Repositories
<div class="navbar-main">
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
<a class="{{ request()->routeIs('source.github.show') ? 'dark:text-white' : '' }}"
{{ wireNavigate() }}
href="{{ route('source.github.show', ['github_app_uuid' => $github_app->uuid]) }}">
General
</a>
<a class="{{ request()->routeIs('source.github.permissions') ? 'dark:text-white' : '' }}"
{{ wireNavigate() }}
href="{{ route('source.github.permissions', ['github_app_uuid' => $github_app->uuid]) }}">
Permissions
</a>
<a class="{{ request()->routeIs('source.github.resources') ? 'dark:text-white' : '' }}"
{{ wireNavigate() }}
href="{{ route('source.github.resources', ['github_app_uuid' => $github_app->uuid]) }}">
Resources
</a>
</nav>
</div>
<div class="pt-4">
<div class="flex flex-col gap-2" @if ($activeTab !== 'general') hidden @endif>
<div class="flex flex-col sm:flex-row gap-2">
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2 w-full">
<x-forms.input canGate="update" :canResource="$github_app" id="name" label="App Name" />
<x-forms.button canGate="update" :canResource="$github_app" wire:click.prevent="updateGithubAppName">
Sync Name
</x-forms.button>
@can('update', $github_app)
<a href="{{ $this->getGithubAppNameUpdatePath() }}">
<x-forms.button canGate="update" :canResource="$github_app"
class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline">
Rename
<x-external-link />
</x-forms.button>
</a>
<a href="{{ getInstallationPath($github_app) }}" class="w-fit">
<x-forms.button canGate="update" :canResource="$github_app"
class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline whitespace-nowrap">
Update Repositories
<x-external-link />
</x-forms.button>
</a>
@endcan
</div>
</div>
<x-forms.input canGate="update" :canResource="$github_app" id="organization" label="Organization"
placeholder="If empty, personal user will be used" />
@if (!isCloud())
<div class="w-48">
<x-forms.checkbox canGate="update" :canResource="$github_app" label="System Wide?"
helper="If checked, this GitHub App will be available for everyone in this Coolify instance."
instantSave id="isSystemWide" />
</div>
@if ($isSystemWide)
<x-callout type="warning" title="Not Recommended">
System-wide GitHub Apps are shared across all teams on this Coolify instance. This means any team can use this GitHub App to deploy applications from your repositories. For better security and isolation, it's recommended to create team-specific GitHub Apps instead.
</x-callout>
@endif
@endif
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="htmlUrl" label="HTML Url" />
<x-forms.input canGate="update" :canResource="$github_app" id="apiUrl" label="API Url" />
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="customUser" label="User"
required />
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="customPort"
label="Port" required />
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="appId"
label="App Id" required />
<x-forms.input canGate="update" :canResource="$github_app" type="number"
id="installationId" label="Installation Id" required />
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="clientId" label="Client Id"
type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="clientSecret"
label="Client Secret" type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="webhookSecret"
label="Webhook Secret" type="password" required />
</div>
<div class="flex gap-2">
<x-forms.select canGate="update" :canResource="$github_app" id="privateKeyId"
label="Private Key" required>
@if (blank($github_app->private_key_id))
<option value="0" selected>Select a private key</option>
@endif
@foreach ($privateKeys as $privateKey)
<option value="{{ $privateKey->id }}">{{ $privateKey->name }}</option>
@endforeach
</x-forms.select>
</div>
</div>
<div class="flex flex-col gap-2" @if ($activeTab !== 'permissions') hidden @endif>
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2">
<h2>Permissions</h2>
@can('view', $github_app)
<x-forms.button canGate="view" :canResource="$github_app" wire:click.prevent="checkPermissions">Refetch</x-forms.button>
<a href="{{ getPermissionsPath($github_app) }}">
<x-forms.button canGate="view" :canResource="$github_app" class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline">
Update
<x-external-link />
</x-forms.button>
</a>
@endcan
</div>
</div>
<x-forms.input canGate="update" :canResource="$github_app" id="organization" label="Organization"
placeholder="If empty, personal user will be used" />
@if (!isCloud())
<div class="w-48">
<x-forms.checkbox canGate="update" :canResource="$github_app" label="System Wide?"
helper="If checked, this GitHub App will be available for everyone in this Coolify instance."
instantSave id="isSystemWide" />
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="view" :canResource="$github_app" id="contents" helper="read - mandatory." label="Content" readonly
placeholder="N/A" />
<x-forms.input canGate="view" :canResource="$github_app" id="metadata" helper="read - mandatory." label="Metadata" readonly
placeholder="N/A" />
<x-forms.input canGate="view" :canResource="$github_app" id="pullRequests"
helper="write access needed to use deployment status update in previews."
label="Pull Request" readonly placeholder="N/A" />
</div>
@if ($isSystemWide)
<x-callout type="warning" title="Not Recommended">
System-wide GitHub Apps are shared across all teams on this Coolify instance. This means any team can use this GitHub App to deploy applications from your repositories. For better security and isolation, it's recommended to create team-specific GitHub Apps instead.
</x-callout>
</div>
<div class="flex flex-col" @if ($activeTab !== 'resources') hidden @endif x-data="{ search: '' }">
@if ($applications->isEmpty())
<div class="py-4 text-sm opacity-70">
No resources are currently using this GitHub App.
</div>
@else
<x-forms.input canGate="view" :canResource="$github_app" placeholder="Search resources..." x-model="search" id="null" />
<div class="overflow-x-auto pt-4">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full">
<thead>
<tr>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Project</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Environment</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Name</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Type</th>
</tr>
</thead>
<tbody class="divide-y">
@foreach ($applications->sortBy('name', SORT_NATURAL) as $resource)
@php
$projectName = (string) data_get($resource->project(), 'name');
$environmentName = (string) data_get($resource, 'environment.name');
$resourceName = (string) $resource->name;
$resourceType = (string) str($resource->type())->headline();
@endphp
<tr class="dark:hover:bg-coolgray-300 hover:bg-neutral-100"
x-show="search === ''
|| '{{ strtolower(addslashes($projectName)) }}'.includes(search.toLowerCase())
|| '{{ strtolower(addslashes($environmentName)) }}'.includes(search.toLowerCase())
|| '{{ strtolower(addslashes($resourceName)) }}'.includes(search.toLowerCase())
|| '{{ strtolower(addslashes($resourceType)) }}'.includes(search.toLowerCase())">
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ $projectName }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ $environmentName }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
<a {{ wireNavigate() }} href="{{ $resource->link() }}">
{{ $resourceName }}
<x-internal-link />
</a>
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ $resourceType }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@endif
@endif
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="htmlUrl" label="HTML Url" />
<x-forms.input canGate="update" :canResource="$github_app" id="apiUrl" label="API Url" />
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="customUser" label="User"
required />
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="customPort"
label="Port" required />
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="appId"
label="App Id" required />
<x-forms.input canGate="update" :canResource="$github_app" type="number"
id="installationId" label="Installation Id" required />
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="clientId" label="Client Id"
type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="clientSecret"
label="Client Secret" type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="webhookSecret"
label="Webhook Secret" type="password" required />
</div>
<div class="flex gap-2">
<x-forms.select canGate="update" :canResource="$github_app" id="privateKeyId"
label="Private Key" required>
@if (blank($github_app->private_key_id))
<option value="0" selected>Select a private key</option>
@endif
@foreach ($privateKeys as $privateKey)
<option value="{{ $privateKey->id }}">{{ $privateKey->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2">
<h2 class="pt-4">Permissions</h2>
@can('view', $github_app)
<x-forms.button wire:click.prevent="checkPermissions">Refetch</x-forms.button>
<a href="{{ getPermissionsPath($github_app) }}">
<x-forms.button>
Update
<x-external-link />
</x-forms.button>
</a>
@endcan
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input id="contents" helper="read - mandatory." label="Content" readonly
placeholder="N/A" />
<x-forms.input id="metadata" helper="read - mandatory." label="Metadata" readonly
placeholder="N/A" />
{{-- <x-forms.input id="administration"
helper="read:write access needed to setup servers as GitHub Runner." label="Administration"
readonly placeholder="N/A" /> --}}
<x-forms.input id="pullRequests"
helper="write access needed to use deployment status update in previews."
label="Pull Request" readonly placeholder="N/A" />
</div>
</div>
@endif
</form>
@if (data_get($github_app, 'installation_id'))
<div class="w-full pt-10">
<div class="h-full">
<div class="flex flex-col">
<div class="flex gap-2">
<h2>Resources</h2>
</div>
<div class="pb-4 title">Here you can find all resources that are using this source.</div>
</div>
@if ($applications->isEmpty())
<div class="py-4 text-sm opacity-70">
No resources are currently using this GitHub App.
</div>
@else
<div class="flex flex-col">
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full">
<thead>
<tr>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">
Project
</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">
Environment</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Name
</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Type
</th>
</tr>
</thead>
<tbody class="divide-y">
@foreach ($applications->sortBy('name',SORT_NATURAL) as $resource)
<tr>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource->project(), 'name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource, 'environment.name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap"><a
class=""
{{ wireNavigate() }}
href="{{ $resource->link() }}">{{ $resource->name }}
<x-internal-link /></a>
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ str($resource->type())->headline() }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@endif
</div>
</div>
@endif
@else
<div class="flex flex-col sm:flex-row sm:items-center gap-2 pb-4">
<h1>GitHub App</h1>
@@ -216,88 +234,137 @@
@endcan
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-center min-h-[calc(100vh-12rem)]">
<div class="mx-auto grid w-full max-w-5xl grid-cols-1 gap-4 lg:grid-cols-2">
@can('create', $github_app)
<h3>Manual Installation</h3>
<div class="flex gap-2 items-center">
If you want to fill the form manually, you can continue below. Only for advanced users.
<x-forms.button wire:click.prevent="createGithubAppManually">
Continue
</x-forms.button>
</div>
<h3>Automated Installation</h3>
<div class=" pb-5 rounded-sm alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 stroke-current shrink-0" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>You must complete this step before you can use this source!</span>
<section class="box-without-bg flex-col gap-4 p-6 h-full transition-all duration-200"
x-data="{
webhookEndpoint: $wire.entangle('webhook_endpoint').live,
useCustomWebhookEndpoint: $wire.entangle('use_custom_webhook_endpoint').live,
customWebhookEndpoint: $wire.entangle('custom_webhook_endpoint').live,
}">
<div class="flex flex-col gap-4 text-left h-full">
<div class="flex items-center justify-between">
<svg class="size-10" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
</svg>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Recommended
</span>
</div>
<div>
<h3 class="text-xl font-bold mb-2">Automated Installation</h3>
<p class="text-sm dark:text-neutral-400">
Register a GitHub App via GitHub's manifest flow. Permissions and webhooks are pre-configured.
</p>
</div>
<div class="flex flex-col gap-3 pt-4 border-t border-neutral-200 dark:border-coolgray-400">
@if (!isCloud() || isDev())
<x-forms.checkbox canGate="create" :canResource="$github_app"
x-model="useCustomWebhookEndpoint" id="use_custom_webhook_endpoint"
label="Use custom webhook endpoint"
helper="Enable this when the public URL GitHub should call differs from Coolify's configured URL, for example behind Cloudflare Tunnel." />
<div x-show="!useCustomWebhookEndpoint">
<x-forms.select canGate="create" :canResource="$github_app"
wire:model.live='webhook_endpoint' x-model="webhookEndpoint"
label="Selected endpoint"
helper="GitHub will use this endpoint unless custom mode is enabled.">
@if ($fqdn)
<option value="{{ $fqdn }}">Use {{ $fqdn }}</option>
@endif
@if ($ipv4)
<option value="{{ $ipv4 }}">Use {{ $ipv4 }}</option>
@endif
@if ($ipv6)
<option value="{{ $ipv6 }}">Use {{ $ipv6 }}</option>
@endif
@if (config('app.url'))
<option value="{{ config('app.url') }}">Use {{ config('app.url') }}</option>
@endif
</x-forms.select>
</div>
<div x-cloak x-show="useCustomWebhookEndpoint">
<x-forms.input canGate="create" :canResource="$github_app"
x-model="customWebhookEndpoint" id="custom_webhook_endpoint" type="url"
label="Custom endpoint" placeholder="https://coolify.example.com"
helper="GitHub will use this custom public URL. Do not include /webhooks." />
</div>
@else
<div class="text-sm dark:text-neutral-400">You need to register a GitHub App before using this source.</div>
@endif
<div class="flex w-full flex-col gap-2">
<x-forms.checkbox canGate="create" :canResource="$github_app" disabled id="default_permissions" label="Mandatory"
helper="Contents: read<br>Metadata: read<br>Email: read" />
<x-forms.checkbox canGate="create" :canResource="$github_app" id="preview_deployment_permissions" label="Preview Deployments"
helper="Necessary for updating pull requests with useful comments (deployment status, links, etc.)<br><br>Pull Request: read & write" />
</div>
</div>
<div class="mt-auto pt-2">
<x-forms.button canGate="create" :canResource="$github_app" class="w-full justify-center" isHighlighted
x-on:click.prevent="createGithubApp(webhookEndpoint, useCustomWebhookEndpoint, customWebhookEndpoint, {{ Illuminate\Support\Js::from($preview_deployment_permissions) }}, {{ Illuminate\Support\Js::from($administration) }})">
Register Now
</x-forms.button>
</div>
</div>
</section>
<section class="box-without-bg flex-col gap-4 p-6 h-full transition-all duration-200">
<div class="flex flex-col gap-4 text-left h-full">
<div class="flex items-center justify-between">
<svg class="size-10" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
</svg>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-neutral-100 dark:bg-coolgray-300 dark:text-neutral-400 rounded">
Advanced
</span>
</div>
<div>
<h3 class="text-xl font-bold mb-2">Manual Installation</h3>
<p class="text-sm dark:text-neutral-400">
Fill the GitHub App form manually. For self-hosted GitHub Enterprise or custom permission setups.
</p>
</div>
<div class="mt-auto pt-2">
<x-forms.button canGate="create" :canResource="$github_app" class="w-full justify-center" wire:click.prevent="createGithubAppManually">
Continue
</x-forms.button>
</div>
</div>
</section>
@else
<div class="pb-10">
<x-callout type="danger" title="Insufficient Permissions">
You don't have permission to create new GitHub Apps. Please contact your team administrator.
</x-callout>
</div>
@endcan
<div class="flex flex-col">
<div class="pb-10">
@can('create', $github_app)
@if (!isCloud() || isDev())
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2">
<x-forms.select wire:model.live='webhook_endpoint' label="Webhook Endpoint"
helper="All Git webhooks will be sent to this endpoint. <br><br>If you would like to use domain instead of IP address, set your Coolify instance's FQDN in the Settings menu.">
@if ($fqdn)
<option value="{{ $fqdn }}">Use {{ $fqdn }}</option>
@endif
@if ($ipv4)
<option value="{{ $ipv4 }}">Use {{ $ipv4 }}</option>
@endif
@if ($ipv6)
<option value="{{ $ipv6 }}">Use {{ $ipv6 }}</option>
@endif
@if (config('app.url'))
<option value="{{ config('app.url') }}">Use {{ config('app.url') }}</option>
@endif
</x-forms.select>
<x-forms.button isHighlighted
x-on:click.prevent="createGithubApp('{{ $webhook_endpoint }}','{{ $preview_deployment_permissions }}',{{ $administration }})">
Register Now
</x-forms.button>
</div>
@else
<div class="flex flex-col sm:flex-row gap-2">
<h2>Register a GitHub App</h2>
<x-forms.button isHighlighted
x-on:click.prevent="createGithubApp('{{ $webhook_endpoint }}','{{ $preview_deployment_permissions }}',{{ $administration }})">
Register Now
</x-forms.button>
</div>
<div>You need to register a GitHub App before using this source.</div>
@endif
<div class="flex w-full flex-col gap-2 pt-4 sm:w-96">
<x-forms.checkbox disabled id="default_permissions" label="Mandatory"
helper="Contents: read<br>Metadata: read<br>Email: read" />
<x-forms.checkbox id="preview_deployment_permissions" label="Preview Deployments "
helper="Necessary for updating pull requests with useful comments (deployment status, links, etc.)<br><br>Pull Request: read & write" />
{{-- <x-forms.checkbox id="administration" label="Administration (for Github Runners)"
helper="Necessary for adding Github Runners to repositories.<br><br>Administration: read & write" /> --}}
</div>
@else
<x-callout type="danger" title="Insufficient Permissions">
You don't have permission to create new GitHub Apps. Please contact your team administrator.
</x-callout>
@endcan
</div>
</div>
</div>
<script>
function createGithubApp(webhook_endpoint, preview_deployment_permissions, administration) {
function createGithubApp(webhook_endpoint, use_custom_webhook_endpoint, custom_webhook_endpoint, preview_deployment_permissions, administration) {
const {
organization,
html_url,
uuid
} = @json($github_app);
if (!webhook_endpoint) {
alert('Please select a webhook endpoint.');
} = @js($github_app->only(['organization', 'html_url', 'uuid']));
const selectedEndpoint = webhook_endpoint ? webhook_endpoint.trim() : '';
const customEndpoint = custom_webhook_endpoint ? custom_webhook_endpoint.trim() : '';
if (use_custom_webhook_endpoint && !customEndpoint) {
alert('Please enter a custom webhook endpoint.');
return;
}
let baseUrl = webhook_endpoint;
if (!use_custom_webhook_endpoint && !selectedEndpoint) {
alert('Please enter a webhook endpoint.');
return;
}
let baseUrl = (use_custom_webhook_endpoint ? customEndpoint : selectedEndpoint).replace(/\/+$/, '');
const name = @js($name);
const manifestState = @js($manifestState);
const isDev = @js(config('app.env')) ===
+6 -6
View File
@@ -6,17 +6,17 @@
})">
@if ($isUpgradeAvailable)
<div :class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
<button class="menu-item" @click="modalOpen=true" x-show="showProgress">
<button title="Upgrade in progress" aria-label="Upgrade in progress" class="menu-item" @click="modalOpen=true" x-show="showProgress">
<svg xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-pink-500 transition-colors hover:text-pink-300 lds-heart" viewBox="0 0 24 24"
class="text-pink-500 transition-colors menu-item-icon hover:text-pink-300 lds-heart" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M19.5 13.572l-7.5 7.428l-7.5 -7.428m0 0a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" />
</svg>
In progress
<span class="text-left menu-item-label" :class="collapsed && 'lg:hidden'">In progress</span>
</button>
<button class="menu-item cursor-pointer" @click="modalOpen=true" x-show="!showProgress">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-pink-500 transition-colors hover:text-pink-300"
<button title="Upgrade" aria-label="Upgrade" class="menu-item cursor-pointer" @click="modalOpen=true" x-show="!showProgress">
<svg xmlns="http://www.w3.org/2000/svg" class="text-pink-500 transition-colors menu-item-icon hover:text-pink-300"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -25,7 +25,7 @@
<path d="M9 21h6" />
<path d="M9 18h6" />
</svg>
Upgrade
<span class="text-left menu-item-label" :class="collapsed && 'lg:hidden'">Upgrade</span>
</button>
<template x-teleport="body">
<div x-show="modalOpen"
+2 -1
View File
@@ -33,7 +33,6 @@ use App\Livewire\Project\Service\DatabaseBackups as ServiceDatabaseBackups;
use App\Livewire\Project\Service\Index as ServiceIndex;
use App\Livewire\Project\Shared\ExecuteContainerCommand;
use App\Livewire\Project\Shared\Logs;
use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow;
use App\Livewire\Project\Show as ProjectShow;
use App\Livewire\Security\ApiTokens;
use App\Livewire\Security\CloudInitScripts;
@@ -320,6 +319,8 @@ Route::middleware(['auth'])->group(function () {
]);
})->name('source.all');
Route::get('/source/github/{github_app_uuid}', GitHubChange::class)->name('source.github.show');
Route::get('/source/github/{github_app_uuid}/permissions', GitHubChange::class)->name('source.github.permissions');
Route::get('/source/github/{github_app_uuid}/resources', GitHubChange::class)->name('source.github.resources');
});
Route::middleware(['auth'])->group(function () {
+1 -1
View File
@@ -7,7 +7,7 @@
services:
audiobookshelf:
image: ghcr.io/advplyr/audiobookshelf:latest
image: ghcr.io/advplyr/audiobookshelf:2.34.0
environment:
- SERVICE_URL_AUDIOBOOKSHELF_80
- TZ=${TIMEZONE:-America/Toronto}
+19
View File
@@ -0,0 +1,19 @@
# documentation: https://github.com/favonia/cloudflare-ddns
# slogan: A small, feature-rich, and robust Cloudflare DDNS updater.
# category: automation
# tags: cloud, ddns
# logo: svgs/cloudflare-ddns.svg
services:
cloudflare-ddns:
image: favonia/cloudflare-ddns:1.16.2
network_mode: host
user: "1000:1000"
read_only: true
cap_drop: [all]
security_opt: [no-new-privileges:true]
environment:
- CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?}
- DOMAINS=${DOMAINS:?}
- PROXIED=${PROXIED:-false}
- IP6_PROVIDER=${IP6_PROVIDER:-none}
+27
View File
@@ -0,0 +1,27 @@
# documentation: https://www.emqx.io/docs/en/latest/deploy/install-docker.html
# slogan: Open-source MQTT broker for IoT, IIoT, and connected vehicles.
# category: Networking
# tags: mqtt,broker,iot,messaging,emqx,iiot
# logo: svgs/emqx-enterprise.svg
# port: 18083
services:
emqx:
image: emqx/emqx-enterprise:6.2.0
environment:
- SERVICE_URL_EMQX_18083
- EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX}
ports:
- "1883:1883"
- "8083:8083"
- "8084:8084"
- "8883:8883"
volumes:
- emqx_data:/opt/emqx/data
- emqx_log:/opt/emqx/log
healthcheck:
test: ["CMD", "/opt/emqx/bin/emqx", "ctl", "status"]
interval: 10s
timeout: 30s
retries: 5
start_period: 30s
+1 -1
View File
@@ -6,7 +6,7 @@
services:
runner:
image: 'docker.io/gitea/runner:1.0.0'
image: 'docker.io/gitea/runner:1.0.6'
environment:
- 'GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL}'
- 'GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}'
+1 -1
View File
@@ -6,7 +6,7 @@
services:
grocy:
image: lscr.io/linuxserver/grocy:latest
image: lscr.io/linuxserver/grocy:4.6.0
environment:
- SERVICE_URL_GROCY
- PUID=1000
@@ -0,0 +1,49 @@
# documentation: https://github.com/nesquena/hermes-webui
# slogan: Hermes Agent — autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI.
# category: ai
# tags: ai, agent, llm, chatbot, hermes, openrouter, anthropic, openai
# logo: svgs/hermes-agent.png
# port: 8787
services:
hermes-agent:
image: nousresearch/hermes-agent:sha-273ff5c4a47af4499bbe5e3b1139efd313995554
command: gateway run
environment:
- HERMES_HOME=/home/hermes/.hermes
- HERMES_UID=1000
- HERMES_GID=1000
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- GOOGLE_API_KEY=${GOOGLE_API_KEY}
volumes:
- hermes-home:/home/hermes/.hermes
- hermes-agent-src:/opt/hermes
healthcheck:
test: ["CMD-SHELL", "test -d /home/hermes/.hermes || exit 1"]
interval: 10s
timeout: 5s
retries: 5
hermes-webui:
image: ghcr.io/nesquena/hermes-webui:0.51.92
depends_on:
- hermes-agent
environment:
- SERVICE_URL_HERMESWEBUI_8787
- HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
- WANTED_UID=1000
- WANTED_GID=1000
- HERMES_WEBUI_PASSWORD=${SERVICE_PASSWORD_HERMESWEBUI}
volumes:
- hermes-home:/home/hermeswebui/.hermes
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro
- hermes-workspace:/workspace
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8787/health"]
interval: 30s
timeout: 5s
retries: 3
+1 -1
View File
@@ -7,7 +7,7 @@
services:
jellyfin:
image: lscr.io/linuxserver/jellyfin:latest
image: lscr.io/linuxserver/jellyfin:10.11.8
environment:
- SERVICE_URL_JELLYFIN_8096
- PUID=1000
+1 -1
View File
@@ -7,7 +7,7 @@
services:
mealie:
image: 'ghcr.io/mealie-recipes/mealie:latest'
image: 'ghcr.io/mealie-recipes/mealie:3.17.0'
environment:
- SERVICE_URL_MEALIE_9000
- ALLOW_SIGNUP=${ALLOW_SIGNUP:-true}
+25
View File
@@ -0,0 +1,25 @@
# documentation: https://openobserve.ai/docs/
# slogan: Cloud-native observability platform for logs, metrics, traces, RUM, errors and session replays — a 140x cheaper alternative to Elasticsearch, Splunk and Datadog.
# category: monitoring
# tags: logs, metrics, traces, observability, monitoring, opentelemetry, otel, elasticsearch, splunk, datadog
# logo: svgs/openobserve.svg
# port: 5080
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:v0.90.0
environment:
- SERVICE_URL_OPENOBSERVE_5080
- ZO_DATA_DIR=/data
- ZO_ROOT_USER_EMAIL=${ZO_ROOT_USER_EMAIL:-root@example.com}
- ZO_ROOT_USER_PASSWORD=${SERVICE_PASSWORD_OPENOBSERVE}
- ZO_TELEMETRY=${ZO_TELEMETRY:-false}
- ZO_COOKIE_SECURE_ONLY=${ZO_COOKIE_SECURE_ONLY:-true}
volumes:
- openobserve-data:/data
healthcheck:
test: ["CMD", "/openobserve", "node", "status"]
interval: 10s
timeout: 5s
retries: 5
start_period: 60s
+1 -1
View File
@@ -7,7 +7,7 @@
services:
ryot:
image: ignisda/ryot:v8
image: ignisda/ryot:v10.3.0
environment:
- SERVICE_URL_RYOT_8000
- DATABASE_URL=postgres://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@postgresql:5432/${POSTGRES_DB}
+75 -6
View File
@@ -171,7 +171,7 @@
"audiobookshelf": {
"documentation": "https://www.audiobookshelf.org/?utm_source=coolify.io",
"slogan": "Self-hosted audiobook, ebook, and podcast server",
"compose": "c2VydmljZXM6CiAgYXVkaW9ib29rc2hlbGY6CiAgICBpbWFnZTogJ2doY3IuaW8vYWR2cGx5ci9hdWRpb2Jvb2tzaGVsZjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BVURJT0JPT0tTSEVMRl84MAogICAgICAtICdUWj0ke1RJTUVaT05FOi1BbWVyaWNhL1Rvcm9udG99JwogICAgdm9sdW1lczoKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtYXVkaW9ib29rczovYXVkaW9ib29rcycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtcG9kY2FzdHM6L3BvZGNhc3RzJwogICAgICAtICdhdWRpb2Jvb2tzaGVsZi1jb25maWc6L2NvbmZpZycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtbWV0YWRhdGE6L21ldGFkYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC0tcXVpZXQgLS10cmllcz0xIC0tdGltZW91dD01IGh0dHA6Ly9sb2NhbGhvc3Q6ODAvcGluZyAtTyAvZGV2L251bGwgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTVzCg==",
"compose": "c2VydmljZXM6CiAgYXVkaW9ib29rc2hlbGY6CiAgICBpbWFnZTogJ2doY3IuaW8vYWR2cGx5ci9hdWRpb2Jvb2tzaGVsZjoyLjM0LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BVURJT0JPT0tTSEVMRl84MAogICAgICAtICdUWj0ke1RJTUVaT05FOi1BbWVyaWNhL1Rvcm9udG99JwogICAgdm9sdW1lczoKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtYXVkaW9ib29rczovYXVkaW9ib29rcycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtcG9kY2FzdHM6L3BvZGNhc3RzJwogICAgICAtICdhdWRpb2Jvb2tzaGVsZi1jb25maWc6L2NvbmZpZycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtbWV0YWRhdGE6L21ldGFkYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC0tcXVpZXQgLS10cmllcz0xIC0tdGltZW91dD01IGh0dHA6Ly9sb2NhbGhvc3Q6ODAvcGluZyAtTyAvZGV2L251bGwgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTVzCg==",
"tags": [
"audiobooks",
"ebooks",
@@ -654,6 +654,18 @@
"minversion": "0.0.0",
"port": "8978"
},
"cloudflare-ddns": {
"documentation": "https://github.com/favonia/cloudflare-ddns?utm_source=coolify.io",
"slogan": "A small, feature-rich, and robust Cloudflare DDNS updater.",
"compose": "c2VydmljZXM6CiAgY2xvdWRmbGFyZS1kZG5zOgogICAgaW1hZ2U6ICdmYXZvbmlhL2Nsb3VkZmxhcmUtZGRuczoxLjE2LjInCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIHVzZXI6ICcxMDAwOjEwMDAnCiAgICByZWFkX29ubHk6IHRydWUKICAgIGNhcF9kcm9wOgogICAgICAtIGFsbAogICAgc2VjdXJpdHlfb3B0OgogICAgICAtICduby1uZXctcHJpdmlsZWdlczp0cnVlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMT1VERkxBUkVfQVBJX1RPS0VOPSR7Q0xPVURGTEFSRV9BUElfVE9LRU46P30nCiAgICAgIC0gJ0RPTUFJTlM9JHtET01BSU5TOj99JwogICAgICAtICdQUk9YSUVEPSR7UFJPWElFRDotZmFsc2V9JwogICAgICAtICdJUDZfUFJPVklERVI9JHtJUDZfUFJPVklERVI6LW5vbmV9Jwo=",
"tags": [
"cloud",
"ddns"
],
"category": "automation",
"logo": "svgs/cloudflare-ddns.svg",
"minversion": "0.0.0"
},
"cloudflared": {
"documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io",
"slogan": "Client for Cloudflare Tunnel, a daemon that exposes private services through the Cloudflare edge.",
@@ -1149,6 +1161,23 @@
"minversion": "0.0.0",
"port": "6555"
},
"emqx-enterprise": {
"documentation": "https://www.emqx.io/docs/en/latest/deploy/install-docker.html?utm_source=coolify.io",
"slogan": "Open-source MQTT broker for IoT, IIoT, and connected vehicles.",
"compose": "c2VydmljZXM6CiAgZW1xeDoKICAgIGltYWdlOiAnZW1xeC9lbXF4LWVudGVycHJpc2U6Ni4yLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FTVFYXzE4MDgzCiAgICAgIC0gJ0VNUVhfREFTSEJPQVJEX19ERUZBVUxUX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9FTVFYfScKICAgIHBvcnRzOgogICAgICAtICcxODgzOjE4ODMnCiAgICAgIC0gJzgwODM6ODA4MycKICAgICAgLSAnODA4NDo4MDg0JwogICAgICAtICc4ODgzOjg4ODMnCiAgICB2b2x1bWVzOgogICAgICAtICdlbXF4X2RhdGE6L29wdC9lbXF4L2RhdGEnCiAgICAgIC0gJ2VtcXhfbG9nOi9vcHQvZW1xeC9sb2cnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL29wdC9lbXF4L2Jpbi9lbXF4CiAgICAgICAgLSBjdGwKICAgICAgICAtIHN0YXR1cwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCg==",
"tags": [
"mqtt",
"broker",
"iot",
"messaging",
"emqx",
"iiot"
],
"category": "Networking",
"logo": "svgs/emqx-enterprise.svg",
"minversion": "0.0.0",
"port": "18083"
},
"ente-photos-with-s3": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
@@ -1637,7 +1666,7 @@
"gitea-runner": {
"documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io",
"slogan": "Gitea Actions runner for docker",
"compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC42JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"tags": [
"gitea",
"actions",
@@ -1951,7 +1980,7 @@
"grocy": {
"documentation": "https://github.com/grocy/grocy?utm_source=coolify.io",
"slogan": "Grocy is a web-based household management and grocery list application.",
"compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfR1JPQ1kKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICB2b2x1bWVzOgogICAgICAtICdncm9jeS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=",
"compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6NC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUk9DWQogICAgICAtIFBVSUQ9MTAwMAogICAgICAtIFBHSUQ9MTAwMAogICAgICAtIFRaPUV1cm9wZS9NYWRyaWQKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyb2N5LWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==",
"tags": [
"groceries",
"household",
@@ -1992,6 +2021,25 @@
"logo": "svgs/heimdall.svg",
"minversion": "0.0.0"
},
"hermes-agent-with-webui": {
"documentation": "https://github.com/nesquena/hermes-webui?utm_source=coolify.io",
"slogan": "Hermes Agent \u2014 autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI.",
"compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50OnNoYS0yNzNmZjVjNGE0N2FmNDQ5OWJiZTVlM2IxMTM5ZWZkMzEzOTk1NTU0JwogICAgY29tbWFuZDogJ2dhdGV3YXkgcnVuJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEVSTUVTX0hPTUU9L2hvbWUvaGVybWVzLy5oZXJtZXMKICAgICAgLSBIRVJNRVNfVUlEPTEwMDAKICAgICAgLSBIRVJNRVNfR0lEPTEwMDAKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWX0nCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogICAgICAtICdHT09HTEVfQVBJX0tFWT0ke0dPT0dMRV9BUElfS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lcy8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUxLjkyJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBoZXJtZXMtYWdlbnQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0hFUk1FU1dFQlVJXzg3ODcKICAgICAgLSBIRVJNRVNfV0VCVUlfSE9TVD0wLjAuMC4wCiAgICAgIC0gSEVSTUVTX1dFQlVJX1BPUlQ9ODc4NwogICAgICAtIEhFUk1FU19XRUJVSV9TVEFURV9ESVI9L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy93ZWJ1aQogICAgICAtIFdBTlRFRF9VSUQ9MTAwMAogICAgICAtIFdBTlRFRF9HSUQ9MTAwMAogICAgICAtICdIRVJNRVNfV0VCVUlfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0hFUk1FU1dFQlVJfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMnCiAgICAgIC0gJ2hlcm1lcy1hZ2VudC1zcmM6L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy9oZXJtZXMtYWdlbnQ6cm8nCiAgICAgIC0gJ2hlcm1lcy13b3Jrc3BhY2U6L3dvcmtzcGFjZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4Nzg3L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCg==",
"tags": [
"ai",
"agent",
"llm",
"chatbot",
"hermes",
"openrouter",
"anthropic",
"openai"
],
"category": "ai",
"logo": "svgs/hermes-agent.png",
"minversion": "0.0.0",
"port": "8787"
},
"heyform": {
"documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io",
"slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.",
@@ -2176,7 +2224,7 @@
"jellyfin": {
"documentation": "https://jellyfin.org?utm_source=coolify.io",
"slogan": "Jellyfin is a media server for hosting and streaming your media collection.",
"compose": "c2VydmljZXM6CiAgamVsbHlmaW46CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvamVsbHlmaW46bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSkVMTFlGSU5fODA5NgogICAgICAtIFBVSUQ9MTAwMAogICAgICAtIFBHSUQ9MTAwMAogICAgICAtIFRaPUV1cm9wZS9NYWRyaWQKICAgICAgLSBKRUxMWUZJTl9QdWJsaXNoZWRTZXJ2ZXJVcmw9JFNFUlZJQ0VfVVJMX0pFTExZRklOCiAgICB2b2x1bWVzOgogICAgICAtICdqZWxseWZpbi1jb25maWc6L2NvbmZpZycKICAgICAgLSAnamVsbHlmaW4tdHZzaG93czovZGF0YS90dnNob3dzJwogICAgICAtICdqZWxseWZpbi1tb3ZpZXM6L2RhdGEvbW92aWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwOTYnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"compose": "c2VydmljZXM6CiAgamVsbHlmaW46CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvamVsbHlmaW46MTAuMTEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0pFTExZRklOXzgwOTYKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICAgIC0gSkVMTFlGSU5fUHVibGlzaGVkU2VydmVyVXJsPSRTRVJWSUNFX1VSTF9KRUxMWUZJTgogICAgdm9sdW1lczoKICAgICAgLSAnamVsbHlmaW4tY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ2plbGx5ZmluLXR2c2hvd3M6L2RhdGEvdHZzaG93cycKICAgICAgLSAnamVsbHlmaW4tbW92aWVzOi9kYXRhL21vdmllcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDk2JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==",
"tags": [
"media",
"server",
@@ -2755,7 +2803,7 @@
"mealie": {
"documentation": "https://docs.mealie.io/?utm_source=coolify.io",
"slogan": "A recipe manager and meal planner.",
"compose": "c2VydmljZXM6CiAgbWVhbGllOgogICAgaW1hZ2U6ICdnaGNyLmlvL21lYWxpZS1yZWNpcGVzL21lYWxpZTpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9NRUFMSUVfOTAwMAogICAgICAtICdBTExPV19TSUdOVVA9JHtBTExPV19TSUdOVVA6LXRydWV9JwogICAgICAtICdQVUlEPSR7UFVJRDotMTAwMH0nCiAgICAgIC0gJ1BHSUQ9JHtQR0lEOi0xMDAwfScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Jlcmxpbn0nCiAgICAgIC0gJ01BWF9XT1JLRVJTPSR7TUFYX1dPUktFUlM6LTF9JwogICAgICAtICdXRUJfQ09OQ1VSUkVOQ1k9JHtXRUJfQ09OQ1VSUkVOQ1k6LTF9JwogICAgdm9sdW1lczoKICAgICAgLSAnbWVhbGllX2RhdGE6L2FwcC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc6PiAvZGV2L3RjcC8xMjcuMC4wLjEvOTAwMCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK",
"compose": "c2VydmljZXM6CiAgbWVhbGllOgogICAgaW1hZ2U6ICdnaGNyLmlvL21lYWxpZS1yZWNpcGVzL21lYWxpZTozLjE3LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9NRUFMSUVfOTAwMAogICAgICAtICdBTExPV19TSUdOVVA9JHtBTExPV19TSUdOVVA6LXRydWV9JwogICAgICAtICdQVUlEPSR7UFVJRDotMTAwMH0nCiAgICAgIC0gJ1BHSUQ9JHtQR0lEOi0xMDAwfScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Jlcmxpbn0nCiAgICAgIC0gJ01BWF9XT1JLRVJTPSR7TUFYX1dPUktFUlM6LTF9JwogICAgICAtICdXRUJfQ09OQ1VSUkVOQ1k9JHtXRUJfQ09OQ1VSUkVOQ1k6LTF9JwogICAgdm9sdW1lczoKICAgICAgLSAnbWVhbGllX2RhdGE6L2FwcC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc6PiAvZGV2L3RjcC8xMjcuMC4wLjEvOTAwMCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK",
"tags": [
"recipe manager",
"meal planner",
@@ -3452,6 +3500,27 @@
"minversion": "0.0.0",
"port": "8080"
},
"openobserve": {
"documentation": "https://openobserve.ai/docs/?utm_source=coolify.io",
"slogan": "Cloud-native observability platform for logs, metrics, traces, RUM, errors and session replays \u2014 a 140x cheaper alternative to Elasticsearch, Splunk and Datadog.",
"compose": "c2VydmljZXM6CiAgb3Blbm9ic2VydmU6CiAgICBpbWFnZTogJ3B1YmxpYy5lY3IuYXdzL3ppbmNsYWJzL29wZW5vYnNlcnZlOnYwLjkwLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PUEVOT0JTRVJWRV81MDgwCiAgICAgIC0gWk9fREFUQV9ESVI9L2RhdGEKICAgICAgLSAnWk9fUk9PVF9VU0VSX0VNQUlMPSR7Wk9fUk9PVF9VU0VSX0VNQUlMOi1yb290QGV4YW1wbGUuY29tfScKICAgICAgLSAnWk9fUk9PVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9PUEVOT0JTRVJWRX0nCiAgICAgIC0gJ1pPX1RFTEVNRVRSWT0ke1pPX1RFTEVNRVRSWTotZmFsc2V9JwogICAgICAtICdaT19DT09LSUVfU0VDVVJFX09OTFk9JHtaT19DT09LSUVfU0VDVVJFX09OTFk6LXRydWV9JwogICAgdm9sdW1lczoKICAgICAgLSAnb3Blbm9ic2VydmUtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvb3Blbm9ic2VydmUKICAgICAgICAtIG5vZGUKICAgICAgICAtIHN0YXR1cwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiA2MHMK",
"tags": [
"logs",
"metrics",
"traces",
"observability",
"monitoring",
"opentelemetry",
"otel",
"elasticsearch",
"splunk",
"datadog"
],
"category": "monitoring",
"logo": "svgs/openobserve.svg",
"minversion": "0.0.0",
"port": "5080"
},
"openpanel": {
"documentation": "https://openpanel.dev/docs?utm_source=coolify.io",
"slogan": "Open source alternative to Mixpanel and Plausible for product analytics",
@@ -4125,7 +4194,7 @@
"ryot": {
"documentation": "https://github.com/ignisda/ryot?utm_source=coolify.io",
"slogan": "Roll your own tracker! Ryot is a self-hosted platform for tracking various aspects of life such as media consumption, fitness activities, and more.",
"compose": "c2VydmljZXM6CiAgcnlvdDoKICAgIGltYWdlOiAnaWduaXNkYS9yeW90OnY4JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUllPVF84MDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzcWw6NTQzMi8ke1BPU1RHUkVTX0RCfScKICAgICAgLSAnU0VSVkVSX0FETUlOX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUllPVH0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9BbXN0ZXJkYW19JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwMDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncnlvdF9wb3N0Z3Jlc3FsX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yeW90LWRifScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Ftc3RlcmRhbX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
"compose": "c2VydmljZXM6CiAgcnlvdDoKICAgIGltYWdlOiAnaWduaXNkYS9yeW90OnYxMC4zLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9SWU9UXzgwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXNxbDo1NDMyLyR7UE9TVEdSRVNfREJ9JwogICAgICAtICdTRVJWRVJfQURNSU5fQUNDRVNTX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF82NF9SWU9UfScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Ftc3RlcmRhbX0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAwMC9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyeW90X3Bvc3RncmVzcWxfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXJ5b3QtZGJ9JwogICAgICAtICdUWj0ke1RaOi1FdXJvcGUvQW1zdGVyZGFtfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
"tags": [
"rss",
"reader",
+75 -6
View File
@@ -171,7 +171,7 @@
"audiobookshelf": {
"documentation": "https://www.audiobookshelf.org/?utm_source=coolify.io",
"slogan": "Self-hosted audiobook, ebook, and podcast server",
"compose": "c2VydmljZXM6CiAgYXVkaW9ib29rc2hlbGY6CiAgICBpbWFnZTogJ2doY3IuaW8vYWR2cGx5ci9hdWRpb2Jvb2tzaGVsZjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQVVESU9CT09LU0hFTEZfODAKICAgICAgLSAnVFo9JHtUSU1FWk9ORTotQW1lcmljYS9Ub3JvbnRvfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLWF1ZGlvYm9va3M6L2F1ZGlvYm9va3MnCiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLXBvZGNhc3RzOi9wb2RjYXN0cycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLW1ldGFkYXRhOi9tZXRhZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXF1aWV0IC0tdHJpZXM9MSAtLXRpbWVvdXQ9NSBodHRwOi8vbG9jYWxob3N0OjgwL3BpbmcgLU8gL2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDE1cwo=",
"compose": "c2VydmljZXM6CiAgYXVkaW9ib29rc2hlbGY6CiAgICBpbWFnZTogJ2doY3IuaW8vYWR2cGx5ci9hdWRpb2Jvb2tzaGVsZjoyLjM0LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQVVESU9CT09LU0hFTEZfODAKICAgICAgLSAnVFo9JHtUSU1FWk9ORTotQW1lcmljYS9Ub3JvbnRvfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLWF1ZGlvYm9va3M6L2F1ZGlvYm9va3MnCiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLXBvZGNhc3RzOi9wb2RjYXN0cycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLW1ldGFkYXRhOi9tZXRhZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXF1aWV0IC0tdHJpZXM9MSAtLXRpbWVvdXQ9NSBodHRwOi8vbG9jYWxob3N0OjgwL3BpbmcgLU8gL2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDE1cwo=",
"tags": [
"audiobooks",
"ebooks",
@@ -654,6 +654,18 @@
"minversion": "0.0.0",
"port": "8978"
},
"cloudflare-ddns": {
"documentation": "https://github.com/favonia/cloudflare-ddns?utm_source=coolify.io",
"slogan": "A small, feature-rich, and robust Cloudflare DDNS updater.",
"compose": "c2VydmljZXM6CiAgY2xvdWRmbGFyZS1kZG5zOgogICAgaW1hZ2U6ICdmYXZvbmlhL2Nsb3VkZmxhcmUtZGRuczoxLjE2LjInCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIHVzZXI6ICcxMDAwOjEwMDAnCiAgICByZWFkX29ubHk6IHRydWUKICAgIGNhcF9kcm9wOgogICAgICAtIGFsbAogICAgc2VjdXJpdHlfb3B0OgogICAgICAtICduby1uZXctcHJpdmlsZWdlczp0cnVlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMT1VERkxBUkVfQVBJX1RPS0VOPSR7Q0xPVURGTEFSRV9BUElfVE9LRU46P30nCiAgICAgIC0gJ0RPTUFJTlM9JHtET01BSU5TOj99JwogICAgICAtICdQUk9YSUVEPSR7UFJPWElFRDotZmFsc2V9JwogICAgICAtICdJUDZfUFJPVklERVI9JHtJUDZfUFJPVklERVI6LW5vbmV9Jwo=",
"tags": [
"cloud",
"ddns"
],
"category": "automation",
"logo": "svgs/cloudflare-ddns.svg",
"minversion": "0.0.0"
},
"cloudflared": {
"documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io",
"slogan": "Client for Cloudflare Tunnel, a daemon that exposes private services through the Cloudflare edge.",
@@ -1149,6 +1161,23 @@
"minversion": "0.0.0",
"port": "6555"
},
"emqx-enterprise": {
"documentation": "https://www.emqx.io/docs/en/latest/deploy/install-docker.html?utm_source=coolify.io",
"slogan": "Open-source MQTT broker for IoT, IIoT, and connected vehicles.",
"compose": "c2VydmljZXM6CiAgZW1xeDoKICAgIGltYWdlOiAnZW1xeC9lbXF4LWVudGVycHJpc2U6Ni4yLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fRU1RWF8xODA4MwogICAgICAtICdFTVFYX0RBU0hCT0FSRF9fREVGQVVMVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRU1RWH0nCiAgICBwb3J0czoKICAgICAgLSAnMTg4MzoxODgzJwogICAgICAtICc4MDgzOjgwODMnCiAgICAgIC0gJzgwODQ6ODA4NCcKICAgICAgLSAnODg4Mzo4ODgzJwogICAgdm9sdW1lczoKICAgICAgLSAnZW1xeF9kYXRhOi9vcHQvZW1xeC9kYXRhJwogICAgICAtICdlbXF4X2xvZzovb3B0L2VtcXgvbG9nJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9vcHQvZW1xeC9iaW4vZW1xeAogICAgICAgIC0gY3RsCiAgICAgICAgLSBzdGF0dXMKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogNQogICAgICBzdGFydF9wZXJpb2Q6IDMwcwo=",
"tags": [
"mqtt",
"broker",
"iot",
"messaging",
"emqx",
"iiot"
],
"category": "Networking",
"logo": "svgs/emqx-enterprise.svg",
"minversion": "0.0.0",
"port": "18083"
},
"ente-photos-with-s3": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
@@ -1637,7 +1666,7 @@
"gitea-runner": {
"documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io",
"slogan": "Gitea Actions runner for docker",
"compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC42JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"tags": [
"gitea",
"actions",
@@ -1951,7 +1980,7 @@
"grocy": {
"documentation": "https://github.com/grocy/grocy?utm_source=coolify.io",
"slogan": "Grocy is a web-based household management and grocery list application.",
"compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0dST0NZCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gVFo9RXVyb3BlL01hZHJpZAogICAgdm9sdW1lczoKICAgICAgLSAnZ3JvY3ktY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6NC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JPQ1kKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICB2b2x1bWVzOgogICAgICAtICdncm9jeS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=",
"tags": [
"groceries",
"household",
@@ -1992,6 +2021,25 @@
"logo": "svgs/heimdall.svg",
"minversion": "0.0.0"
},
"hermes-agent-with-webui": {
"documentation": "https://github.com/nesquena/hermes-webui?utm_source=coolify.io",
"slogan": "Hermes Agent \u2014 autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI.",
"compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50OnNoYS0yNzNmZjVjNGE0N2FmNDQ5OWJiZTVlM2IxMTM5ZWZkMzEzOTk1NTU0JwogICAgY29tbWFuZDogJ2dhdGV3YXkgcnVuJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEVSTUVTX0hPTUU9L2hvbWUvaGVybWVzLy5oZXJtZXMKICAgICAgLSBIRVJNRVNfVUlEPTEwMDAKICAgICAgLSBIRVJNRVNfR0lEPTEwMDAKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWX0nCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogICAgICAtICdHT09HTEVfQVBJX0tFWT0ke0dPT0dMRV9BUElfS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lcy8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUxLjkyJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBoZXJtZXMtYWdlbnQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9IRVJNRVNXRUJVSV84Nzg3CiAgICAgIC0gSEVSTUVTX1dFQlVJX0hPU1Q9MC4wLjAuMAogICAgICAtIEhFUk1FU19XRUJVSV9QT1JUPTg3ODcKICAgICAgLSBIRVJNRVNfV0VCVUlfU1RBVEVfRElSPS9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMvd2VidWkKICAgICAgLSBXQU5URURfVUlEPTEwMDAKICAgICAgLSBXQU5URURfR0lEPTEwMDAKICAgICAgLSAnSEVSTUVTX1dFQlVJX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9IRVJNRVNXRUJVSX0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXJtZXMtaG9tZTovaG9tZS9oZXJtZXN3ZWJ1aS8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMvaGVybWVzLWFnZW50OnJvJwogICAgICAtICdoZXJtZXMtd29ya3NwYWNlOi93b3Jrc3BhY2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODc4Ny9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=",
"tags": [
"ai",
"agent",
"llm",
"chatbot",
"hermes",
"openrouter",
"anthropic",
"openai"
],
"category": "ai",
"logo": "svgs/hermes-agent.png",
"minversion": "0.0.0",
"port": "8787"
},
"heyform": {
"documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io",
"slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.",
@@ -2176,7 +2224,7 @@
"jellyfin": {
"documentation": "https://jellyfin.org?utm_source=coolify.io",
"slogan": "Jellyfin is a media server for hosting and streaming your media collection.",
"compose": "c2VydmljZXM6CiAgamVsbHlmaW46CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvamVsbHlmaW46bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0pFTExZRklOXzgwOTYKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICAgIC0gSkVMTFlGSU5fUHVibGlzaGVkU2VydmVyVXJsPSRTRVJWSUNFX0ZRRE5fSkVMTFlGSU4KICAgIHZvbHVtZXM6CiAgICAgIC0gJ2plbGx5ZmluLWNvbmZpZzovY29uZmlnJwogICAgICAtICdqZWxseWZpbi10dnNob3dzOi9kYXRhL3R2c2hvd3MnCiAgICAgIC0gJ2plbGx5ZmluLW1vdmllczovZGF0YS9tb3ZpZXMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA5NicKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=",
"compose": "c2VydmljZXM6CiAgamVsbHlmaW46CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvamVsbHlmaW46MTAuMTEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9KRUxMWUZJTl84MDk2CiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gVFo9RXVyb3BlL01hZHJpZAogICAgICAtIEpFTExZRklOX1B1Ymxpc2hlZFNlcnZlclVybD0kU0VSVklDRV9GUUROX0pFTExZRklOCiAgICB2b2x1bWVzOgogICAgICAtICdqZWxseWZpbi1jb25maWc6L2NvbmZpZycKICAgICAgLSAnamVsbHlmaW4tdHZzaG93czovZGF0YS90dnNob3dzJwogICAgICAtICdqZWxseWZpbi1tb3ZpZXM6L2RhdGEvbW92aWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwOTYnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"tags": [
"media",
"server",
@@ -2755,7 +2803,7 @@
"mealie": {
"documentation": "https://docs.mealie.io/?utm_source=coolify.io",
"slogan": "A recipe manager and meal planner.",
"compose": "c2VydmljZXM6CiAgbWVhbGllOgogICAgaW1hZ2U6ICdnaGNyLmlvL21lYWxpZS1yZWNpcGVzL21lYWxpZTpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTUVBTElFXzkwMDAKICAgICAgLSAnQUxMT1dfU0lHTlVQPSR7QUxMT1dfU0lHTlVQOi10cnVlfScKICAgICAgLSAnUFVJRD0ke1BVSUQ6LTEwMDB9JwogICAgICAtICdQR0lEPSR7UEdJRDotMTAwMH0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9CZXJsaW59JwogICAgICAtICdNQVhfV09SS0VSUz0ke01BWF9XT1JLRVJTOi0xfScKICAgICAgLSAnV0VCX0NPTkNVUlJFTkNZPSR7V0VCX0NPTkNVUlJFTkNZOi0xfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21lYWxpZV9kYXRhOi9hcHAvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzkwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==",
"compose": "c2VydmljZXM6CiAgbWVhbGllOgogICAgaW1hZ2U6ICdnaGNyLmlvL21lYWxpZS1yZWNpcGVzL21lYWxpZTozLjE3LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTUVBTElFXzkwMDAKICAgICAgLSAnQUxMT1dfU0lHTlVQPSR7QUxMT1dfU0lHTlVQOi10cnVlfScKICAgICAgLSAnUFVJRD0ke1BVSUQ6LTEwMDB9JwogICAgICAtICdQR0lEPSR7UEdJRDotMTAwMH0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9CZXJsaW59JwogICAgICAtICdNQVhfV09SS0VSUz0ke01BWF9XT1JLRVJTOi0xfScKICAgICAgLSAnV0VCX0NPTkNVUlJFTkNZPSR7V0VCX0NPTkNVUlJFTkNZOi0xfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21lYWxpZV9kYXRhOi9hcHAvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzkwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==",
"tags": [
"recipe manager",
"meal planner",
@@ -3452,6 +3500,27 @@
"minversion": "0.0.0",
"port": "8080"
},
"openobserve": {
"documentation": "https://openobserve.ai/docs/?utm_source=coolify.io",
"slogan": "Cloud-native observability platform for logs, metrics, traces, RUM, errors and session replays \u2014 a 140x cheaper alternative to Elasticsearch, Splunk and Datadog.",
"compose": "c2VydmljZXM6CiAgb3Blbm9ic2VydmU6CiAgICBpbWFnZTogJ3B1YmxpYy5lY3IuYXdzL3ppbmNsYWJzL29wZW5vYnNlcnZlOnYwLjkwLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1BFTk9CU0VSVkVfNTA4MAogICAgICAtIFpPX0RBVEFfRElSPS9kYXRhCiAgICAgIC0gJ1pPX1JPT1RfVVNFUl9FTUFJTD0ke1pPX1JPT1RfVVNFUl9FTUFJTDotcm9vdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ1pPX1JPT1RfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfT1BFTk9CU0VSVkV9JwogICAgICAtICdaT19URUxFTUVUUlk9JHtaT19URUxFTUVUUlk6LWZhbHNlfScKICAgICAgLSAnWk9fQ09PS0lFX1NFQ1VSRV9PTkxZPSR7Wk9fQ09PS0lFX1NFQ1VSRV9PTkxZOi10cnVlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ29wZW5vYnNlcnZlLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL29wZW5vYnNlcnZlCiAgICAgICAgLSBub2RlCiAgICAgICAgLSBzdGF0dXMKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogNjBzCg==",
"tags": [
"logs",
"metrics",
"traces",
"observability",
"monitoring",
"opentelemetry",
"otel",
"elasticsearch",
"splunk",
"datadog"
],
"category": "monitoring",
"logo": "svgs/openobserve.svg",
"minversion": "0.0.0",
"port": "5080"
},
"openpanel": {
"documentation": "https://openpanel.dev/docs?utm_source=coolify.io",
"slogan": "Open source alternative to Mixpanel and Plausible for product analytics",
@@ -4125,7 +4194,7 @@
"ryot": {
"documentation": "https://github.com/ignisda/ryot?utm_source=coolify.io",
"slogan": "Roll your own tracker! Ryot is a self-hosted platform for tracking various aspects of life such as media consumption, fitness activities, and more.",
"compose": "c2VydmljZXM6CiAgcnlvdDoKICAgIGltYWdlOiAnaWduaXNkYS9yeW90OnY4JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JZT1RfODAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlc3FsOjU0MzIvJHtQT1NUR1JFU19EQn0nCiAgICAgIC0gJ1NFUlZFUl9BRE1JTl9BQ0NFU1NfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JZT1R9JwogICAgICAtICdUWj0ke1RaOi1FdXJvcGUvQW1zdGVyZGFtfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3J5b3RfcG9zdGdyZXNxbF9kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcnlvdC1kYn0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9BbXN0ZXJkYW19JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
"compose": "c2VydmljZXM6CiAgcnlvdDoKICAgIGltYWdlOiAnaWduaXNkYS9yeW90OnYxMC4zLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fUllPVF84MDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzcWw6NTQzMi8ke1BPU1RHUkVTX0RCfScKICAgICAgLSAnU0VSVkVSX0FETUlOX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUllPVH0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9BbXN0ZXJkYW19JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwMDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncnlvdF9wb3N0Z3Jlc3FsX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yeW90LWRifScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Ftc3RlcmRhbX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"rss",
"reader",
@@ -80,11 +80,11 @@ it('checks legacy preview deployment configuration hash using preview environmen
$diff = $application->pendingDeploymentConfigurationDiff();
expect($diff->isLegacyFallback())->toBeTrue()
->and($diff->isChanged())->toBeTrue();
expect($diff->isChanged())->toBeTrue()
->and($diff->count())->toBeGreaterThan(0);
});
it('falls back to legacy configuration hash when no deployment snapshot exists', function () {
it('falls back to real diff against empty snapshot when no deployment snapshot exists', function () {
$application = configurationChangedTestApplication();
$application->isConfigurationChanged(save: true);
@@ -92,6 +92,10 @@ it('falls back to legacy configuration hash when no deployment snapshot exists',
$application->update(['build_command' => 'pnpm build']);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isLegacyFallback())->toBeTrue()
->and($application->pendingDeploymentConfigurationDiff()->isChanged())->toBeTrue();
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->isChanged())->toBeTrue()
->and($diff->isLegacyFallback())->toBeFalse()
->and($diff->count())->toBeGreaterThan(0)
->and(collect($diff->changes())->pluck('label')->toArray())->toContain('Build command');
});
@@ -1,5 +1,6 @@
<?php
use App\Actions\Docker\GetContainersStatus;
use App\Livewire\Project\Shared\Destination;
use App\Models\Application;
use App\Models\Environment;
@@ -10,6 +11,7 @@ use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@@ -65,6 +67,10 @@ beforeEach(function () {
session(['currentTeam' => $this->teamA]);
});
afterEach(function () {
GetContainersStatus::clearFake();
});
describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () {
test('cannot attach another team\'s server + network to own application', function () {
try {
@@ -98,6 +104,16 @@ describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () {
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('cannot attach own network paired with wrong own server', function () {
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverA->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
});
test('can attach own team\'s server + network to own application', function () {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('addServer', $this->destinationA2->id, $this->serverA2->id);
@@ -121,4 +137,96 @@ describe('Destination::promote GHSA-j395-3pqh-9r5g', function () {
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
});
test('cannot promote own network paired with wrong own server', function () {
$originalDestinationId = $this->applicationA->destination_id;
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA->id);
} catch (Throwable $e) {
}
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
});
test('can promote own team network and preserve previous main as additional network', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA2->id);
$application = $this->applicationA->fresh();
$additional = $application->additional_networks;
expect($application->destination_id)->toBe($this->destinationA2->id);
expect($additional)->toHaveCount(1);
expect($additional->first()->id)->toBe($this->destinationA->id);
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
});
test('refresh failures after promote do not roll back promoted destination', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
GetContainersStatus::shouldRun()
->once()
->andThrow(new RuntimeException('refresh failed'));
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA2->id);
} catch (Throwable $e) {
// The refresh failure is intentionally outside the transaction; persistence is the assertion.
}
$application = $this->applicationA->fresh();
$additional = $application->additional_networks;
expect($application->destination_id)->toBe($this->destinationA2->id);
expect($additional)->toHaveCount(1);
expect($additional->first()->id)->toBe($this->destinationA->id);
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
});
test('only detaches the promoted network for the selected pivot server', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA2->id);
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA->id)
->exists())->toBeTrue();
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA2->id)
->exists())->toBeFalse();
});
});
describe('Destination::removeServer', function () {
test('only detaches the removed network for the selected pivot server', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('removeServer', $this->destinationA2->id, $this->serverA2->id, 'password');
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA->id)
->exists())->toBeTrue();
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA2->id)
->exists())->toBeFalse();
});
});
@@ -94,6 +94,11 @@ it('scopes scroll teardown to the component so a stale loop cannot leak across d
->not->toContain("document.getElementById('logsContainer')")
// morph.updated hook only acts on this component's own DOM.
->toContain('this.$root.contains(el)')
// Global Livewire hook is unregistered when Alpine tears down.
->toContain('morphUpdatedCleanup: null')
->toContain("this.morphUpdatedCleanup = Livewire.hook('morph.updated'")
->toContain("typeof this.morphUpdatedCleanup === 'function'")
->toContain('this.morphUpdatedCleanup()')
// Continuation timeout is tracked so it can be cancelled.
->toContain('scrollTimeout');
});
+36 -6
View File
@@ -127,7 +127,7 @@ describe('GitHub Private Repository Component', function () {
Http::assertNothingSent();
});
test('loadRepositories does not mint tokens for another teams system wide github app', function () {
test('mount lists another teams system wide github app', function () {
$victimTeam = Team::factory()->create();
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
$systemWideGithubApp = GithubApp::create([
@@ -147,13 +147,43 @@ describe('GitHub Private Repository Component', function () {
'is_system_wide' => true,
]);
Http::fake();
$component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']);
expect(fn () => Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
expect($component->get('github_apps')->pluck('id')->all())
->toContain($this->githubApp->id)
->toContain($systemWideGithubApp->id);
});
test('loadRepositories can use another teams system wide github app', function () {
$victimTeam = Team::factory()->create();
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
$systemWideGithubApp = GithubApp::create([
'name' => 'System Wide GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 54321,
'installation_id' => 67890,
'client_id' => 'system-client-id',
'client_secret' => 'system-client-secret',
'webhook_secret' => 'system-webhook-secret',
'private_key_id' => $victimPrivateKey->id,
'team_id' => $victimTeam->id,
'is_public' => false,
'is_system_wide' => true,
]);
$repos = [
['id' => 1, 'name' => 'system-repo', 'owner' => ['login' => 'testuser']],
];
fakeGithubHttp($repos);
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $systemWideGithubApp->id)
)->toThrow(ModelNotFoundException::class);
Http::assertNothingSent();
->assertSet('current_step', 'repository')
->assertSet('total_repositories_count', 1)
->assertSet('selected_repository_id', 1);
});
test('github installation token is not stored as public component state', function () {
+104 -4
View File
@@ -2,6 +2,7 @@
use App\Livewire\Source\Github\Change;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
@@ -19,9 +20,45 @@ beforeEach(function () {
// Set current team
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::forceCreate([
'id' => 0,
'fqdn' => null,
'public_ipv4' => null,
'public_ipv6' => null,
]);
});
function validPrivateKey(): string
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
return $privateKey;
}
describe('GitHub Source Change Component', function () {
test('all github app form controls declare explicit authorization', function () {
$view = file_get_contents(resource_path('views/livewire/source/github/change.blade.php'));
preg_match_all(
'/<x-forms\.(button|input|select|checkbox)\b(?![^>]*\bcanGate=)[^>]*>/s',
$view,
$matches,
PREG_OFFSET_CAPTURE
);
$missingAuthorization = collect($matches[0])
->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0])))
->all();
expect($missingAuthorization)->toBeEmpty();
});
test('can mount with newly created github app with null app_id', function () {
// Create a GitHub app without app_id (simulating a newly created source)
$githubApp = GithubApp::create([
@@ -47,10 +84,69 @@ describe('GitHub Source Change Component', function () {
->assertSet('privateKeyId', null);
});
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
config(['app.url' => 'http://localhost:8000']);
InstanceSettings::findOrFail(0)->update([
'fqdn' => null,
'public_ipv4' => null,
'public_ipv6' => null,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('webhook_endpoint', 'http://localhost:8000');
});
test('custom webhook endpoint is selected explicitly with a checkbox', function () {
config(['app.url' => 'http://localhost:8000']);
InstanceSettings::findOrFail(0)->update([
'fqdn' => 'http://staging.example.com',
'public_ipv4' => '84.1.202.183',
'public_ipv6' => null,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('use_custom_webhook_endpoint', false)
->set('custom_webhook_endpoint', 'https://staging.example.com')
->set('use_custom_webhook_endpoint', true)
->assertSet('webhook_endpoint', 'http://staging.example.com')
->assertSet('custom_webhook_endpoint', 'https://staging.example.com')
->assertSet('use_custom_webhook_endpoint', true)
->assertSee('Use custom webhook endpoint')
->assertSee('Selected endpoint')
->assertSee('Custom endpoint')
->assertSee('createGithubApp(webhookEndpoint, useCustomWebhookEndpoint, customWebhookEndpoint');
});
test('can mount with fully configured github app', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-private-key-content',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
@@ -84,7 +180,7 @@ describe('GitHub Source Change Component', function () {
test('can update github app from null to valid values', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-private-key-content',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
@@ -157,8 +253,8 @@ describe('GitHub Source Change Component', function () {
// Verify the database was updated
$githubApp->refresh();
expect($githubApp->app_id)->toBe('1234567890');
expect($githubApp->installation_id)->toBe('1234567890');
expect($githubApp->app_id)->toBe(1234567890);
expect($githubApp->installation_id)->toBe(1234567890);
});
test('checkPermissions validates required fields', function () {
@@ -179,6 +275,8 @@ describe('GitHub Source Change Component', function () {
->assertSuccessful()
->call('checkPermissions')
->assertDispatched('error', function ($event, $message) {
$message = is_array($message) ? implode(' ', $message) : $message;
return str_contains($message, 'App ID') && str_contains($message, 'Private Key');
});
});
@@ -202,6 +300,8 @@ describe('GitHub Source Change Component', function () {
->assertSuccessful()
->call('checkPermissions')
->assertDispatched('error', function ($event, $message) {
$message = is_array($message) ? implode(' ', $message) : $message;
return str_contains($message, 'Private Key not found');
});
});
@@ -126,8 +126,7 @@ it('does not render environment variable secret values', function () {
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('API_TOKEN')
->assertSee('changed')
->assertSee('Set')
->assertSee('••••••••')
->assertDontSee('Hidden')
->assertDontSee('old-secret')
->assertDontSee('new-secret');
@@ -150,9 +149,9 @@ it('renders added environment variables as set without exposing secret values',
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('API_TOKEN')
->assertSee('From')
->assertSee('Not set')
->assertSee('-')
->assertSee('To')
->assertSee('Set')
->assertSee('••••••••')
->assertDontSee('Hidden')
->assertDontSee('new-secret');
});
+68
View File
@@ -0,0 +1,68 @@
<?php
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Proxy\StartProxy;
use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use App\Models\SslCertificate;
use App\Models\Team;
use Database\Seeders\ProductionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('creates the root team before seeding the localhost server and predefined shared variables', function () {
config([
'broadcasting.default' => 'log',
'constants.coolify.is_windows_docker_desktop' => true,
]);
Queue::fake();
StartProxy::shouldRun()->andReturn('OK');
Server::creating(function (Server $server) {
if ((int) $server->getKey() === 0) {
expect(Team::find(0))->not->toBeNull();
}
});
Server::created(function (Server $server) {
SslCertificate::create([
'server_id' => $server->id,
'common_name' => 'Coolify CA Certificate',
'ssl_certificate' => 'certificate',
'ssl_private_key' => 'private-key',
'valid_until' => now()->addYear(),
'is_ca_certificate' => true,
]);
});
$this->seed(ProductionSeeder::class);
$rootTeam = Team::find(0);
$localhostServer = Server::find(0);
expect($rootTeam)->not->toBeNull()
->and($localhostServer)->not->toBeNull()
->and($localhostServer->team_id)->toBe(0);
expect(SharedEnvironmentVariable::query()
->where('type', 'server')
->where('server_id', 0)
->where('team_id', 0)
->pluck('key')
->all()
)->toContain('COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME');
instanceSettings()->update(['is_registration_enabled' => true]);
$rootUser = app(CreateNewUser::class)->create([
'name' => 'Root User',
'email' => 'root@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
]);
expect(Team::whereKey(0)->count())->toBe(1)
->and($rootUser->teams()->where('team_id', 0)->exists())->toBeTrue();
});
@@ -1,17 +1,19 @@
<?php
use App\Jobs\PushServerUpdateJob;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('database last_online_at is updated when status unchanged', function () {
test('database last_online_at is not updated when status is unchanged', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$database = createPushUpdatePostgresql($team, [
'status' => 'running:healthy',
'last_online_at' => now()->subMinutes(5),
]);
@@ -40,15 +42,13 @@ test('database last_online_at is updated when status unchanged', function () {
$database->refresh();
// last_online_at should be updated even though status didn't change
expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue();
expect((string) $database->last_online_at)->toBe((string) $oldLastOnline);
expect($database->status)->toBe('running:healthy');
});
test('database status is updated when container status changes', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$database = createPushUpdatePostgresql($team, [
'status' => 'exited',
]);
@@ -79,8 +79,7 @@ test('database status is updated when container status changes', function () {
test('database is not marked exited when containers list is empty', function () {
$team = Team::factory()->create();
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$database = createPushUpdatePostgresql($team, [
'status' => 'running:healthy',
]);
@@ -99,3 +98,31 @@ test('database is not marked exited when containers list is empty', function ()
// Status should remain running, NOT be set to exited
expect($database->status)->toBe('running:healthy');
});
function createPushUpdatePostgresql(Team $team, array $attributes = []): StandalonePostgresql
{
$lastOnlineAt = $attributes['last_online_at'] ?? null;
unset($attributes['last_online_at']);
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create(array_merge([
'uuid' => (string) str()->uuid(),
'name' => 'postgres-'.str()->random(8),
'postgres_password' => 'secret',
'status' => 'exited',
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'environment_id' => $environment->id,
], $attributes));
if ($lastOnlineAt !== null) {
$database->forceFill(['last_online_at' => $lastOnlineAt])->saveQuietly();
}
return $database;
}
@@ -50,6 +50,39 @@ it('does not dispatch storage check when disk usage is below threshold', functio
Queue::assertNotPushed(ServerStorageCheckJob::class);
});
it('clears stale storage cache when disk usage drops below threshold', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$storageCacheKey = 'storage-check:'.$server->id;
Cache::put($storageCacheKey, 85, 600);
$belowThresholdData = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 45],
];
$job = new PushServerUpdateJob($server, $belowThresholdData);
$job->handle();
Queue::assertNotPushed(ServerStorageCheckJob::class);
expect(Cache::missing($storageCacheKey))->toBeTrue();
Queue::fake();
$aboveThresholdData = [
'containers' => [],
'filesystem_usage_root' => ['used_percentage' => 85],
];
$job = new PushServerUpdateJob($server, $aboveThresholdData);
$job->handle();
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
return $job->server->id === $server->id && $job->percentage === 85;
});
});
it('does not dispatch storage check when disk percentage is unchanged', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
+11 -4
View File
@@ -4,17 +4,21 @@ use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('containers with empty service subId are skipped', function () {
$server = Server::factory()->create();
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$service = Service::factory()->create([
'server_id' => $server->id,
]);
$serviceApp = ServiceApplication::factory()->create([
$serviceApp = ServiceApplication::create([
'service_id' => $service->id,
'uuid' => (string) str()->uuid(),
'name' => 'app-'.str()->random(8),
]);
$data = [
@@ -44,12 +48,15 @@ test('containers with empty service subId are skipped', function () {
});
test('containers with valid service subId are processed', function () {
$server = Server::factory()->create();
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$service = Service::factory()->create([
'server_id' => $server->id,
]);
$serviceApp = ServiceApplication::factory()->create([
$serviceApp = ServiceApplication::create([
'service_id' => $service->id,
'uuid' => (string) str()->uuid(),
'name' => 'app-'.str()->random(8),
]);
$data = [
@@ -0,0 +1,150 @@
<?php
use App\Jobs\ScheduledJobManager;
use App\Jobs\ScheduledTaskJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('dispatches scheduled tasks across chunks', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fake();
$team = Team::factory()->create();
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $team->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
'docker_cleanup_frequency' => '0 * * * *',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'status' => 'running',
]);
ScheduledTask::factory()
->count(101)
->create([
'team_id' => $team->id,
'application_id' => $application->id,
'frequency' => '* * * * *',
'enabled' => true,
]);
(new ScheduledJobManager)->handle();
Queue::assertPushed(ScheduledTaskJob::class, 101);
});
it('skips expensive dispatch for non-due schedules while seeding dedup cache', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
Queue::fake();
$application = createScheduledTaskApplication();
$task = ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => '0 2 * * *',
'enabled' => true,
]);
(new ScheduledJobManager)->handle();
Queue::assertNotPushed(ScheduledTaskJob::class);
expect(Cache::get("scheduled-task:{$task->id}"))->not->toBeNull();
});
it('does not query relationships when constructing scheduled task jobs', function () {
$application = createScheduledTaskApplication();
$task = ScheduledTask::factory()->create([
'team_id' => $application->environment->project->team_id,
'application_id' => $application->id,
'frequency' => '* * * * *',
'enabled' => true,
])->fresh();
DB::flushQueryLog();
DB::enableQueryLog();
$job = new ScheduledTaskJob($task);
expect(DB::getQueryLog())->toBeEmpty()
->and($job->queue)->toBe(crons_queue())
->and($job->timeout)->toBe(300);
});
function createScheduledTaskApplication(): Application
{
$team = Team::factory()->create();
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $team->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
'docker_cleanup_frequency' => '0 * * * *',
]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'status' => 'running',
]);
}
@@ -1,8 +1,10 @@
<?php
use App\Http\Controllers\Api\SentinelController;
use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use App\Models\User;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
@@ -11,6 +13,8 @@ use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.store' => 'array']);
Queue::fake();
Cache::flush();
@@ -45,6 +49,25 @@ function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): arra
$running = fn () => [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
it('skips dispatch decision when sentinel lock acquisition times out', function () use ($running) {
$lock = Mockery::mock();
$lock->shouldReceive('block')
->once()
->with(5, Mockery::type('callable'))
->andThrow(LockTimeoutException::class);
Cache::shouldReceive('lock')
->once()
->with('sentinel:push-lock:'.$this->server->id, 10)
->andReturn($lock);
$controller = new SentinelController;
$method = new ReflectionMethod($controller, 'shouldDispatchUpdate');
$method->setAccessible(true);
expect($method->invoke($controller, $this->server, sentinelPayload($running())))->toBeFalse();
});
it('dispatches the job on the first push', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
@@ -69,6 +92,43 @@ it('updates the heartbeat even when the job is skipped', function () use ($runni
expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5);
});
it('accepts an empty container list as a heartbeat when no containers are running', function () {
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
pushSentinel($this->token, sentinelPayload([]))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5);
});
it('rejects malformed sentinel payloads before touching server state', function (array $payload) {
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
$originalHeartbeat = $this->server->fresh()->sentinel_updated_at;
pushSentinel($this->token, $payload)
->assertUnprocessable()
->assertJsonPath('message', 'Validation failed.')
->assertJsonValidationErrors('containers');
Queue::assertNotPushed(PushServerUpdateJob::class);
expect($this->server->fresh()->sentinel_updated_at)->toBe($originalHeartbeat);
expect(Cache::has('sentinel:push-hash:'.$this->server->id))->toBeFalse();
expect(Cache::has('sentinel:push-force:'.$this->server->id))->toBeFalse();
})->with([
'missing containers' => [[]],
'non-array containers' => [['containers' => 'not-an-array']],
]);
it('guards the dedupe decision with a server scoped atomic cache lock', function () {
$controller = file_get_contents(app_path('Http/Controllers/Api/SentinelController.php'));
expect($controller)
->toContain('$lockKey = "sentinel:push-lock:{$server->id}";')
->toContain('Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool')
->toContain('Cache::put($hashKey, $hash, now()->addDay())')
->toContain("Cache::put(\$forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300))");
});
it('dispatches the job when container state changes', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
@@ -78,6 +138,16 @@ it('dispatches the job when container state changes', function () use ($running)
Queue::assertPushed(PushServerUpdateJob::class, 2);
});
it('ignores health status changes while container lifecycle state is unchanged', function () {
$healthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
$unhealthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'unhealthy']];
pushSentinel($this->token, sentinelPayload($healthy))->assertOk();
pushSentinel($this->token, sentinelPayload($unhealthy))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('ignores disk percentage changes (excluded from the hash)', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 42.0))->assertOk();
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 88.0))->assertOk();
-176
View File
@@ -1,12 +1,10 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Jobs\CleanupStaleMultiplexedConnections;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
@@ -116,177 +114,3 @@ it('returns false and runs no process when multiplexing is globally disabled', f
Process::assertNothingRan();
});
it('kills only old orphaned ssh masters whose control socket no longer exists', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$liveSocket = $muxDir.'/mux_live_'.uniqid();
$orphanSocket = $muxDir.'/mux_orphan_'.uniqid();
$youngSocket = $muxDir.'/mux_young_'.uniqid();
File::put($liveSocket, 'x');
Process::fake([
'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$liveSocket} root@1.2.3.4\n".
"222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4\n".
"333 1 30 ssh -fN -o ControlMaster=auto -o ControlPath={$youngSocket} root@1.2.3.4\n"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '333'));
File::delete($liveSocket);
});
it('kills old orphaned native openssh mux masters whose control socket no longer exists', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$liveSocket = $muxDir.'/mux_native_live_'.uniqid();
$orphanSocket = $muxDir.'/mux_native_orphan_'.uniqid();
File::put($liveSocket, 'x');
Process::fake([
'ps*' => Process::result(output: "111 1 5000 ssh: {$liveSocket} [mux]\n".
"222 1 5000 ssh: {$orphanSocket} [mux]\n"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
File::delete($liveSocket);
});
it('kills only old orphaned cloudflared proxies whose parent ssh is gone', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
Process::fake([
'ps*' => Process::result(output: "100 1 5000 ssh -fN -o ControlMaster=auto root@1.2.3.4\n".
"200 100 5000 cloudflared access ssh --hostname host.example.com\n".
"300 2176 5000 cloudflared access ssh --hostname host.example.com\n".
"400 2176 30 cloudflared access ssh --hostname host.example.com\n".
"2176 1 9000 /usr/bin/some-supervisor\n"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupOrphanedCloudflaredProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '300'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '200'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '400'));
});
it('dry-run mode logs orphans but kills nothing when reaping is disabled', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => false]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$orphanSocket = $muxDir.'/mux_orphan_'.uniqid();
Process::fake([
'ps*' => Process::result(output: "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4\n"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill'));
});
it('resets duplicate ssh mux process groups atomically when reaping is enabled', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$controlPath = $muxDir.'/mux_duplicate_'.uniqid();
File::put($controlPath, 'socket');
Process::fake([
'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$controlPath} root@1.2.3.4\n".
"222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$controlPath} root@1.2.3.4\n"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupDuplicateSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222'));
expect(file_exists($controlPath))->toBeFalse();
});
it('resets duplicate native openssh mux process groups atomically when reaping is enabled', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$controlPath = $muxDir.'/mux_native_duplicate_'.uniqid();
File::put($controlPath, 'socket');
Process::fake([
'ps*' => Process::result(output: "111 1 5000 ssh: {$controlPath} [mux]\n".
"222 1 5000 ssh: {$controlPath} [mux]\n"),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupDuplicateSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222'));
expect(file_exists($controlPath))->toBeFalse();
});
it('removes mux files for non-existent servers when reaping is enabled', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
Storage::fake('ssh-mux');
$file = 'mux_ghost'.uniqid();
Storage::disk('ssh-mux')->put($file, 'x');
Process::fake();
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections');
$method->setAccessible(true);
$method->invoke($job);
expect(Storage::disk('ssh-mux')->exists($file))->toBeFalse();
});
it('keeps mux files for non-existent servers in dry-run mode', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => false]);
Storage::fake('ssh-mux');
$file = 'mux_ghost'.uniqid();
Storage::disk('ssh-mux')->put($file, 'x');
Process::fake();
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections');
$method->setAccessible(true);
$method->invoke($job);
expect(Storage::disk('ssh-mux')->exists($file))->toBeTrue();
Process::assertNothingRan();
});
+48
View File
@@ -0,0 +1,48 @@
<?php
use Illuminate\Support\Facades\Http;
function createSyncBunnyFailingBinary(string $binDir, string $name): void
{
file_put_contents("{$binDir}/{$name}", <<<'SH'
#!/bin/sh
printf '%s %s\n' "$(basename "$0")" "$*" >> "$SYNC_BUNNY_TEST_LOG"
exit 1
SH);
chmod("{$binDir}/{$name}", 0755);
}
it('syncs nightly versions to BunnyCDN without creating a GitHub PR', function () {
Http::fake([
'storage.bunnycdn.com/*' => Http::response([], 201),
'api.bunny.net/purge*' => Http::response([], 200),
]);
$binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
$logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
mkdir($binDir, 0755, true);
createSyncBunnyFailingBinary($binDir, 'gh');
createSyncBunnyFailingBinary($binDir, 'git');
$originalPath = getenv('PATH') ?: '';
putenv("PATH={$binDir}:{$originalPath}");
putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
try {
$this->artisan('sync:bunny --release --nightly')
->expectsConfirmation('Are you sure you want to proceed?', 'yes')
->expectsOutputToContain('BunnyCDN sync: ✓ Complete')
->doesntExpectOutputToContain('GitHub PR')
->assertExitCode(0);
} finally {
putenv("PATH={$originalPath}");
putenv('SYNC_BUNNY_TEST_LOG');
}
expect(file_exists($logFile))->toBeFalse();
Http::assertSent(fn ($request) => $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify-nightly/versions.json');
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
&& $request['url'] === 'https://cdn.coollabs.io/coolify-nightly/versions.json');
});
+32
View File
@@ -0,0 +1,32 @@
<?php
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('attaches the root user as owner when reusing an existing root team', function () {
Team::factory()->create(['id' => 0, 'name' => 'Existing Root Team']);
$rootUser = User::factory()->create(['id' => 0]);
expect($rootUser->teams()->whereKey(0)->first()?->pivot?->role)->toBe('owner');
});
it('promotes the root user to owner when the reused root team pivot already exists', function () {
Team::factory()->create(['id' => 0, 'name' => 'Existing Root Team']);
DB::table('team_user')->insert([
'team_id' => 0,
'user_id' => 0,
'role' => 'member',
'created_at' => now(),
'updated_at' => now(),
]);
$rootUser = User::factory()->create(['id' => 0]);
expect($rootUser->teams()->whereKey(0)->first()?->pivot?->role)->toBe('owner');
});
@@ -0,0 +1,64 @@
<?php
use Illuminate\Support\Env;
function databaseConfigWithEnvironment(array $overrides): array
{
$keys = [
'DB_HOST',
'DB_READ_HOST',
'DB_WRITE_HOST',
];
$repository = Env::getRepository();
$original = [];
foreach ($keys as $key) {
$original[$key] = env($key);
$repository->clear($key);
}
try {
foreach ($overrides as $key => $value) {
$repository->set($key, (string) $value);
}
return require __DIR__.'/../../config/database.php';
} finally {
foreach ($keys as $key) {
$repository->clear($key);
if ($original[$key] !== null) {
$repository->set($key, (string) $original[$key]);
}
}
}
}
it('trims and filters read hosts from comma separated values', function () {
$config = databaseConfigWithEnvironment([
'DB_READ_HOST' => ' read-1, read-2, ',
]);
expect($config['connections']['pgsql']['read']['host'])->toBe(['read-1', 'read-2']);
});
it('falls back to db host when write host is empty', function () {
$config = databaseConfigWithEnvironment([
'DB_HOST' => 'primary-db',
'DB_READ_HOST' => 'read-db',
'DB_WRITE_HOST' => '',
]);
expect($config['connections']['pgsql']['write']['host'])->toBe(['primary-db']);
});
it('falls back to the default host when write host and db host are empty', function () {
$config = databaseConfigWithEnvironment([
'DB_HOST' => '',
'DB_READ_HOST' => 'read-db',
'DB_WRITE_HOST' => '',
]);
expect($config['connections']['pgsql']['write']['host'])->toBe(['coolify-db']);
});
@@ -93,8 +93,8 @@ it('detects environment variable value changes without exposing secret values',
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBe('Changed')
->and($change['old_display_value'])->toBe('Set')
->and($change['new_display_value'])->toBe('Set')
->and($change['old_display_value'])->toBe('••••••••')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
});
@@ -117,7 +117,7 @@ it('describes added environment variables as set without exposing secret values'
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('Not set')
->and($change['new_display_value'])->toBe('Set')
->and($change['old_display_value'])->toBe('-')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
});
+70 -3
View File
@@ -1,6 +1,10 @@
<?php
use App\Models\S3Storage;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class);
test('S3Storage model has correct cast definitions', function () {
$s3Storage = new S3Storage;
@@ -45,9 +49,72 @@ test('S3Storage awsUrl method constructs correct URL format', function () {
expect($s3Storage->awsUrl())->toBe('https://minio.example.com:9000/backups');
});
test('S3Storage model is guarded correctly', function () {
test('S3Storage model fillable attributes are configured correctly', function () {
$s3Storage = new S3Storage;
// The model should have $guarded = [] which means everything is fillable
expect($s3Storage->getGuarded())->toBe([]);
expect($s3Storage->getFillable())->toBe([
'name',
'description',
'region',
'key',
'secret',
'bucket',
'endpoint',
'is_usable',
'unusable_email_sent',
]);
});
test('S3Storage connection validation uses short s3 client timeouts', function () {
$disk = Mockery::mock();
$disk->expects('files')->once()->andReturn([]);
Storage::expects('build')
->once()
->with(Mockery::on(function (array $config) {
expect($config['http']['connect_timeout'])->toBe(15);
expect($config['http']['timeout'])->toBe(15);
return true;
}))
->andReturn($disk);
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => null,
'secret' => null,
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
]);
$s3Storage->testConnection();
expect($s3Storage->is_usable)->toBeTrue();
});
test('S3Storage connection validation returns friendly timeout error', function () {
$disk = Mockery::mock();
$disk->expects('files')
->once()
->andThrow(new RuntimeException('cURL error 28: Operation timed out after 15000 milliseconds'));
Storage::expects('build')->once()->andReturn($disk);
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => null,
'secret' => null,
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.amazonaws.com',
'unusable_email_sent' => true,
]);
expect(fn () => $s3Storage->testConnection())
->toThrow(RuntimeException::class, 'Could not connect to the S3 endpoint within 15 seconds.');
expect($s3Storage->is_usable)->toBeFalse();
});
+15 -3
View File
@@ -1,6 +1,7 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Rules\ValidHostname;
use App\Rules\ValidServerIp;
@@ -57,20 +58,20 @@ it('rejects injection payloads in server ip', function (string $payload) {
// -------------------------------------------------------------------------
it('strips dangerous characters from server ip on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->ip = '192.168.1.1;rm -rf /';
// Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed
expect($server->ip)->toBe('192.168.1.1rm-rf');
});
it('strips dangerous characters from server user on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->user = 'root$(id)';
expect($server->user)->toBe('rootid');
});
it('strips non-numeric characters from server port on write', function () {
$server = new App\Models\Server;
$server = new Server;
$server->port = '22; evil';
expect($server->port)->toBe(22);
});
@@ -102,6 +103,17 @@ it('has no raw user@ip string interpolation in SshMultiplexingHelper', function
expect($source)->not->toContain('{$server->user}@{$server->ip}');
});
it('escapes scp source and destination operands', function () {
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
$source = file_get_contents($reflection->getFileName());
expect($source)
->toContain('escapeshellarg($source)')
->toContain('escapeshellarg($dest)')
->not->toContain('"{$source} "')
->not->toContain('":{$dest}"');
});
// -------------------------------------------------------------------------
// ValidHostname rejects shell metacharacters
// -------------------------------------------------------------------------